Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1703,6 +1703,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 every job writing the Azure or GCP Terraform state carries the
# shared concurrency group for that state (`azure-tfstate-<env>`,
# `gcp-tfstate-<env>`), with the suffix taken from the same literal or
Expand Down
88 changes: 60 additions & 28 deletions scripts/init-backend.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@
# Initialize Terraform S3 backend for state management
# Usage: ./scripts/init-backend.sh <environment> <region>
# Example: ./scripts/init-backend.sh dev us-east-1
#
# Run from the repository root. Writes
# terraform/environments/aws/backends/<env>.tfbackend (gitignored) for the flat
# AWS root, keyed the way the deploy workflows key their state:
# --compute-platform lambda (default) github-<env>/terraform.tfstate
# --compute-platform fargate github-fargate-<env>/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

Expand All @@ -28,6 +36,7 @@ print_error() {
AWS_PROFILE=""
ENVIRONMENT=""
REGION=""
COMPUTE_PLATFORM="lambda"

# Parse command line arguments
while [[ $# -gt 0 ]]; do
Expand All @@ -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
Expand All @@ -61,7 +74,7 @@ done

# Check required arguments
if [ -z "$ENVIRONMENT" ] || [ -z "$REGION" ]; then
print_error "Usage: $0 --environment <env> --region <region> [--profile <profile>]"
print_error "Usage: $0 --environment <env> --region <region> [--profile <profile>] [--compute-platform lambda|fargate]"
print_error " or: $0 <environment> <region> # Legacy format"
print_error "Example: $0 --environment dev --region us-east-1 --profile personal"
exit 1
Expand All @@ -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}"
Expand Down Expand Up @@ -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}" <<EOF
# Terraform Backend Configuration
# Auto-generated by scripts/init-backend.sh
# DO NOT EDIT MANUALLY

terraform {
backend "s3" {
bucket = "${BUCKET_NAME}"
key = "${ENVIRONMENT}/terraform.tfstate"
region = "${REGION}"
encrypt = true
use_lockfile = true

# Optional: Use KMS for encryption
# kms_key_id = "arn:aws:kms:${REGION}:${ACCOUNT_ID}:key/YOUR-KMS-KEY-ID"
}
}
# AWS Terraform Backend Configuration - ${ENVIRONMENT} (${COMPUTE_PLATFORM})
# Auto-generated by scripts/init-backend.sh; gitignored.
# Use from ${TF_ROOT}:
# terraform init -backend-config=${BACKEND_FILE_NAME}

bucket = "${BUCKET_NAME}"
key = "${STATE_KEY}"
region = "${REGION}"
encrypt = true
use_lockfile = true

# Optional: Use KMS for encryption
# kms_key_id = "arn:aws:kms:${REGION}:${ACCOUNT_ID}:key/YOUR-KMS-KEY-ID"
EOF

print_info "Backend configuration file created"
Expand All @@ -241,18 +272,19 @@ print_info "Terraform backend initialized successfully!"
echo ""
echo "Backend Configuration:"
echo " S3 Bucket: ${BUCKET_NAME}"
echo " State Key: ${STATE_KEY}"
echo " Locking: S3-native (use_lockfile = true)"
echo " Region: ${REGION}"
echo " Config File: ${BACKEND_CONFIG_FILE}"
echo ""
echo "Next steps:"
echo " 1. cd terraform/environments/aws/${ENVIRONMENT}"
echo " 2. cp terraform.tfvars.example terraform.tfvars"
echo " 3. Edit terraform.tfvars with your configuration"
echo " 4. terraform init"
echo " 5. terraform plan"
echo " 6. terraform apply"
echo " 1. cd ${TF_ROOT}"
echo " 2. terraform init -backend-config=${BACKEND_FILE_NAME}"
echo " 3. terraform plan -var-file=github-${ENVIRONMENT}.tfvars -var=\"compute_platform=${COMPUTE_PLATFORM}\" -out=tfplan"
echo " 4. terraform apply tfplan"
echo ""
print_warn "Note: The backend configuration has been uncommented in ${BACKEND_CONFIG_FILE}"
print_warn "You will need to run 'terraform init' to migrate state to S3"
print_warn "CI does not read ${BACKEND_CONFIG_FILE}: it builds its backend from the"
print_warn "TF_BACKEND_AWS secret plus the same key (${STATE_KEY}). Local runs and CI"
print_warn "share one state object only when that secret, for the ${ENVIRONMENT} GitHub"
print_warn "Environment, names bucket ${BUCKET_NAME}."
echo ""
191 changes: 191 additions & 0 deletions scripts/test-init-backend.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
#!/usr/bin/env bash
# test-init-backend.sh
#
# Offline test for init-backend.sh (#306): the aws CLI is stubbed via PATH and
# the script runs inside a throwaway copy of the AWS Terraform root, so nothing
# here touches a real account or the working tree.
#
# The script used to write terraform/environments/aws/<env>/backend.tf with
# key "<env>/terraform.tfstate" and tell the operator to apply from that new
# directory, while every deploy workflow inits the flat root with
# key "github-<env>/terraform.tfstate" (or "github-fargate-<env>/..."). 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:-<none>} fargate/prod=${FARGATE_KEY:-<none>}"

# --- 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/<env>.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 ]]
Loading