Skip to content

chore(infra/website): tf-apply pipeline + restore Route53-record management#1580

Open
ytallo wants to merge 2 commits intomainfrom
chore/iii-website-tf-state-drift
Open

chore(infra/website): tf-apply pipeline + restore Route53-record management#1580
ytallo wants to merge 2 commits intomainfrom
chore/iii-website-tf-state-drift

Conversation

@ytallo
Copy link
Copy Markdown
Contributor

@ytallo ytallo commented Apr 29, 2026

Summary

Two coupled changes that together make infra/terraform/website/ actually applyable from CI without nuking prod DNS.

1. Plan: 4 to destroy0 to destroy

terraform plan against prod was showing 4 production DNS records as will be destroyed:

- aws_route53_record.apex_a[0]    iii.dev      A    → CloudFront alias
- aws_route53_record.apex_aaaa[0] iii.dev      AAAA → CloudFront alias
- aws_route53_record.www_a[0]     www.iii.dev  A    → CloudFront alias
- aws_route53_record.www_aaaa[0]  www.iii.dev  AAAA → CloudFront alias

manage_apex_records and manage_www_records defaulted to false — gates from #1470's Phase-4 cutover. The records were imported into state during cutover (Terraform now owns them), but the defaults stayed falsecount = 0 while state has 1 → plan wants to destroy.

Cutover has been live since 2026-04. Flip both defaults to true. Stripped the now-stale comment blocks above each variable (the description field already says what the flags do).

2. tf-apply pipeline

There was no apply pipeline. tf-plan.yml runs on PRs but applies were fully manual — which is why the cleanUrls fix from #1576 sat unapplied for hours after merge.

New .github/workflows/tf-apply.yml:

  • Trigger: push to main touching infra/terraform/website/** or the workflow itself, plus workflow_dispatch for ad-hoc applies on a chosen ref.
  • Concurrency: tf-apply-website with cancel-in-progress: false so we never interrupt an in-flight apply.
  • Auth: new iii-website-prod-github-tf-apply IAM role assumed via OIDC. AdministratorAccess (the trust scope is the safety boundary). Trust is narrowly conditioned on repo:iii-hq/iii:environment:iii-website-prod-tf-apply — a new GitHub environment, separate from the existing iii-website-prod env, so repo settings can gate applies behind required reviewers without slowing routine S3 syncs.
  • Output: apply tail appended to the job summary.

Plan against prod

AWS_PROFILE=motia-prod terraform plan — three iterations:

Before this PR After flag flip After tf-apply role
1 add, 1 change, 4 destroy 🚨 1 add, 1 change, 0 destroy 2 add, 0 change, 0 destroy

The earlier 1 add (recreating aws_sns_topic_subscription.email) and 1 change (cosmetic CF Function reformat from a local-formatter-touched targeted apply) settled out by the third plan. Everything left is the two new IAM resources for the tf-apply role.

Test plan

  • terraform validate, terraform fmt -check -recursive clean.
  • terraform plan against prod → 2 to add, 0 to change, 0 to destroy.
  • Bootstrap (one-time, manual):
    1. AWS_PROFILE=motia-prod terraform apply from a laptop — creates the new role.
    2. Grab github_tf_apply_role_arn from outputs; set as repo secret AWS_TF_APPLY_ROLE_ARN.
    3. Create iii-website-prod-tf-apply GitHub environment in repo settings; add required reviewers if you want manual approval gating.
  • After bootstrap, verify the workflow runs green on the next push touching infra/terraform/website/**.

Summary by CodeRabbit

  • New Features

    • Added a GitHub Actions workflow to run Terraform applies with manual dispatch and a summarized apply output.
  • Chores

    • DNS ownership defaults updated: apex and www records now default to being managed via infrastructure-as-code.
  • Security

    • Added a dedicated deployment IAM role and an output to expose its ARN for the deploy workflow to consume (intended for storage as a repository secret).

…tover

The Phase-4 cutover gate (manage_apex_records, manage_www_records = false)
was for the migration to S3+CloudFront in #1470. The records were imported
into state during that cutover, but the variable defaults stayed false —
so `terraform plan` has been showing 4 production DNS records (iii.dev
A/AAAA + www.iii.dev A/AAAA) as "will be destroyed" because count is now
0 while the resources still exist in state.

Flip the defaults to true. State and config now agree; plan no longer
proposes destroying the apex/www records. Flag retained as an escape
hatch for emergency rollback.

Refs: #1470 (Phase-4 S3+CloudFront cutover)
@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented Apr 29, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
iii-website Ready Ready Preview, Comment Apr 29, 2026 10:50pm

Request Review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 29, 2026

📝 Walkthrough

Walkthrough

Adds a GitHub Actions “Terraform Apply” workflow and a dedicated OIDC IAM role for tf-apply; exposes the role ARN as a Terraform output; introduces github_tf_apply_environment variable; and changes Route53 management defaults (manage_apex_records and manage_www_records) to true.

Changes

Cohort / File(s) Summary
Terraform variables
infra/terraform/website/variables.tf
Add variable "github_tf_apply_environment" (string, default iii-website-prod-tf-apply). Change defaults: manage_apex_records and manage_www_records from falsetrue. Remove/modify prior inline cutover docs.
GitHub Actions workflow
.github/workflows/tf-apply.yml
New "Terraform Apply" workflow: triggers on pushes to main for website infra and manual dispatch (optional ref), checks out specified ref, installs Terraform, assumes AWS role via OIDC, runs terraform init & terraform apply, captures and exposes truncated apply output.
IAM / OIDC
infra/terraform/website/iam_github_oidc.tf
Add new OIDC trust policy data github_tf_apply_trust scoped to github_tf_apply_environment, create aws_iam_role.github_tf_apply, and attach AdministratorAccess via aws_iam_role_policy_attachment.github_tf_apply_admin.
Terraform outputs
infra/terraform/website/outputs.tf
Add github_tf_apply_role_arn output exposing the ARN of aws_iam_role.github_tf_apply (intended for GitHub secret AWS_TF_APPLY_ROLE_ARN).

Sequence Diagram(s)

sequenceDiagram
    participant Dev as Developer (push / dispatch)
    participant GH as GitHub Actions
    participant OIDC as GitHub OIDC
    participant AWS as AWS STS/IAM
    participant TF as Terraform (in repo)
    participant AWSResources as AWS (Route53, etc.)

    Dev->>GH: push to main or manual dispatch (optional ref)
    GH->>OIDC: request OIDC token (sub includes github_tf_apply_environment)
    OIDC->>AWS: present token to assume role
    AWS->>GH: return temporary credentials (assume role github_tf_apply)
    GH->>TF: run terraform init && terraform apply (with creds)
    TF->>AWSResources: modify infra (Route53, IAM, etc.)
    TF-->>GH: stream apply output
    GH->>GH: capture and truncate apply.txt, post summary in workflow
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Poem

🐰 I hopped through vars and roles tonight,

I bound a GitHub trust so right,
A workflow hums, assumes my name,
Applies the plan, records the change,
A carrot-cheer for infra’s bright delight!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title 'chore(infra/website): tf-apply pipeline + restore Route53-record management' accurately summarizes the two main changes: introducing the tf-apply pipeline and fixing Route53 record management defaults.
Description check ✅ Passed The PR description comprehensively covers the What, Why, and necessary context following the template structure. It includes detailed explanations of both changes, verification steps, and bootstrap instructions for reviewers.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch chore/iii-website-tf-state-drift

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
Review rate limit: 6/8 reviews remaining, refill in 8 minutes and 40 seconds.

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link
Copy Markdown
Contributor

Terraform plan — infra/terraform/website

Click to expand
aws_cloudfront_origin_access_control.site: Refreshing state... [id=E3GRB5YVVA4332]
aws_iam_openid_connect_provider.github: Refreshing state... [id=arn:aws:iam::600627348446:oidc-provider/token.actions.githubusercontent.com]
data.aws_route53_zone.iii_dev: Reading...
aws_cloudfront_function.redirects: Refreshing state... [id=iii-website-prod-redirects]
aws_sns_topic.alarms: Refreshing state... [id=arn:aws:sns:us-east-1:600627348446:iii-website-prod-alarms]
aws_acm_certificate.site: Refreshing state... [id=arn:aws:acm:us-east-1:600627348446:certificate/b8e26c06-08b6-4b90-bd59-1f83bc231db2]
aws_cloudfront_response_headers_policy.site: Refreshing state... [id=033b1d66-8d53-48a1-a817-8280d8a60114]
data.aws_caller_identity.current: Reading...
aws_s3_bucket.site: Refreshing state... [id=iii-website-prod-us-east-1]
data.aws_caller_identity.current: Read complete after 0s [id=600627348446]
data.aws_iam_policy_document.github_trust: Reading...
data.aws_iam_policy_document.github_tf_plan_trust: Reading...
data.aws_iam_policy_document.github_trust: Read complete after 0s [id=932063200]
data.aws_iam_policy_document.github_tf_plan_trust: Read complete after 0s [id=2702237784]
aws_iam_role.github_deploy_website: Refreshing state... [id=iii-website-prod-github-deploy]
aws_iam_role.github_tf_plan: Refreshing state... [id=iii-infra-github-tf-plan]
aws_sns_topic_subscription.email: Refreshing state... [id=arn:aws:sns:us-east-1:600627348446:iii-website-prod-alarms:e50a5c41-0aa3-4855-83ad-8a914562ff95]
data.aws_route53_zone.iii_dev: Read complete after 0s [id=Z05516132AI1ZGB3NLC6D]
aws_cloudwatch_metric_alarm.acm_days_to_expiry: Refreshing state... [id=iii-website-prod-acm-days-to-expiry]
aws_route53_record.cert_validation["www.iii.dev"]: Refreshing state... [id=Z05516132AI1ZGB3NLC6D__2bb3298c0bcca2494fce5e36c3d1427d.www.iii.dev._CNAME]
aws_route53_record.cert_validation["iii-preview.iii.dev"]: Refreshing state... [id=Z05516132AI1ZGB3NLC6D__0e43dc21d86b26a03742fb15aa5b4558.iii-preview.iii.dev._CNAME]
aws_route53_record.cert_validation["iii.dev"]: Refreshing state... [id=Z05516132AI1ZGB3NLC6D__7853369f3aea55a5d242d7c68506980e.iii.dev._CNAME]
aws_iam_role_policy_attachment.github_tf_plan_readonly: Refreshing state... [id=iii-infra-github-tf-plan-20260414113923643200000001]
aws_s3_bucket_public_access_block.site: Refreshing state... [id=iii-website-prod-us-east-1]
aws_s3_bucket_versioning.site: Refreshing state... [id=iii-website-prod-us-east-1]
aws_s3_bucket_server_side_encryption_configuration.site: Refreshing state... [id=iii-website-prod-us-east-1]
aws_acm_certificate_validation.site: Refreshing state... [id=2026-04-14 00:00:29.305 +0000 UTC]
aws_cloudfront_distribution.site: Refreshing state... [id=E3N35RPQE4YVFQ]
aws_route53_record.preview_aaaa: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_iii-preview.iii.dev_AAAA]
aws_route53_record.apex_a[0]: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_iii.dev_A]
aws_route53_record.www_a[0]: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_www.iii.dev_A]
data.aws_iam_policy_document.github_deploy_website: Reading...
aws_route53_record.www_aaaa[0]: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_www.iii.dev_AAAA]
data.aws_iam_policy_document.site_bucket: Reading...
aws_cloudwatch_metric_alarm.cf_5xx_rate: Refreshing state... [id=iii-website-prod-cf-5xx-rate]
aws_route53_record.preview_a: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_iii-preview.iii.dev_A]
aws_route53_record.apex_aaaa[0]: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_iii.dev_AAAA]
data.aws_iam_policy_document.github_deploy_website: Read complete after 0s [id=1235650417]
data.aws_iam_policy_document.site_bucket: Read complete after 0s [id=2990320740]
aws_iam_policy.github_deploy_website: Refreshing state... [id=arn:aws:iam::600627348446:policy/iii-website-prod-github-deploy]
aws_s3_bucket_policy.site: Refreshing state... [id=iii-website-prod-us-east-1]
aws_iam_role_policy_attachment.github_deploy_website: Refreshing state... [id=iii-website-prod-github-deploy-20260414000337121000000003]

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  + create
  ~ update in-place

Terraform will perform the following actions:

  # aws_cloudfront_function.redirects will be updated in-place
  ~ resource "aws_cloudfront_function" "redirects" {
      ~ code                         = <<-EOT
            // Viewer-request handler for the default (S3) behavior. Tested in redirects.test.js.
            
            function redirect(location) {
              return {
                statusCode: 301,
                statusDescription: 'Moved Permanently',
                headers: {
                  location: { value: location },
                  'cache-control': { value: 'public, max-age=3600' },
                },
          -   }
          +   };
            }
            
            // CloudFront Functions deliver request.querystring as
            //   { key: { value: string, multiValue?: [{ value: string }, ...] } }
            // where repeated params spill into multiValue. We re-encode and rejoin so the
            // host-redirect path below preserves the original query (otherwise `?a=1&a=2`
            // would silently drop on the 301).
            function serializeQuerystring(qs) {
          -   if (!qs) return ''
          -   var parts = []
          +   if (!qs) return '';
          +   var parts = [];
              for (var key in qs) {
          -     if (!Object.prototype.hasOwnProperty.call(qs, key)) continue
          -     var entry = qs[key]
          -     if (!entry) continue
          -     var encodedKey = encodeURIComponent(key)
          -     var primary = entry.value == null ? '' : entry.value
          -     parts.push(encodedKey + '=' + encodeURIComponent(primary))
          +     if (!Object.prototype.hasOwnProperty.call(qs, key)) continue;
          +     var entry = qs[key];
          +     if (!entry) continue;
          +     var encodedKey = encodeURIComponent(key);
          +     var primary = entry.value == null ? '' : entry.value;
          +     parts.push(encodedKey + '=' + encodeURIComponent(primary));
                if (entry.multiValue && entry.multiValue.length) {
                  for (var i = 0; i < entry.multiValue.length; i++) {
          -         var extra = entry.multiValue[i]
          -         var extraValue = extra && extra.value != null ? extra.value : ''
          -         parts.push(encodedKey + '=' + encodeURIComponent(extraValue))
          +         var extra = entry.multiValue[i];
          +         var extraValue = extra && extra.value != null ? extra.value : '';
          +         parts.push(encodedKey + '=' + encodeURIComponent(extraValue));
                  }
                }
              }
          -   return parts.length ? '?' + parts.join('&') : ''
          +   return parts.length ? '?' + parts.join('&') : '';
            }
            
            // biome-ignore lint/correctness/noUnusedVariables: CloudFront Function entry point
            // biome-ignore lint/complexity/useOptionalChain: cloudfront-js-2.0 does NOT support optional chaining
            function handler(event) {
          -   var request = event.request
          -   var uri = request.uri
          -   var host = request.headers && request.headers.host ? request.headers.host.value : undefined
          +   var request = event.request;
          +   var uri = request.uri;
          +   var host =
          +     request.headers && request.headers.host
          +       ? request.headers.host.value
          +       : undefined;
            
              if (host === 'www.iii.dev') {
          -     return redirect(`https://iii.dev${uri}${serializeQuerystring(request.querystring)}`)
          +     return redirect(
          +       `https://iii.dev${uri}${serializeQuerystring(request.querystring)}`,
          +     );
              }
            
          -   if (uri.indexOf('/.well-known/') === 0) return request
          +   if (uri.indexOf('/.well-known/') === 0) return request;
            
              // Pretty URLs → matching *.html objects in S3 (Option A). Add a key when you
              // ship a new top-level page as `pagename.html`.
              var htmlPretty = {
                '/manifesto': '/manifesto.html',
          -   }
          -   var htmlTarget = htmlPretty[uri]
          +   };
          +   var htmlTarget = htmlPretty[uri];
              if (htmlTarget !== undefined) {
          -     request.uri = htmlTarget
          -     return request
          +     request.uri = htmlTarget;
          +     return request;
              }
            
              // SPA fallback: extensionless path not ending in /
              if (uri !== '/' && uri.charAt(uri.length - 1) !== '/') {
          -     const lastSlash = uri.lastIndexOf('/')
          -     const lastSegment = uri.substring(lastSlash + 1)
          +     const lastSlash = uri.lastIndexOf('/');
          +     const lastSegment = uri.substring(lastSlash + 1);
                if (lastSegment.indexOf('.') === -1) {
          -       request.uri = '/index.html'
          -       return request
          +       request.uri = '/index.html';
          +       return request;
                }
              }
            
          -   return request
          +   return request;
            }
        EOT
        id                           = "iii-website-prod-redirects"
        name                         = "iii-website-prod-redirects"
        # (8 unchanged attributes hidden)
    }

  # aws_sns_topic_subscription.email will be created
  + resource "aws_sns_topic_subscription" "email" {
      + arn                             = (known after apply)
      + confirmation_timeout_in_minutes = 1
      + confirmation_was_authenticated  = (known after apply)
      + endpoint                        = "devops@motia.dev"
      + endpoint_auto_confirms          = false
      + filter_policy_scope             = (known after apply)
      + id                              = (known after apply)
      + owner_id                        = (known after apply)
      + pending_confirmation            = (known after apply)
      + protocol                        = "email"
      + raw_message_delivery            = false
      + topic_arn                       = "arn:aws:sns:us-east-1:600627348446:iii-website-prod-alarms"
    }

Plan: 1 to add, 1 to change, 0 to destroy.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
infra/terraform/website/variables.tf (1)

68-71: ⚡ Quick win

Clarify rollback order to make the “no-destroy” path unambiguous.

The current wording can be read as “flip then later state rm.” Please state explicitly that state removal must happen before any apply after flipping to false, otherwise records can still be planned for deletion.

Proposed wording update
-  # rollback — set to false to release ownership without destroying the records
-  # (use `terraform state rm` after flipping).
+  # rollback: set to false, then remove these records from Terraform state
+  # before any `terraform apply` (`terraform state rm ...`) to release
+  # ownership without planning deletion.

-  # Phase 4 cutover complete; same situation as manage_apex_records. Decoupled
-  # from apex so the two can be released independently if ever needed.
+  # Phase 4 cutover complete; same as manage_apex_records. Decoupled from apex
+  # so each can be released independently (set false + `terraform state rm`
+  # before apply).

Also applies to: 78-79

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@infra/terraform/website/variables.tf` around lines 68 - 71, Update the
comment that documents the escape-hatch flag in variables.tf (the "Phase 4
cutover" flag) to explicitly require running `terraform state rm` before
performing any `terraform apply` after flipping the flag to false; reword the
line that currently says "(use `terraform state rm` after flipping)" to clearly
state "first run `terraform state rm` to remove the records from state, then you
may run any `terraform apply`; do not run `terraform apply` before removing the
records or they may be planned for deletion." Apply the same clarification to
the duplicate comment instance (the occurrence at the second block currently
around lines 78-79).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@infra/terraform/website/variables.tf`:
- Around line 68-71: Update the comment that documents the escape-hatch flag in
variables.tf (the "Phase 4 cutover" flag) to explicitly require running
`terraform state rm` before performing any `terraform apply` after flipping the
flag to false; reword the line that currently says "(use `terraform state rm`
after flipping)" to clearly state "first run `terraform state rm` to remove the
records from state, then you may run any `terraform apply`; do not run
`terraform apply` before removing the records or they may be planned for
deletion." Apply the same clarification to the duplicate comment instance (the
occurrence at the second block currently around lines 78-79).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: e4f1e9af-d73e-4278-8e5b-7b0d17fc9c55

📥 Commits

Reviewing files that changed from the base of the PR and between c9e08ae and bd6cf33.

📒 Files selected for processing (1)
  • infra/terraform/website/variables.tf

Two changes:

1. Strip stale `# Leave false until Phase 4 cutover…` comments from
   manage_apex_records / manage_www_records — the variable description
   field already says what they do, and the cutover narrative they
   preserved is no longer load-bearing.

2. Add a tf-apply pipeline (`.github/workflows/tf-apply.yml`) so changes
   under `infra/terraform/website/` actually deploy on merge to main.
   Previously only `tf-plan.yml` ran on PRs and applies were manual,
   which is how the cleanUrls fix sat unapplied for hours after #1576
   merged.

   - New IAM role `iii-website-prod-github-tf-apply` (AdministratorAccess,
     trust narrowly scoped to a new `iii-website-prod-tf-apply` env so
     repo settings can require reviewers without gating routine S3
     deploys).
   - Workflow runs on push to main + workflow_dispatch, uses concurrency
     `tf-apply-website` to serialize applies, captures output to the job
     summary.

Bootstrap (one-time, manual):
  AWS_PROFILE=motia-prod terraform apply
  → grab `github_tf_apply_role_arn` from outputs
  → set repo secret `AWS_TF_APPLY_ROLE_ARN`
  → create `iii-website-prod-tf-apply` GitHub environment with required
    reviewers
@github-actions
Copy link
Copy Markdown
Contributor

Terraform plan — infra/terraform/website

Click to expand
aws_iam_openid_connect_provider.github: Refreshing state... [id=arn:aws:iam::600627348446:oidc-provider/token.actions.githubusercontent.com]
aws_cloudfront_function.redirects: Refreshing state... [id=iii-website-prod-redirects]
aws_cloudfront_origin_access_control.site: Refreshing state... [id=E3GRB5YVVA4332]
data.aws_caller_identity.current: Reading...
data.aws_route53_zone.iii_dev: Reading...
aws_sns_topic.alarms: Refreshing state... [id=arn:aws:sns:us-east-1:600627348446:iii-website-prod-alarms]
aws_s3_bucket.site: Refreshing state... [id=iii-website-prod-us-east-1]
aws_cloudfront_response_headers_policy.site: Refreshing state... [id=033b1d66-8d53-48a1-a817-8280d8a60114]
aws_acm_certificate.site: Refreshing state... [id=arn:aws:acm:us-east-1:600627348446:certificate/b8e26c06-08b6-4b90-bd59-1f83bc231db2]
data.aws_caller_identity.current: Read complete after 0s [id=600627348446]
data.aws_iam_policy_document.github_tf_apply_trust: Reading...
data.aws_iam_policy_document.github_trust: Reading...
data.aws_iam_policy_document.github_tf_plan_trust: Reading...
data.aws_iam_policy_document.github_trust: Read complete after 0s [id=932063200]
data.aws_iam_policy_document.github_tf_plan_trust: Read complete after 0s [id=2702237784]
data.aws_iam_policy_document.github_tf_apply_trust: Read complete after 0s [id=3342560050]
aws_iam_role.github_deploy_website: Refreshing state... [id=iii-website-prod-github-deploy]
aws_iam_role.github_tf_plan: Refreshing state... [id=iii-infra-github-tf-plan]
aws_sns_topic_subscription.email: Refreshing state... [id=arn:aws:sns:us-east-1:600627348446:iii-website-prod-alarms:489ae5f2-92df-4071-8c35-5d600930b024]
aws_cloudwatch_metric_alarm.acm_days_to_expiry: Refreshing state... [id=iii-website-prod-acm-days-to-expiry]
aws_iam_role_policy_attachment.github_tf_plan_readonly: Refreshing state... [id=iii-infra-github-tf-plan-20260414113923643200000001]
data.aws_route53_zone.iii_dev: Read complete after 1s [id=Z05516132AI1ZGB3NLC6D]
aws_route53_record.cert_validation["iii-preview.iii.dev"]: Refreshing state... [id=Z05516132AI1ZGB3NLC6D__0e43dc21d86b26a03742fb15aa5b4558.iii-preview.iii.dev._CNAME]
aws_route53_record.cert_validation["www.iii.dev"]: Refreshing state... [id=Z05516132AI1ZGB3NLC6D__2bb3298c0bcca2494fce5e36c3d1427d.www.iii.dev._CNAME]
aws_route53_record.cert_validation["iii.dev"]: Refreshing state... [id=Z05516132AI1ZGB3NLC6D__7853369f3aea55a5d242d7c68506980e.iii.dev._CNAME]
aws_s3_bucket_public_access_block.site: Refreshing state... [id=iii-website-prod-us-east-1]
aws_s3_bucket_versioning.site: Refreshing state... [id=iii-website-prod-us-east-1]
aws_s3_bucket_server_side_encryption_configuration.site: Refreshing state... [id=iii-website-prod-us-east-1]
aws_acm_certificate_validation.site: Refreshing state... [id=2026-04-14 00:00:29.305 +0000 UTC]
aws_cloudfront_distribution.site: Refreshing state... [id=E3N35RPQE4YVFQ]
aws_route53_record.preview_aaaa: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_iii-preview.iii.dev_AAAA]
aws_cloudwatch_metric_alarm.cf_5xx_rate: Refreshing state... [id=iii-website-prod-cf-5xx-rate]
aws_route53_record.apex_a[0]: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_iii.dev_A]
data.aws_iam_policy_document.github_deploy_website: Reading...
data.aws_iam_policy_document.site_bucket: Reading...
aws_route53_record.www_a[0]: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_www.iii.dev_A]
aws_route53_record.preview_a: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_iii-preview.iii.dev_A]
aws_route53_record.www_aaaa[0]: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_www.iii.dev_AAAA]
aws_route53_record.apex_aaaa[0]: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_iii.dev_AAAA]
data.aws_iam_policy_document.github_deploy_website: Read complete after 0s [id=1235650417]
data.aws_iam_policy_document.site_bucket: Read complete after 0s [id=2990320740]
aws_iam_policy.github_deploy_website: Refreshing state... [id=arn:aws:iam::600627348446:policy/iii-website-prod-github-deploy]
aws_s3_bucket_policy.site: Refreshing state... [id=iii-website-prod-us-east-1]
aws_iam_role_policy_attachment.github_deploy_website: Refreshing state... [id=iii-website-prod-github-deploy-20260414000337121000000003]

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  + create
  ~ update in-place

Terraform will perform the following actions:

  # aws_cloudfront_function.redirects will be updated in-place
  ~ resource "aws_cloudfront_function" "redirects" {
      ~ code                         = <<-EOT
            // Viewer-request handler for the default (S3) behavior. Tested in redirects.test.js.
            
            function redirect(location) {
              return {
                statusCode: 301,
                statusDescription: 'Moved Permanently',
                headers: {
                  location: { value: location },
                  'cache-control': { value: 'public, max-age=3600' },
                },
          -   };
          +   }
            }
            
            // CloudFront Functions deliver request.querystring as
            //   { key: { value: string, multiValue?: [{ value: string }, ...] } }
            // where repeated params spill into multiValue. We re-encode and rejoin so the
            // host-redirect path below preserves the original query (otherwise `?a=1&a=2`
            // would silently drop on the 301).
            function serializeQuerystring(qs) {
          -   if (!qs) return '';
          -   var parts = [];
          +   if (!qs) return ''
          +   var parts = []
              for (var key in qs) {
          -     if (!Object.prototype.hasOwnProperty.call(qs, key)) continue;
          -     var entry = qs[key];
          -     if (!entry) continue;
          -     var encodedKey = encodeURIComponent(key);
          -     var primary = entry.value == null ? '' : entry.value;
          -     parts.push(encodedKey + '=' + encodeURIComponent(primary));
          +     if (!Object.prototype.hasOwnProperty.call(qs, key)) continue
          +     var entry = qs[key]
          +     if (!entry) continue
          +     var encodedKey = encodeURIComponent(key)
          +     var primary = entry.value == null ? '' : entry.value
          +     parts.push(encodedKey + '=' + encodeURIComponent(primary))
                if (entry.multiValue && entry.multiValue.length) {
                  for (var i = 0; i < entry.multiValue.length; i++) {
          -         var extra = entry.multiValue[i];
          -         var extraValue = extra && extra.value != null ? extra.value : '';
          -         parts.push(encodedKey + '=' + encodeURIComponent(extraValue));
          +         var extra = entry.multiValue[i]
          +         var extraValue = extra && extra.value != null ? extra.value : ''
          +         parts.push(encodedKey + '=' + encodeURIComponent(extraValue))
                  }
                }
              }
          -   return parts.length ? '?' + parts.join('&') : '';
          +   return parts.length ? '?' + parts.join('&') : ''
            }
            
            // biome-ignore lint/correctness/noUnusedVariables: CloudFront Function entry point
            // biome-ignore lint/complexity/useOptionalChain: cloudfront-js-2.0 does NOT support optional chaining
            function handler(event) {
          -   var request = event.request;
          -   var uri = request.uri;
          -   var host =
          -     request.headers && request.headers.host
          -       ? request.headers.host.value
          -       : undefined;
          +   var request = event.request
          +   var uri = request.uri
          +   var host = request.headers && request.headers.host ? request.headers.host.value : undefined
            
              if (host === 'www.iii.dev') {
          -     return redirect(
          -       `https://iii.dev${uri}${serializeQuerystring(request.querystring)}`,
          -     );
          +     return redirect(`https://iii.dev${uri}${serializeQuerystring(request.querystring)}`)
              }
            
          -   if (uri.indexOf('/.well-known/') === 0) return request;
          +   if (uri.indexOf('/.well-known/') === 0) return request
            
              // Pretty URLs → matching *.html objects in S3 (Option A). Add a key when you
              // ship a new top-level page as `pagename.html`.
              var htmlPretty = {
                '/manifesto': '/manifesto.html',
          -   };
          -   var htmlTarget = htmlPretty[uri];
          +   }
          +   var htmlTarget = htmlPretty[uri]
              if (htmlTarget !== undefined) {
          -     request.uri = htmlTarget;
          -     return request;
          +     request.uri = htmlTarget
          +     return request
              }
            
              // SPA fallback: extensionless path not ending in /
              if (uri !== '/' && uri.charAt(uri.length - 1) !== '/') {
          -     const lastSlash = uri.lastIndexOf('/');
          -     const lastSegment = uri.substring(lastSlash + 1);
          +     const lastSlash = uri.lastIndexOf('/')
          +     const lastSegment = uri.substring(lastSlash + 1)
                if (lastSegment.indexOf('.') === -1) {
          -       request.uri = '/index.html';
          -       return request;
          +       request.uri = '/index.html'
          +       return request
                }
              }
            
          -   return request;
          +   return request
            }
        EOT
        id                           = "iii-website-prod-redirects"
        name                         = "iii-website-prod-redirects"
        # (8 unchanged attributes hidden)
    }

  # aws_iam_role.github_tf_apply will be created
  + resource "aws_iam_role" "github_tf_apply" {
      + arn                   = (known after apply)
      + assume_role_policy    = jsonencode(
            {
              + Statement = [
                  + {
                      + Action    = "sts:AssumeRoleWithWebIdentity"
                      + Condition = {
                          + StringEquals = {
                              + "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
                              + "token.actions.githubusercontent.com:sub" = "repo:iii-hq/iii:environment:iii-website-prod-tf-apply"
                            }
                        }
                      + Effect    = "Allow"
                      + Principal = {
                          + Federated = "arn:aws:iam::600627348446:oidc-provider/token.actions.githubusercontent.com"
                        }
                    },
                ]
              + Version   = "2012-10-17"
            }
        )
      + create_date           = (known after apply)
      + description           = "Assumed by GitHub Actions from the iii-website-prod-tf-apply environment to run `terraform apply` against infra/terraform/website. Configure that environment with required reviewers in repo settings to gate applies."
      + force_detach_policies = false
      + id                    = (known after apply)
      + managed_policy_arns   = (known after apply)
      + max_session_duration  = 3600
      + name                  = "iii-website-prod-github-tf-apply"
      + name_prefix           = (known after apply)
      + path                  = "/"
      + tags_all              = {
          + "Environment" = "prod"
          + "ManagedBy"   = "terraform"
          + "Module"      = "infra/terraform/website"
          + "Project"     = "iii"
          + "Service"     = "website"
        }
      + unique_id             = (known after apply)

      + inline_policy (known after apply)
    }

  # aws_iam_role_policy_attachment.github_tf_apply_admin will be created
  + resource "aws_iam_role_policy_attachment" "github_tf_apply_admin" {
      + id         = (known after apply)
      + policy_arn = "arn:aws:iam::aws:policy/AdministratorAccess"
      + role       = "iii-website-prod-github-tf-apply"
    }

Plan: 2 to add, 1 to change, 0 to destroy.

Changes to Outputs:
  + github_tf_apply_role_arn = (known after apply)

@ytallo ytallo changed the title chore(infra/website): default manage_apex/www_records to true post-cutover chore(infra/website): tf-apply pipeline + restore Route53-record management Apr 29, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.github/workflows/tf-apply.yml:
- Around line 69-87: The job currently writes the full terraform apply output
(the APPLY env variable) into $GITHUB_STEP_SUMMARY; change this to avoid
exposing raw logs by replacing the line that echoes "${APPLY:-(no apply output
captured)}" with a short status message (e.g., "Apply completed; see the job log
for full output.") or a redacted/trimmed version, and ensure the modified output
still appends to $GITHUB_STEP_SUMMARY rather than the raw APPLY content; update
the same block that builds the Job summary (the echo lines around APPLY and the
surrounding details tags) so only the safe status text or link is emitted.

In `@infra/terraform/website/iam_github_oidc.tf`:
- Around line 123-126: The role aws_iam_role.github_tf_apply is currently
attached to the overly-broad AWS managed AdministratorAccess via
aws_iam_role_policy_attachment.github_tf_apply_admin; replace this with a scoped
policy by creating a least-privilege aws_iam_policy (e.g.,
aws_iam_policy.github_tf_apply_policy) that only grants the specific
actions/resources the website Terraform needs (state S3 bucket, DynamoDB lock
table, CloudFront/S3 deploy, Route53, etc.), then update
aws_iam_role_policy_attachment.github_tf_apply_admin to use policy_arn =
aws_iam_policy.github_tf_apply_policy. Alternatively, split into separate
narrower roles if apply and other workflows need different scopes and reference
those role names in place of aws_iam_role.github_tf_apply.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: bda7eb0d-6e9f-4447-8357-ad854896fd42

📥 Commits

Reviewing files that changed from the base of the PR and between bd6cf33 and b2338cf.

📒 Files selected for processing (4)
  • .github/workflows/tf-apply.yml
  • infra/terraform/website/iam_github_oidc.tf
  • infra/terraform/website/outputs.tf
  • infra/terraform/website/variables.tf
🚧 Files skipped from review as they are similar to previous changes (1)
  • infra/terraform/website/variables.tf

Comment on lines +69 to +87
- name: Job summary
if: always()
env:
APPLY: ${{ steps.apply.outputs.apply }}
run: |
{
echo "## terraform apply — \`infra/terraform/website\`"
echo
echo "- Commit: \`${{ github.sha }}\`"
echo "- Ref: \`${{ inputs.ref || github.ref }}\`"
echo
echo '<details><summary>Apply output</summary>'
echo
echo '```'
echo "${APPLY:-(no apply output captured)}"
echo '```'
echo
echo '</details>'
} >> "$GITHUB_STEP_SUMMARY"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Trim the raw apply log from the job summary.

Writing the full terraform apply output into $GITHUB_STEP_SUMMARY makes it more durable and easier to skim than the job log, which increases the chance that sensitive diffs or provider error details get surfaced unnecessarily. Consider replacing it with a short status message and a link back to the run logs, or redacting the captured output first.

Suggested change
-            echo '```'
-            echo "${APPLY:-(no apply output captured)}"
-            echo '```'
+            echo "Apply completed; see the job log for full output."
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/tf-apply.yml around lines 69 - 87, The job currently
writes the full terraform apply output (the APPLY env variable) into
$GITHUB_STEP_SUMMARY; change this to avoid exposing raw logs by replacing the
line that echoes "${APPLY:-(no apply output captured)}" with a short status
message (e.g., "Apply completed; see the job log for full output.") or a
redacted/trimmed version, and ensure the modified output still appends to
$GITHUB_STEP_SUMMARY rather than the raw APPLY content; update the same block
that builds the Job summary (the echo lines around APPLY and the surrounding
details tags) so only the safe status text or link is emitted.

Comment on lines +123 to +126
resource "aws_iam_role_policy_attachment" "github_tf_apply_admin" {
role = aws_iam_role.github_tf_apply.name
policy_arn = "arn:aws:iam::aws:policy/AdministratorAccess"
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Replace AdministratorAccess with a scoped policy.

Attaching the AWS managed admin policy gives this GitHub OIDC role full account-wide power, which is far broader than the website module needs and makes a workflow compromise much more damaging. Please scope this to the Terraform resources the module actually manages, or split out a narrower apply role.

🧰 Tools
🪛 Checkov (3.2.525)

[high] 123-126: Disallow IAM roles, users, and groups from using the AWS AdministratorAccess policy

(CKV_AWS_274)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@infra/terraform/website/iam_github_oidc.tf` around lines 123 - 126, The role
aws_iam_role.github_tf_apply is currently attached to the overly-broad AWS
managed AdministratorAccess via
aws_iam_role_policy_attachment.github_tf_apply_admin; replace this with a scoped
policy by creating a least-privilege aws_iam_policy (e.g.,
aws_iam_policy.github_tf_apply_policy) that only grants the specific
actions/resources the website Terraform needs (state S3 bucket, DynamoDB lock
table, CloudFront/S3 deploy, Route53, etc.), then update
aws_iam_role_policy_attachment.github_tf_apply_admin to use policy_arn =
aws_iam_policy.github_tf_apply_policy. Alternatively, split into separate
narrower roles if apply and other workflows need different scopes and reference
those role names in place of aws_iam_role.github_tf_apply.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant