Skip to content

fix(scripts): make init-backend.sh write the state key CI uses - #1142

Merged
IAmJSD merged 2 commits into
LeanerCloud:mainfrom
IAmJSD:fix/issue-306
Oct 25, 2026
Merged

IAmJSD merged 2 commits into
LeanerCloud:mainfrom
IAmJSD:fix/issue-306

Conversation

@IAmJSD

@IAmJSD IAmJSD commented Oct 19, 2026

Copy link
Copy Markdown
Collaborator

Root cause

scripts/init-backend.sh wrote terraform/environments/aws/<env>/backend.tf with key = "<env>/terraform.tfstate" and told the operator to cd into that new, empty directory and run terraform init/plan/apply there. Every deploy workflow does something else: it inits the flat terraform/environments/aws root with -backend-config and key github-<env>/terraform.tfstate for Lambda (deploy-aws-lambda.yml) or github-fargate-<env>/terraform.tfstate for Fargate (deploy-aws-fargate.yml). So the script described a state object and a Terraform root that the pipeline never uses (audit finding A14-035).

Fix

scripts/init-backend.sh:

  • Writes terraform/environments/aws/backends/<env>.tfbackend (already gitignored by *.tfbackend, next to the existing dev.tfbackend.example) with bare -backend-config attributes and the workflows' key, instead of a terraform {} block in a per-env directory. It no longer creates terraform/environments/aws/<env>/.
  • New --compute-platform lambda|fargate flag, default lambda, which picks github-<env>/ or github-fargate-<env>/. Any other value is rejected.
  • Next steps now run from the flat root: terraform init -backend-config=backends/<env>.tfbackend, then terraform plan -var-file=github-<env>.tfvars -var="compute_platform=<platform>", then apply. This is the same shape as CI.
  • Warns that CI builds its backend from the TF_BACKEND_AWS secret, so local and CI runs share one state object only when that secret (for the env's GitHub Environment) names the same bucket.
  • Refuses to run unless terraform/environments/aws/backend.tf exists relative to the working directory. The check runs before any AWS call, so starting the script from the wrong directory no longer creates a stray terraform/ tree there.

The bucket creation and hardening steps are unchanged.

Regression test

scripts/test-init-backend.sh runs the real script against a stubbed aws CLI (on PATH; every call is logged) inside a temp copy of the AWS root. It makes no AWS calls and writes nothing into the working tree. The expected keys are read from deploy-aws-lambda.yml and deploy-aws-fargate.yml, not hard-coded, so the test also fails if the workflows' key changes without the script. It asserts:

  • the backend file lands at backends/<env>.tfbackend, and no per-env root is created
  • the key matches the workflow's key (Lambda via legacy positional args; Fargate via flags)
  • bucket, region and use_lockfile are written, and the file is bare attributes rather than a terraform {} block
  • the printed steps init the flat root with that file and plan with the matching compute_platform, and never cd into terraform/environments/aws/<env>
  • the generated path is gitignored in the repo
  • an unknown --compute-platform, or a run outside the repo root, exits non-zero with zero AWS calls

ci.yml runs it as a new step in the existing aws-tfstate-platform-key job, so ci-success needs no change.

The test builds the compute_platform=<x> strings from a variable on purpose. test-aws-tfstate-platform-key.sh sweeps every script under scripts/, and literal compute_platform=lambda plus compute_platform=fargate in one file show up there as a "MIXED platform" job. I hit that during development and fixed it; the sweep now passes with the new files included (26/26).

Verification

All local, macOS, bash 3.2. This is stub/fixture evidence only: nothing ran against a real AWS account, and no terraform init was run against a real S3 backend.

  • Fail before. I ran bash scripts/test-init-backend.sh against the original script (git show origin/main:scripts/init-backend.sh): 6 passed, 11 failed. The failures are no backends/dev.tfbackend, a terraform/environments/aws/dev directory created, the operator sent into the per-env dir, no compute_platform in the plan step, Fargate flags rejected, and a run outside the repo root still calling aws and creating terraform/.
  • Pass after. bash scripts/test-init-backend.sh: 24 passed, 0 failed.
  • Mutation probes on the fixed script. Each one made the test fail:
    • Lambda key without the github- prefix: 1 fail
    • Fargate key in the Lambda namespace: 1 fail
    • default platform flipped to fargate: 2 fail
    • repo-root guard removed: 3 fail
    • backend file written outside backends/: 3 fail plus skipped content checks
    • region hard-coded: 1 fail
    • plan -var hard-coded to lambda: 1 fail
  • Guard suites that sweep scripts/:
    • bash scripts/test-aws-tfstate-platform-key.sh: 26/26
    • bash scripts/test-ecr-delete-selection.sh: 59/59
    • bash scripts/test-rds-deletion-protection-scope.sh: 58/58
    • bash scripts/test-cloud-sql-delete-scope.sh: 62/62
  • Lint: shellcheck scripts/init-backend.sh scripts/test-init-backend.sh is clean.
  • Workflow lint:
    • actionlint .github/workflows/ci.yml is clean.
    • zizmor==1.29.0 --offline --persona=pedantic --min-severity=medium .github/workflows/ci.yml reports no findings.
  • Go guard that reads ci.yml: AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null go test ./internal/database/postgres/migrations/ -run TestShellMigrationURLs -count=1 passes. The AWS config override is only because this machine's ~/.aws/config default profile is invalid.
  • git-secrets:
    • git-secrets 1.3.0 with --register-aws plus scripts/setup-git-secrets.sh patterns, in a fresh git init repo with an isolated HOME, finds nothing in the three changed files. A positive control (an assigned 12-digit account ID) is caught.
    • bash scripts/test-git-secrets-allowlist.sh: 42 PASS, 0 FAIL.
  • No Go code changed, so Go build/vet/golangci-lint/gocyclo are unaffected.

Out of scope (noted, not changed)

  • docs/DEPLOYMENT.md "State Management". It still shows a backends/prod.tfbackend example with bucket = "cudly-terraform-state-prod" and key = "prod/terraform.tfstate", which is the same stale key in prose. Happy to fix it here or in a follow-up if you want it.
  • The bucket name. The script still creates a per-env cudly-terraform-state-<env> bucket. CI's bucket comes from TF_BACKEND_AWS, which this repo can't see; ci-cd-permissions uses a single cudly-terraform-state-<account> bucket. I didn't add a --bucket option to target CI's existing bucket on purpose: the script runs put-bucket-tagging and put-bucket-lifecycle-configuration, and both replace existing config, so pointing it at a shared, live bucket would be destructive. The new warning tells the operator when local and CI state will actually coincide.
  • Related but separate. The scripts/tf-deploy.sh layout mismatch is tracked in chore(ci): medium/low findings from the 2026-07-28 full review cloud-commitments-cli#1594 (A14-034).
  • Pre-existing. --profile, --region and the other flags still crash under set -u with a confusing message when given without a value.

Maintainer: please mirror the issue's triage labels onto this PR: priority/p2, severity/medium, urgency/this-quarter, impact/internal, effort/s, type/chore, triaged.

Closes #306

🤖 Generated with Claude Code

init-backend.sh wrote terraform/environments/aws/<env>/backend.tf with
key "<env>/terraform.tfstate" and told the operator to init and apply
from that new, empty directory. Every deploy workflow instead inits the
flat terraform/environments/aws root with -backend-config and key
"github-<env>/terraform.tfstate" (Lambda) or
"github-fargate-<env>/terraform.tfstate" (Fargate), so the script
documented a state object and a root the pipeline never uses.

The script now writes terraform/environments/aws/backends/<env>.tfbackend
(gitignored by *.tfbackend) with the workflows' key, takes
--compute-platform lambda|fargate (default lambda) to pick the
namespace, prints init/plan/apply steps for the flat root, and refuses
to run outside the repository root before making any AWS call.

scripts/test-init-backend.sh runs the script against a stubbed aws CLI
in a temp copy of the root and reads the expected keys from
deploy-aws-lambda.yml and deploy-aws-fargate.yml; ci.yml runs it in the
aws-tfstate-platform-key job.

Closes LeanerCloud#306

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

This review includes 3 billable files and costs up to $0.75.

Or wait 59 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

Run configuration
Commits

Reviewing files that changed from the base of the PR and between ff7d191 and b7ac3aa.

Files selected for processing: 3
  • .github/workflows/ci.yml
  • scripts/init-backend.sh
  • scripts/test-init-backend.sh
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

@IAmJSD IAmJSD added priority/p2 Backlog-worthy severity/medium Moderate harm impact/internal Team-internal only type/chore Maintenance / non-user-visible triaged Item has been triaged urgency/this-quarter Within the quarter effort/s Hours labels Oct 19, 2026
@cristim

cristim commented Oct 21, 2026

Copy link
Copy Markdown
Member

Independent review 2 (claude-fleet, cold): APPROVE 898f279

Findings (none blocking):

  • [non-blocking] scripts/init-backend.sh:241,248 - cat > overwrites backends/<env>.tfbackend without a warning.
    • terraform/environments/aws/backends/dev.tfbackend.example:6 and docs/DEPLOYMENT.md:48,122 tell operators to create and edit that file by hand.
    • Lambda and Fargate for the same env also share the one file name.
    • Failure scenario:
      1. An operator has a hand-made dev.tfbackend (their own bucket), or already ran the script for lambda.
      2. They run it with --compute-platform fargate, which replaces the file.
      3. A later terraform init points at a different bucket or key.
    • It is not silent: the old script also overwrote its output, the new header and console output name the key and platform, and a -reconfigure init still needs an explicit operator action.
    • Suggest refusing when the file exists (or adding --force), or naming the file per platform.
  • [non-blocking, pre-existing] init-backend.sh:254-258 - --profile is used for the bucket calls but not written as profile = in the tfbackend.
    • The printed terraform init therefore uses default credentials, which may be a different account.
    • Init fails loudly (AccessDenied or NoSuchBucket), because bucket names are global.
  • [non-blocking, out of scope per the body] docs/DEPLOYMENT.md:168-172 still shows the stale prod/terraform.tfstate key. Worth a follow-up issue.

Against #306: this fixes the root cause.

  • The key now matches the workflows' printf (deploy-aws-lambda.yml:267, deploy-aws-fargate.yml:177,192).
  • The script initialises the flat root, and the plan step uses the matching -var-file/compute_platform (lambda.yml:287-289, fargate.yml:202-205).
  • A bucket-name mismatch with TF_BACKEND_AWS is only warned about. That matches the issue, and the body's reason for not adding --bucket (tagging and lifecycle calls would replace config on a shared bucket) is sound.
  • Blast radius: init-backend.sh is referenced only by the new test, ci.yml and an audit doc.

Attacked:

  • Merge: git merge-tree --write-tree origin/main <head> is clean.
    • Since the base, main changed ci.yml, the AWS deploy workflows (ALERT_EMAILS only, not the key lines) and setup-git-secrets.sh.
    • Everything was re-run on a merged-tree export.
  • Mutation probes on a scratch copy, each restored and confirmed identical with cmp. All 9 were caught:
    1. The old <env>/ lambda key: 1 FAIL.
    2. The Fargate key in the github- namespace: 1 FAIL.
    3. The default platform set to fargate: 2 FAIL.
    4. The repo-root guard disabled: 3 FAIL.
    5. An unknown platform silently defaulted to lambda: 2 FAIL.
    6. The plan -var hard-coded to lambda: 1 FAIL.
    7. Key drift in deploy-aws-lambda.yml: 1 FAIL. This confirms the key is read from the workflow.
    8. use_lockfile=false: 1 FAIL.
    9. *.tfbackend removed from .gitignore: 1 FAIL.
  • Gaps: no test covers --profile passthrough or the overwrite of an existing file (see Findings).

Verified locally, on git-initialised minimal copies of the head, base and merged exports:

  • bash scripts/test-init-backend.sh: 24/0 on head, on the merged tree and with macOS bash 3.2. The working tree is untouched afterwards.
  • Fail-before: the new test on base gives 6 passed and 11 failed, matching the PR body.
  • bash scripts/test-aws-tfstate-platform-key.sh: 26/0 on head and on the merged tree.
  • shellcheck on both scripts and actionlint on ci.yml: clean on head and on the merged tree.
  • go test -count=1 -run TestShellMigrationURLs ./internal/database/postgres/migrations/ on the merged tree, with no cloud credentials: ok.
  • main's git-secrets patterns run over the changed files: clean.
  • No Go, Terraform, frontend or migration changes.

Reviewed by an independent agent with no author context (second reviewer). Review only: nothing was pushed, and the GitHub review state is unchanged.

@IAmJSD

IAmJSD commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator Author

Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: APPROVE 898f2794ca96374ace5f9271988e208bce2377bb

Blocking:

  • none

Non-blocking:

  • scripts/init-backend.sh:283: the printed terraform plan step leaves out the variables CI injects through env (TF_VAR_admin_email, TF_VAR_from_email, TF_VAR_dashboard_url, deploy-aws-lambda.yml:274-285). A local operator will be prompted for them, or plan with empty values. Consider adding a line saying they must be exported first.
  • The bucket half of the mismatch remains, and the body says so. The script still creates and points at cudly-terraform-state-<env>, while CI uses whatever TF_BACKEND_AWS names. If the two differ, following the printed steps exactly applies a complete second stack into a state CI never sees: the same failure mode as the issue, now one variable away instead of two. The new warning names the condition, which is a reasonable stopping point. Still, a follow-up to read the bucket from the operator (for example --bucket, with the destructive tagging and lifecycle calls skipped for existing buckets) would close it properly.
  • docs/DEPLOYMENT.md:179 still shows key = "prod/terraform.tfstate", and the body lists it as out of scope. That fix is small enough to fold in here or file as a follow-up.
  • (From an earlier review, which I agree with:) cat > silently overwrites an existing backends/<env>.tfbackend, including one hand-made per dev.tfbackend.example and docs/DEPLOYMENT.md. Lambda and Fargate also share one file per env even though their keys differ. If an operator runs the script with --compute-platform fargate and later inits for lambda from the same file, the lambda plan runs against the Fargate state. Consider naming the file <env>-<platform>.tfbackend, or refusing to overwrite without --force.

Attacked:

  • Checked the keys against the workflows at this head: deploy-aws-lambda.yml:267 writes github-%s/terraform.tfstate, and deploy-aws-fargate.yml:177,192 write github-fargate-%s/terraform.tfstate. The flat root's backend.tf is a partial backend "s3" {}. *.tfbackend is gitignored (.gitignore:110). github-{dev,staging,prod}.tfvars and variable "compute_platform" exist. The repo-root guard runs before the first aws call. An unknown --compute-platform is rejected. The new CI step is a fixed bash invocation with no ${{ }} interpolation, so there is no injection surface. It sits in the existing job, so ci-success is unaffected.
  • Fail-before: I swapped git show origin/main:scripts/init-backend.sh in and ran bash scripts/test-init-backend.sh: 6 passed, 11 failed. With the PR script restored, 24 passed and 0 failed, and the working tree stayed clean. The test reads the expected key from the workflows rather than hard-coding it, and the stub aws logs every call (zero calls in the wrong-directory and bad-platform cases).
  • Also ran: shellcheck on both scripts (clean), actionlint .github/workflows/ci.yml (clean), and bash scripts/test-aws-tfstate-platform-key.sh (26/26). No Go changed. No terraform init, plan or AWS call was made.

Body-stale:

  • none found

Reviewer: rB1. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths.

Brings the branch up to date with main so the PR is mergeable again.

Conflicted files:
- .github/workflows/ci.yml: the branch appended a "Run init-backend.sh
  self-test" step to the aws-tfstate-platform-key job, and main added the
  tfstate-concurrency-group (LeanerCloud#1149) and rollback-ecr-registry (LeanerCloud#1112)
  jobs right after that job. The step stays at the end of
  aws-tfstate-platform-key and both new main jobs follow it unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@IAmJSD

IAmJSD commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator Author

Merged current main into the branch. New head: b7ac3aa.

Checks:

  • actionlint and shellcheck are clean.
  • test-init-backend.sh gives 24/0.
  • Every shell suite that sweeps scripts/ or reads ci.yml passes: aws-tfstate-platform-key 26/0, tfstate-concurrency-group 41/0, ecr-delete-selection 59/0, rds-deletion-protection-scope 58/0, cloud-sql-delete-scope 62/0, rollback-ecr-registry 52/0, gcp-secret-scope 53/0, integration-test-selection 23/0, check-gosec-pin 43/0, run-gosec.
  • go build and go vet (default, integration, e2e) pass. The Go guards that read ci.yml pass.
  • The git-secrets scan is clean, and the allowlist test gives 137 PASS.

@IAmJSD

IAmJSD commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator Author

Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: APPROVE b7ac3aa5454484bc6a4159da43bb448b1f856aa7

Blocking:

  • none

Non-blocking:

  • scripts/init-backend.sh (the cat > "${BACKEND_CONFIG_FILE}" block): the script now writes to backends/<env>.tfbackend. backends/dev.tfbackend.example tells operators to create a file at that same path by hand, so a second run (or a run after a manual copy) silently overwrites their edited file, including any profile line. Refusing to overwrite, or writing a .bak first, would make this safer.
  • --profile is used for the aws CLI calls but not written into the generated tfbackend. A later terraform init from that file then uses the default credential chain, which may be a different account. This was already true before the PR. Adding a profile = "<profile>" line when --profile is set would close it.

Attacked:

  • Keys and flags: checked the generated key and plan flags against both workflows. deploy-aws-lambda.yml:270/:293 use github-%s and compute_platform=lambda. deploy-aws-fargate.yml:197/:210 use github-fargate-%s and compute_platform=fargate. The flat root has backend.tf with an empty s3 block, and github-<env>.tfvars exist. *.tfbackend is gitignored. The platform check and the repo-root guard both run before the first aws call.
  • Test isolation: the test reads the expected keys from the workflows, uses a PATH-stubbed aws, and runs in a mktemp fixture. After the run, the worktree was clean: no backends/*.tfbackend and no terraform/environments/aws/dev.
  • Ran: bash scripts/test-init-backend.sh (24 passed, 0 failed), bash scripts/test-aws-tfstate-platform-key.sh (26 passed), and shellcheck on both scripts (clean).
  • Fail-before: swapping in main's scripts/init-backend.sh gives 6 passed, 11 failed, matching the body. The file was restored afterwards.

Body-stale:

  • none found

Reviewer: rB18. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths.

@IAmJSD

IAmJSD commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator Author

Local verification at b7ac3aa5454484bc6a4159da43bb448b1f856aa7 merged onto main (de56f95b), together with #1114, #1142, #1254, #1274, #1278, #1279, #1280, #1282, #1414 in that order (combined tree e1bf26a0, Linux, Go 1.26.10): go build ./..., go vet with and without -tags integration, the full go test -race -short -count=1 ./..., and go test -race -tags integration -count=1 on every changed package, against Docker Postgres. All pass. CI Success and pre-commit are green on this head, with 1 approving independent review(s) on it. Nothing was run against any cloud.

@IAmJSD
IAmJSD marked this pull request as ready for review October 25, 2026 22:35
@IAmJSD
IAmJSD merged commit 04c0130 into LeanerCloud:main Oct 25, 2026
38 checks passed
@IAmJSD

IAmJSD commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator Author

Merged by IAmJSD's agent at IAmJSD's request (IAmJSD is acting owner while the maintainers are away), under the bar IAmJSD set: 1 approving independent review(s) on b7ac3aa5, green CI, and the combined-tree verification above. Squash-merged at exactly b7ac3aa5454484bc6a4159da43bb448b1f856aa7.

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

Labels

effort/s Hours impact/internal Team-internal only priority/p2 Backlog-worthy severity/medium Moderate harm triaged Item has been triaged type/chore Maintenance / non-user-visible urgency/this-quarter Within the quarter

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(scripts): init-backend.sh emits a backend with a state key CI never uses

2 participants