From 898f2794ca96374ace5f9271988e208bce2377bb Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Mon, 19 Oct 2026 23:31:48 +0100 Subject: [PATCH] fix(scripts): make init-backend.sh write the state key CI uses init-backend.sh wrote terraform/environments/aws//backend.tf with key "/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-/terraform.tfstate" (Lambda) or "github-fargate-/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/.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 #306 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 6 ++ scripts/init-backend.sh | 88 +++++++++++----- scripts/test-init-backend.sh | 191 +++++++++++++++++++++++++++++++++++ 3 files changed, 257 insertions(+), 28 deletions(-) create mode 100755 scripts/test-init-backend.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b885e287e..210f07e85 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1157,6 +1157,12 @@ jobs: - name: Run state namespace self-tests run: bash scripts/test-aws-tfstate-platform-key.sh + # scripts/init-backend.sh must write the same state key the deploy + # workflows build, for the flat AWS root (#306). Runs the script against + # a stubbed aws CLI in a temp copy of the root; no AWS access. + - name: Run init-backend.sh self-test + run: bash scripts/test-init-backend.sh + # Assert that no Terraform file declares an azurerm_key_vault_access_policy # resource. This project's only Key Vault sets enable_rbac_authorization = # true, and an RBAC-enabled vault ignores access policies entirely, so such a diff --git a/scripts/init-backend.sh b/scripts/init-backend.sh index eaf4c19cd..8a313c112 100755 --- a/scripts/init-backend.sh +++ b/scripts/init-backend.sh @@ -2,6 +2,14 @@ # Initialize Terraform S3 backend for state management # Usage: ./scripts/init-backend.sh # Example: ./scripts/init-backend.sh dev us-east-1 +# +# Run from the repository root. Writes +# terraform/environments/aws/backends/.tfbackend (gitignored) for the flat +# AWS root, keyed the way the deploy workflows key their state: +# --compute-platform lambda (default) github-/terraform.tfstate +# --compute-platform fargate github-fargate-/terraform.tfstate +# It does not create a per-environment Terraform root: the AWS environment is a +# single root at terraform/environments/aws, initialized with -backend-config. set -euo pipefail @@ -28,6 +36,7 @@ print_error() { AWS_PROFILE="" ENVIRONMENT="" REGION="" +COMPUTE_PLATFORM="lambda" # Parse command line arguments while [[ $# -gt 0 ]]; do @@ -44,6 +53,10 @@ while [[ $# -gt 0 ]]; do ENVIRONMENT="$2" shift 2 ;; + --compute-platform) + COMPUTE_PLATFORM="$2" + shift 2 + ;; *) # Legacy positional arguments if [ -z "$ENVIRONMENT" ]; then @@ -61,7 +74,7 @@ done # Check required arguments if [ -z "$ENVIRONMENT" ] || [ -z "$REGION" ]; then - print_error "Usage: $0 --environment --region [--profile ]" + print_error "Usage: $0 --environment --region [--profile ] [--compute-platform lambda|fargate]" print_error " or: $0 # Legacy format" print_error "Example: $0 --environment dev --region us-east-1 --profile personal" exit 1 @@ -73,6 +86,26 @@ if [[ ! "$ENVIRONMENT" =~ ^(dev|staging|prod)$ ]]; then exit 1 fi +# The deploy workflows keep one state object per compute platform in the same +# bucket (deploy-aws-lambda.yml, deploy-aws-fargate.yml); use the same keys. +case "$COMPUTE_PLATFORM" in + lambda) STATE_KEY="github-${ENVIRONMENT}/terraform.tfstate" ;; + fargate) STATE_KEY="github-fargate-${ENVIRONMENT}/terraform.tfstate" ;; + *) + print_error "Compute platform must be one of: lambda, fargate" + exit 1 + ;; +esac + +# The single AWS Terraform root every workflow inits and applies. Checked before +# any AWS call, so a run from the wrong directory fails without touching the +# account or creating a stray terraform/ tree wherever it was started. +TF_ROOT="terraform/environments/aws" +if [ ! -f "${TF_ROOT}/backend.tf" ]; then + print_error "${TF_ROOT}/backend.tf not found; run this script from the repository root" + exit 1 +fi + # Configuration PROJECT_NAME="cudly" BUCKET_NAME="${PROJECT_NAME}-terraform-state-${ENVIRONMENT}" @@ -202,32 +235,30 @@ $AWS_CMD s3api put-bucket-tagging \ --region "${REGION}" # ============================================== -# Update Terraform Backend Configuration +# Write the backend config for the flat AWS root # ============================================== -BACKEND_CONFIG_FILE="terraform/environments/aws/${ENVIRONMENT}/backend.tf" +BACKEND_FILE_NAME="backends/${ENVIRONMENT}.tfbackend" +BACKEND_CONFIG_FILE="${TF_ROOT}/${BACKEND_FILE_NAME}" print_info "Creating backend configuration file: ${BACKEND_CONFIG_FILE}" -mkdir -p "terraform/environments/aws/${ENVIRONMENT}" +mkdir -p "${TF_ROOT}/backends" cat > "${BACKEND_CONFIG_FILE}" </backend.tf with +# key "/terraform.tfstate" and tell the operator to apply from that new +# directory, while every deploy workflow inits the flat root with +# key "github-/terraform.tfstate" (or "github-fargate-/..."). The +# expected key is read from the deploy workflows themselves rather than written +# here, so the test fails if either side changes without the other. +# +# Exits 0 when all cases pass; exits 1 on any failure. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +SUT="${SCRIPT_DIR}/init-backend.sh" +WORKFLOW_DIR="${REPO_ROOT}/.github/workflows" +AWS_ROOT_REL="terraform/environments/aws" + +pass=0 +fail=0 + +note_pass() { + echo "PASS: $1" + ((pass++)) || true +} + +note_fail() { + echo "FAIL: $1" + ((fail++)) || true +} + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT +mkdir -p "$WORK/bin" + +# Stub aws. Every call is logged; the bucket probe reports NoSuchBucket so the +# create path runs, and every mutating s3api call succeeds without output. +cat >"$WORK/bin/aws" <<'STUB' +#!/usr/bin/env bash +echo "$*" >>"$AWS_LOG" +args=" $* " +case "$args" in + *" sts get-caller-identity "*"--query Account"*) echo "stub-account" ;; + *" sts get-caller-identity "*) echo '{"Account":"stub-account"}' ;; + *" s3 ls "*) echo "An error occurred (NoSuchBucket) when calling the ListObjectsV2 operation" >&2 ;; +esac +exit 0 +STUB +chmod +x "$WORK/bin/aws" + +# workflow_key FILE ENV +# +# Prints the S3 state key FILE's Terraform Init step builds for ENV, taken from +# its `printf '...key = "github-...%s..."...'` line. Fails (prints nothing) when +# the file builds no key or more than one distinct key format. +workflow_key() { + local file="$1" env="$2" formats + formats="$(grep -oE 'key = "github-[^"]*%s[^"]*/terraform\.tfstate"' "$file" | sort -u)" + if [[ -z "$formats" || "$(printf '%s\n' "$formats" | wc -l | tr -d ' ')" != "1" ]]; then + return 0 + fi + formats="${formats#key = \"}" + formats="${formats%\"}" + # shellcheck disable=SC2059 # the workflow's own format string is the point + printf "$formats" "$env" +} + +# new_fixture NAME: a copy of the AWS root's backend.tf, which is all the +# script needs to recognize the repository root. +new_fixture() { + local dir="$WORK/$1" + mkdir -p "$dir/${AWS_ROOT_REL}" + cp "${REPO_ROOT}/${AWS_ROOT_REL}/backend.tf" "$dir/${AWS_ROOT_REL}/backend.tf" + echo "$dir" +} + +# run_sut DIR ARGS...: runs the script from DIR with the stub first on PATH. +# Sets RC, OUT (combined output) and AWS_LOG. +run_sut() { + local dir="$1" + shift + AWS_LOG="$WORK/aws-$(basename "$dir").log" + : >"$AWS_LOG" + set +e + OUT="$(cd "$dir" && AWS_LOG="$AWS_LOG" PATH="$WORK/bin:$PATH" bash "$SUT" "$@" 2>&1)" + RC=$? + set -e +} + +# backend_value FILE NAME: the value of a `name = value` line, quotes dropped. +backend_value() { + sed -nE "s/^[[:space:]]*$2[[:space:]]*=[[:space:]]*\"?([^\"]*)\"?[[:space:]]*$/\1/p" "$1" +} + +check() { + local label="$1" + shift + if "$@"; then note_pass "$label"; else note_fail "$label"; fi +} + +# same_nonempty A B: A is non-empty and equal to B. +same_nonempty() { [[ -n "$1" && "$1" == "$2" ]]; } + +# has TEXT NEEDLE / lacks TEXT NEEDLE: fixed-string search in captured output. +has() { [[ "$1" == *"$2"* ]]; } +lacks() { [[ "$1" != *"$2"* ]]; } + +# The platform flag the plan step should carry. Built from a variable rather +# than written out, because test-aws-tfstate-platform-key.sh sweeps scripts/ +# and reads a literal platform assignment in any script as a state-writing job. +platform_var() { printf 'compute_platform=%s' "$1"; } + +# --- The keys CI uses -------------------------------------------------------- +LAMBDA_KEY="$(workflow_key "${WORKFLOW_DIR}/deploy-aws-lambda.yml" dev)" +FARGATE_KEY="$(workflow_key "${WORKFLOW_DIR}/deploy-aws-fargate.yml" prod)" +check "deploy-aws-lambda.yml builds one recognizable state key" test -n "$LAMBDA_KEY" +check "deploy-aws-fargate.yml builds one recognizable state key" test -n "$FARGATE_KEY" +echo " CI keys: lambda/dev=${LAMBDA_KEY:-} fargate/prod=${FARGATE_KEY:-}" + +# --- Default (Lambda), legacy positional arguments --------------------------- +FX="$(new_fixture lambda)" +run_sut "$FX" dev us-east-1 +BACKEND="$FX/${AWS_ROOT_REL}/backends/dev.tfbackend" +check "lambda: script exits 0" test "$RC" -eq 0 +check "lambda: writes ${AWS_ROOT_REL}/backends/dev.tfbackend" test -f "$BACKEND" +check "lambda: creates no per-environment root (${AWS_ROOT_REL}/dev)" test ! -e "$FX/${AWS_ROOT_REL}/dev" +if [[ -f "$BACKEND" ]]; then + got_key="$(backend_value "$BACKEND" key)" + check "lambda: key '${got_key}' is the key deploy-aws-lambda.yml uses" \ + same_nonempty "$LAMBDA_KEY" "$got_key" + check "lambda: bucket is the one the script created" \ + test "$(backend_value "$BACKEND" bucket)" = "cudly-terraform-state-dev" + check "lambda: region is the one requested" test "$(backend_value "$BACKEND" region)" = "us-east-1" + check "lambda: S3-native locking is on" test "$(backend_value "$BACKEND" use_lockfile)" = "true" + # A -backend-config file holds bare attributes; a `terraform {}` block in it + # is rejected by terraform init. + check "lambda: file is a -backend-config file, not a terraform block" \ + test -z "$(grep -vE '^[[:space:]]*(#.*)?$' "$BACKEND" | grep -vE '^[a-z_]+[[:space:]]*=[[:space:]]*[^[:space:]]' || true)" +fi +check "lambda: tells the operator to init the flat root with that file" \ + has "$OUT" "terraform init -backend-config=backends/dev.tfbackend" +check "lambda: plans with the platform it keyed the state for" \ + has "$OUT" "$(platform_var lambda)" +check "lambda: does not send the operator into a per-environment directory" \ + lacks "$OUT" "cd ${AWS_ROOT_REL}/dev" + +# --- Fargate, flag arguments ------------------------------------------------- +FX="$(new_fixture fargate)" +run_sut "$FX" --environment prod --region eu-west-1 --compute-platform fargate +BACKEND="$FX/${AWS_ROOT_REL}/backends/prod.tfbackend" +check "fargate: script exits 0" test "$RC" -eq 0 +check "fargate: writes ${AWS_ROOT_REL}/backends/prod.tfbackend" test -f "$BACKEND" +if [[ -f "$BACKEND" ]]; then + got_key="$(backend_value "$BACKEND" key)" + check "fargate: key '${got_key}' is the key deploy-aws-fargate.yml uses" \ + same_nonempty "$FARGATE_KEY" "$got_key" + check "fargate: region is the one requested" test "$(backend_value "$BACKEND" region)" = "eu-west-1" +fi +check "fargate: plans with the platform it keyed the state for" \ + has "$OUT" "$(platform_var fargate)" + +# --- The generated file can never be committed ------------------------------- +if git -C "$REPO_ROOT" rev-parse --git-dir >/dev/null 2>&1; then + check "backends/.tfbackend is gitignored in this repository" \ + git -C "$REPO_ROOT" check-ignore -q "${AWS_ROOT_REL}/backends/prod.tfbackend" +else + note_fail "not a git checkout; cannot check that the generated file is gitignored" +fi + +# --- Rejections happen before any AWS call ----------------------------------- +FX="$(new_fixture badplatform)" +run_sut "$FX" --environment dev --region us-east-1 --compute-platform ecs +check "unknown --compute-platform: exits non-zero" test "$RC" -ne 0 +check "unknown --compute-platform: makes no AWS call" test ! -s "$AWS_LOG" + +FX="$WORK/notroot" +mkdir -p "$FX" +run_sut "$FX" dev us-east-1 +check "outside the repository root: exits non-zero" test "$RC" -ne 0 +check "outside the repository root: makes no AWS call" test ! -s "$AWS_LOG" +check "outside the repository root: creates no terraform/ tree" test ! -e "$FX/terraform" + +echo "" +echo "${pass} passed, ${fail} failed" +[[ "$fail" -eq 0 ]]