From bd6cf334a3fe7bc732f3947647b7f77979538935 Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Wed, 29 Apr 2026 19:43:05 -0300 Subject: [PATCH 1/2] chore(infra/website): default manage_apex/www_records to true post-cutover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- infra/terraform/website/variables.tf | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/infra/terraform/website/variables.tf b/infra/terraform/website/variables.tf index 5105151439..31aab8bdfa 100644 --- a/infra/terraform/website/variables.tf +++ b/infra/terraform/website/variables.tf @@ -65,20 +65,19 @@ variable "csp_report_only" { } variable "manage_apex_records" { - # Leave false until Phase 4 cutover. When false, the apex records are not managed - # by this module (they already exist — manually created in Route53, not - # External-DNS-owned). Flip to true after importing the existing records. + # Phase 4 cutover is complete (see #1470). Records were imported into state + # and Terraform now owns them. Flag retained as an escape hatch for emergency + # rollback — set to false to release ownership without destroying the records + # (use `terraform state rm` after flipping). description = "Whether Terraform manages the iii.dev apex A/AAAA Route53 records." type = bool - default = false + default = true } variable "manage_www_records" { - # Leave false until www.iii.dev has been released by External-DNS (argocd PR - # removing the iii-dev-www Ingress has merged + synced). Flip to true after - # importing the existing records. Decoupled from apex so the two can be - # cut over independently to limit blast radius. + # Phase 4 cutover complete; same situation as manage_apex_records. Decoupled + # from apex so the two can be released independently if ever needed. description = "Whether Terraform manages the www.iii.dev A/AAAA Route53 records." type = bool - default = false + default = true } From b2338cfdd7dedde62f8fe8fc356ab0ba61630d92 Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Wed, 29 Apr 2026 19:49:38 -0300 Subject: [PATCH 2/2] chore(infra/website): add tf-apply pipeline + clean variable comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/workflows/tf-apply.yml | 87 ++++++++++++++++++++++ infra/terraform/website/iam_github_oidc.tf | 40 +++++++++- infra/terraform/website/outputs.tf | 5 ++ infra/terraform/website/variables.tf | 14 ++-- 4 files changed, 138 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/tf-apply.yml diff --git a/.github/workflows/tf-apply.yml b/.github/workflows/tf-apply.yml new file mode 100644 index 0000000000..9d713797d0 --- /dev/null +++ b/.github/workflows/tf-apply.yml @@ -0,0 +1,87 @@ +name: Terraform Apply + +on: + push: + branches: [main] + paths: + - 'infra/terraform/website/**' + - '.github/workflows/tf-apply.yml' + workflow_dispatch: + inputs: + ref: + description: 'Git ref to apply (default: current default branch)' + required: false + type: string + +concurrency: + group: tf-apply-website + cancel-in-progress: false + +permissions: + contents: read + id-token: write + +jobs: + apply: + name: terraform apply (infra/terraform/website) + runs-on: ubuntu-latest + environment: iii-website-prod-tf-apply + timeout-minutes: 15 + + env: + AWS_REGION: us-east-1 + TF_IN_AUTOMATION: 'true' + + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref || github.ref }} + + - uses: hashicorp/setup-terraform@v3 + with: + terraform_version: '1.9.8' + terraform_wrapper: false + + - name: Configure AWS credentials (GitHub OIDC) + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ secrets.AWS_TF_APPLY_ROLE_ARN }} + aws-region: ${{ env.AWS_REGION }} + + - name: Terraform init + working-directory: infra/terraform/website + run: terraform init -input=false + + - name: Terraform apply + id: apply + working-directory: infra/terraform/website + env: + TF_VAR_alarm_email: ${{ secrets.ALARM_EMAIL }} + run: | + set -o pipefail + terraform apply -input=false -auto-approve -no-color 2>&1 | tee apply.txt + { + echo 'apply<> "$GITHUB_OUTPUT" + + - 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 '
Apply output' + echo + echo '```' + echo "${APPLY:-(no apply output captured)}" + echo '```' + echo + echo '
' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/infra/terraform/website/iam_github_oidc.tf b/infra/terraform/website/iam_github_oidc.tf index d504f9d422..43bfa64df8 100644 --- a/infra/terraform/website/iam_github_oidc.tf +++ b/infra/terraform/website/iam_github_oidc.tf @@ -87,8 +87,44 @@ resource "aws_iam_role_policy_attachment" "github_deploy_website" { policy_arn = aws_iam_policy.github_deploy_website.arn } -# Read-only role for `tf-plan.yml`. Separate from the deploy role so a malicious -# PR can't accidentally run `terraform apply` with write permissions. +data "aws_iam_policy_document" "github_tf_apply_trust" { + statement { + effect = "Allow" + actions = ["sts:AssumeRoleWithWebIdentity"] + + principals { + type = "Federated" + identifiers = [aws_iam_openid_connect_provider.github.arn] + } + + condition { + test = "StringEquals" + variable = "token.actions.githubusercontent.com:aud" + values = ["sts.amazonaws.com"] + } + + condition { + test = "StringEquals" + variable = "token.actions.githubusercontent.com:sub" + values = [ + "repo:${var.github_repo}:environment:${var.github_tf_apply_environment}", + ] + } + } +} + +resource "aws_iam_role" "github_tf_apply" { + name = "iii-website-prod-github-tf-apply" + description = "Assumed by GitHub Actions from the ${var.github_tf_apply_environment} environment to run `terraform apply` against infra/terraform/website. Configure that environment with required reviewers in repo settings to gate applies." + assume_role_policy = data.aws_iam_policy_document.github_tf_apply_trust.json + max_session_duration = 3600 +} + +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" +} + data "aws_iam_policy_document" "github_tf_plan_trust" { statement { effect = "Allow" diff --git a/infra/terraform/website/outputs.tf b/infra/terraform/website/outputs.tf index 0e3a8a1fea..131ce27087 100644 --- a/infra/terraform/website/outputs.tf +++ b/infra/terraform/website/outputs.tf @@ -38,6 +38,11 @@ output "github_tf_plan_role_arn" { value = aws_iam_role.github_tf_plan.arn } +output "github_tf_apply_role_arn" { + description = "Admin IAM role ARN assumed by the tf-apply GitHub Actions workflow on push to main — set as repo-level GitHub secret AWS_TF_APPLY_ROLE_ARN" + value = aws_iam_role.github_tf_apply.arn +} + output "sns_alarms_topic_arn" { description = "SNS topic ARN that receives production alarms" value = aws_sns_topic.alarms.arn diff --git a/infra/terraform/website/variables.tf b/infra/terraform/website/variables.tf index 31aab8bdfa..7ab6ba226e 100644 --- a/infra/terraform/website/variables.tf +++ b/infra/terraform/website/variables.tf @@ -58,6 +58,14 @@ variable "github_environment" { default = "iii-website-prod" } +variable "github_tf_apply_environment" { + # Distinct from github_environment so the apply env can be configured with + # required reviewers without gating routine S3 deploys. + description = "GitHub environment scoping the tf-apply role. Configure required reviewers on this env in repo settings to gate applies." + type = string + default = "iii-website-prod-tf-apply" +} + variable "csp_report_only" { description = "Send CSP as report-only instead of enforcing." type = bool @@ -65,18 +73,12 @@ variable "csp_report_only" { } variable "manage_apex_records" { - # Phase 4 cutover is complete (see #1470). Records were imported into state - # and Terraform now owns them. Flag retained as an escape hatch for emergency - # rollback — set to false to release ownership without destroying the records - # (use `terraform state rm` after flipping). description = "Whether Terraform manages the iii.dev apex A/AAAA Route53 records." type = bool default = true } variable "manage_www_records" { - # Phase 4 cutover complete; same situation as manage_apex_records. Decoupled - # from apex so the two can be released independently if ever needed. description = "Whether Terraform manages the www.iii.dev A/AAAA Route53 records." type = bool default = true