diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bcab59a7..89c5f104 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1706,6 +1706,26 @@ jobs: - name: Run down-migration gate self-tests run: bash scripts/test-migration-down-gate.sh + # Assert that scripts/gcp-import-dev-state.sh takes the GCP project and the + # state backend from GCP_PROJECT_ID and TF_BACKEND_GCP with no default, and + # refuses to start without them (#305). Runs the script end to end against + # stubbed terraform and gcloud; nothing reaches GCP. Fast (shell only), so it + # always runs. + gcp-import-dev-state: + name: GCP dev state import configuration + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false + + - name: Run import script self-tests + run: bash scripts/test-gcp-import-dev-state.sh + # Summary job - all checks must pass ci-success: name: CI Success @@ -1731,6 +1751,7 @@ jobs: - aws-tfstate-platform-key - azure-kv-access-policy - migration-down-gate + - gcp-import-dev-state if: always() permissions: contents: read diff --git a/scripts/delete-owned-cloud-sql-instance.sh b/scripts/delete-owned-cloud-sql-instance.sh index 914846d7..a0679748 100755 --- a/scripts/delete-owned-cloud-sql-instance.sh +++ b/scripts/delete-owned-cloud-sql-instance.sh @@ -57,7 +57,7 @@ # step already ran three state rm commands: those addresses were root-level # (`google_sql_database_instance.main`), but the resources live under # `module.database` (terraform/environments/gcp/database.tf:5; -# scripts/gcp-import-dev-state.sh:273-288 imports them module-qualified). +# scripts/gcp-import-dev-state.sh:334-349 imports them module-qualified). # Measured on Terraform 1.14.7 against a state holding those three # module-qualified resources: `terraform state rm # google_sql_database_instance.main` exits 1 "No matching objects found" and diff --git a/scripts/gcp-import-dev-state.sh b/scripts/gcp-import-dev-state.sh index c5cbc61d..d1fbecc0 100755 --- a/scripts/gcp-import-dev-state.sh +++ b/scripts/gcp-import-dev-state.sh @@ -5,26 +5,82 @@ # missing from the Terraform github-dev state in GCS. # # Usage: +# export GCP_PROJECT_ID= +# export TF_BACKEND_GCP='bucket = ""' # export GOOGLE_APPLICATION_CREDENTIALS=~/.config/gcloud/cudly-terraform-deploy.json # cd # bash scripts/gcp-import-dev-state.sh # +# GCP_PROJECT_ID and TF_BACKEND_GCP are required and have no defaults. They +# take the same values deploy-gcp.yml reads from vars.GCP_PROJECT_ID and +# secrets.TF_BACKEND_GCP: TF_BACKEND_GCP is the backend-config body (at least +# a `bucket = "..."` line), and this script appends `prefix = "github-dev"` +# exactly as the deploy workflow does. The project is also exported as +# TF_VAR_project_id so terraform and gcloud cannot disagree about it. +# # Prerequisites: # - gcloud CLI authenticated (or GOOGLE_APPLICATION_CREDENTIALS set) # - terraform >= 1.10 -# - Access to gs://cudly-terraform-state-cloudprowess +# - Access to the state bucket named in TF_BACKEND_GCP set -euo pipefail # --------------------------------------------------------------------------- # Configuration # --------------------------------------------------------------------------- -PROJECT="serene-bazaar-666" +usage() { + cat >&2 <<'USAGE' +usage: GCP_PROJECT_ID= TF_BACKEND_GCP='bucket = ""' \ + bash scripts/gcp-import-dev-state.sh + + GCP_PROJECT_ID GCP project whose cudly-dev resources are imported + (same value as the GCP_PROJECT_ID repository variable) + TF_BACKEND_GCP GCS backend config containing a bucket = "..." line + (same value as the TF_BACKEND_GCP repository secret); + prefix = "github-dev" is appended by this script +USAGE +} + +# die MESSAGE: print the error and usage, then exit 2 before anything touches +# terraform, gcloud or the backend. +die() { + echo "ERROR: $*" >&2 + usage + exit 2 +} + +PROJECT="${GCP_PROJECT_ID:-}" +TF_BACKEND="${TF_BACKEND_GCP:-}" + +[ -n "$PROJECT" ] || die "GCP_PROJECT_ID is not set" +[ -n "$TF_BACKEND" ] || die "TF_BACKEND_GCP is not set" + +# Validate the shape, not merely non-emptiness (same rule as +# delete-owned-cloud-sql-instance.sh). [[ =~ ]] matches the whole string, so a +# value with an embedded newline is rejected too. +[[ "$PROJECT" =~ ^[a-z][a-z0-9-]{5,29}$ ]] || + die "GCP_PROJECT_ID is malformed ('${PROJECT}')" + +# Without a bucket the GCS backend cannot initialise; with its own prefix the +# appended prefix line would be a duplicate attribute and terraform would +# reject the file. Fail on both here, before init runs. +grep -Eq '^[[:space:]]*bucket[[:space:]]*=' <<<"$TF_BACKEND" || + die 'TF_BACKEND_GCP has no bucket = "..." line' +if grep -Eq '^[[:space:]]*prefix[[:space:]]*=' <<<"$TF_BACKEND"; then + die 'TF_BACKEND_GCP must not set prefix; this script always uses prefix = "github-dev"' +fi + +# Terraform reads the project from var.project_id, gcloud from --project. A +# TF_VAR_project_id left over in the environment for another project would +# send imports into config for a different project than the one queried. +if [ -n "${TF_VAR_project_id:-}" ] && [ "$TF_VAR_project_id" != "$PROJECT" ]; then + die "TF_VAR_project_id ('${TF_VAR_project_id}') does not match GCP_PROJECT_ID ('${PROJECT}')" +fi +export TF_VAR_project_id="$PROJECT" + REGION="us-central1" SERVICE_NAME="cudly-dev" TF_DIR="$(cd "$(dirname "$0")/.." && pwd)/terraform/environments/gcp" -BACKEND_CONFIG="bucket = \"cudly-terraform-state-cloudprowess\"\nprefix = \"github-dev\"" -BACKEND_FILE="/tmp/gcp-dev-backend.tfbackend" export GOOGLE_APPLICATION_CREDENTIALS="${GOOGLE_APPLICATION_CREDENTIALS:-$HOME/.config/gcloud/cudly-terraform-deploy.json}" @@ -61,7 +117,12 @@ echo "TF_DIR: $TF_DIR" echo "Credentials: $GOOGLE_APPLICATION_CREDENTIALS" [ -f "$GOOGLE_APPLICATION_CREDENTIALS" ] || { echo "ERROR: credentials file not found"; exit 1; } -printf '%b\n' "$BACKEND_CONFIG" > "$BACKEND_FILE" +# The backend config now comes from the TF_BACKEND_GCP secret, so write it to a +# private mktemp file (mode 0600, unique name) rather than a fixed /tmp path, +# and remove it on exit. terraform init copies what it needs into .terraform/. +BACKEND_FILE="$(mktemp "${TMPDIR:-/tmp}/gcp-dev-backend.XXXXXX")" +trap 'rm -f "$BACKEND_FILE"' EXIT +printf '%s\nprefix = "github-dev"\n' "$TF_BACKEND" > "$BACKEND_FILE" echo "Backend file written to $BACKEND_FILE" cd "$TF_DIR" diff --git a/scripts/test-gcp-import-dev-state.sh b/scripts/test-gcp-import-dev-state.sh new file mode 100755 index 00000000..fff9ccd8 --- /dev/null +++ b/scripts/test-gcp-import-dev-state.sh @@ -0,0 +1,306 @@ +#!/usr/bin/env bash +# test-gcp-import-dev-state.sh +# +# Asserts that scripts/gcp-import-dev-state.sh takes the GCP project and the +# Terraform state backend from GCP_PROJECT_ID and TF_BACKEND_GCP, with no +# built-in default, and refuses to start without them (#305). +# +# The script used to carry the dev project ID and the state bucket as +# unconditional literals, so pointing it at any other environment silently +# imported the dev project's resources into whatever state was initialised. +# +# Every case runs the real script end to end against stubbed `terraform` and +# `gcloud` on PATH, in a clean environment (env -i), so nothing here reaches +# GCP or a real state backend. The stubs log every call they receive. +# +# Two halves, positive first: +# 1. With both variables set, every gcloud call targets exactly the given +# project, every terraform import ID names it, terraform sees it as +# TF_VAR_project_id, and the backend file passed to `terraform init` is +# exactly TF_BACKEND_GCP plus the github-dev prefix. The calls are counted +# first, since a run that makes no calls has no wrong project either. +# 2. With either variable missing or malformed, the script exits 2 with a +# usage message BEFORE it calls terraform or gcloud or writes a backend +# file. +# +# Half 1 needs bash >= 4, because the script under test uses `declare -A` +# (the bash 3.2 abort is tracked separately). The script under test runs under +# the same bash as this test ("$BASH"), not whichever `bash` comes first on the +# stub PATH, so the version check below is about the bash that actually runs +# it. On an older bash those cases are reported as SKIP locally and as FAIL +# under CI, so CI can never pass them by not running them. +# +# Exits 0 when all cases pass; exits 1 on any failure. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TARGET="${SCRIPT_DIR}/gcp-import-dev-state.sh" + +PROJECT_A="example-dev-project" +PROJECT_B="other-sandbox-42" +BACKEND_A='bucket = "example-tfstate-bucket"' + +pass=0 +fail=0 + +ok() { + echo "PASS: $1" + pass=$((pass + 1)) +} + +ko() { + echo "FAIL: $1" + shift + local line + for line in "$@"; do + echo " $line" + done + fail=$((fail + 1)) +} + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +STUB_DIR="${WORK}/bin" +CALLS="${WORK}/calls.log" +CAPTURED_BACKEND="${WORK}/backend.captured" +RUN_TMP="${WORK}/tmp" +CREDS="${WORK}/creds.json" +mkdir -p "$STUB_DIR" "$RUN_TMP" +printf '{}\n' >"$CREDS" + +# terraform stub: logs each call with the TF_VAR_project_id it saw. On init it +# copies the -backend-config file aside and records whether its mode was 0600. +# `state show` fails so every discovered resource is imported. +cat >"${STUB_DIR}/terraform" <<'STUB' +#!/usr/bin/env bash +printf 'terraform TF_VAR_project_id=%s %s\n' "${TF_VAR_project_id:-}" "$*" >>"$STUB_CALLS" +case "${1:-}" in + init) + for arg in "$@"; do + case "$arg" in + -backend-config=*) + f="${arg#-backend-config=}" + cat "$f" >"$STUB_CAPTURED_BACKEND" + if [ -n "$(find "$f" -perm 0600)" ]; then + echo "backend-mode=0600" >>"$STUB_CALLS" + else + echo "backend-mode=other" >>"$STUB_CALLS" + fi + ;; + esac + done + echo "Terraform has been successfully initialized!" + ;; + state) + [ "${2:-}" = "show" ] && exit 1 + ;; +esac +exit 0 +STUB + +# gcloud stub: logs each call and reports every resource as existing, so the +# script exercises every import branch. +cat >"${STUB_DIR}/gcloud" <<'STUB' +#!/usr/bin/env bash +printf 'gcloud %s\n' "$*" >>"$STUB_CALLS" +case "$*" in + "secrets versions list"*) echo "projects/stub/secrets/stub/versions/7" ;; + "sql users list"*) echo "cudly" ;; + "services vpc-peerings list"*) echo "servicenetworking.googleapis.com" ;; + *) echo "stub-resource" ;; +esac +exit 0 +STUB +chmod +x "${STUB_DIR}/terraform" "${STUB_DIR}/gcloud" + +RUN_EXIT=0 +RUN_ERR="" + +# run_target [VAR=value ...]: runs the script in a clean environment with the +# stubs first on PATH. Only the given variables (plus PATH, HOME, TMPDIR and +# credentials) are set, so nothing leaks in from the caller's shell. +run_target() { + : >"$CALLS" + rm -f "$CAPTURED_BACKEND" + RUN_EXIT=0 + env -i \ + PATH="${STUB_DIR}:/usr/bin:/bin:/usr/sbin:/sbin" \ + HOME="$WORK" \ + TMPDIR="$RUN_TMP" \ + GOOGLE_APPLICATION_CREDENTIALS="$CREDS" \ + STUB_CALLS="$CALLS" \ + STUB_CAPTURED_BACKEND="$CAPTURED_BACKEND" \ + "$@" \ + "$BASH" "$TARGET" >"${WORK}/out" 2>"${WORK}/err" || RUN_EXIT=$? + RUN_ERR="$(cat "${WORK}/err")" +} + +leftover_backend_files() { + find "$RUN_TMP" -name 'gcp-dev-backend*' | wc -l | tr -d ' ' +} + +# --------------------------------------------------------------------------- +# Half 1: the configured project and backend are the ones used +# --------------------------------------------------------------------------- + +# assert_targets LABEL PROJECT BACKEND [extra VAR=value ...] +assert_targets() { + local label="$1" project="$2" backend="$3" + shift 3 + run_target GCP_PROJECT_ID="$project" TF_BACKEND_GCP="$backend" "$@" + + if [ "$RUN_EXIT" -ne 0 ]; then + ko "$label: script exits 0" "exit $RUN_EXIT" "stderr: $RUN_ERR" + return + fi + ok "$label: script exits 0" + + local gcloud_calls import_calls + gcloud_calls="$(grep -c '^gcloud ' "$CALLS" || true)" + import_calls="$(grep -c '^terraform .* import ' "$CALLS" || true)" + if [ "$gcloud_calls" -ge 15 ] && [ "$import_calls" -ge 20 ]; then + ok "$label: queried GCP and imported ($gcloud_calls gcloud calls, $import_calls imports)" + else + ko "$label: queried GCP and imported" "gcloud calls: $gcloud_calls, imports: $import_calls" + fi + + local bad + bad="$(grep '^gcloud ' "$CALLS" | grep -Ev -- "--project=${project}( |$)" || true)" + if [ -z "$bad" ]; then + ok "$label: every gcloud call passes --project=${project}" + else + ko "$label: every gcloud call passes --project=${project}" "offending: $bad" + fi + + local projects_named + projects_named="$(grep -o -- '--project=[^ ]*' "$CALLS" | sort -u | tr '\n' ' ')" + if [ "$projects_named" = "--project=${project} " ]; then + ok "$label: no gcloud call names another project" + else + ko "$label: no gcloud call names another project" "seen: $projects_named" + fi + + # Check the import ID alone: the stub's log prefix carries TF_VAR_project_id, + # so matching the whole line would always find the project. Strip + # "terraform TF_VAR_project_id=... import
" (addresses have no + # spaces; IAM member IDs do). Every ID must lead with the project, either as + # projects//... or bare (/region/... for the router and + # NAT, " roles/..." for project IAM members). + local ids id_projects sa_projects + ids="$(grep '^terraform .* import ' "$CALLS" | + sed -E 's/^terraform TF_VAR_project_id=[^ ]* import [^ ]+ //' || true)" + bad="$(printf '%s\n' "$ids" | grep -Ev -- "^(projects/)?${project}[/ ]" || true)" + id_projects="$(printf '%s\n' "$ids" | grep -o 'projects/[^/ ]*' | sort -u | tr '\n' ' ')" + sa_projects="$(printf '%s\n' "$ids" | grep -o '@[^ ]*\.iam\.gserviceaccount\.com' | sort -u | tr '\n' ' ')" + if [ -z "$bad" ] && [ "$id_projects" = "projects/${project} " ] && + [ "$sa_projects" = "@${project}.iam.gserviceaccount.com " ]; then + ok "$label: every import ID names ${project} and no other project" + else + ko "$label: every import ID names ${project} and no other project" \ + "IDs not led by it: $bad" "projects/ prefixes seen: $id_projects" \ + "service account projects seen: $sa_projects" + fi + + bad="$(grep '^terraform ' "$CALLS" | grep -v "^terraform TF_VAR_project_id=${project} " || true)" + if [ -z "$bad" ]; then + ok "$label: terraform sees TF_VAR_project_id=${project}" + else + ko "$label: terraform sees TF_VAR_project_id=${project}" "offending: $bad" + fi + + local want got + want="$(printf '%s\nprefix = "github-dev"' "$backend")" + got="$(cat "$CAPTURED_BACKEND" 2>/dev/null || echo '')" + if [ "$got" = "$want" ]; then + ok "$label: terraform init gets TF_BACKEND_GCP plus the github-dev prefix" + else + ko "$label: terraform init gets TF_BACKEND_GCP plus the github-dev prefix" \ + "expected: $want" "actual: $got" + fi + + if grep -qx 'backend-mode=0600' "$CALLS"; then + ok "$label: backend file is mode 0600" + else + ko "$label: backend file is mode 0600" "$(grep '^backend-mode=' "$CALLS" || echo 'init not called')" + fi + + if [ "$(leftover_backend_files)" = "0" ]; then + ok "$label: backend file removed on exit" + else + ko "$label: backend file removed on exit" "$(find "$RUN_TMP" -name 'gcp-dev-backend*')" + fi +} + +if [ "${BASH_VERSINFO[0]}" -ge 4 ]; then + assert_targets "project A" "$PROJECT_A" "$BACKEND_A" + # A second, unrelated project and a multi-line backend, so a hardcoded value + # cannot pass by coinciding with the first fixture. + assert_targets "project B" "$PROJECT_B" "$(printf 'bucket = "other-state"\nimpersonate_service_account = "tf@%s.iam.gserviceaccount.com"' "$PROJECT_B")" + # A TF_VAR_project_id that agrees with GCP_PROJECT_ID is accepted. + assert_targets "matching TF_VAR_project_id" "$PROJECT_A" "$BACKEND_A" TF_VAR_project_id="$PROJECT_A" +elif [ "${GITHUB_ACTIONS:-}" = "true" ] || [ "${CI:-}" = "true" ]; then + ko "end-to-end cases need bash >= 4 (running ${BASH_VERSION})" +else + echo "SKIP: end-to-end cases need bash >= 4 (running ${BASH_VERSION}); CI runs them" +fi + +# --------------------------------------------------------------------------- +# Half 2: missing or malformed configuration is refused before any side effect +# --------------------------------------------------------------------------- + +# assert_refused LABEL EXPECTED_STDERR_SUBSTRING [VAR=value ...] +assert_refused() { + local label="$1" want_err="$2" + shift 2 + run_target "$@" + + local ncalls + ncalls="$(grep -c . "$CALLS" || true)" + if [ "$RUN_EXIT" -eq 2 ] && + [ "$ncalls" -eq 0 ] && + [ "$(leftover_backend_files)" = "0" ] && + printf '%s' "$RUN_ERR" | grep -qF -- "$want_err" && + printf '%s' "$RUN_ERR" | grep -qF -- "usage:"; then + ok "refused: $label" + else + ko "refused: $label" \ + "expected exit 2, got $RUN_EXIT" \ + "expected 0 terraform/gcloud calls, got $ncalls" \ + "backend files left in TMPDIR: $(leftover_backend_files)" \ + "expected stderr to contain '$want_err' and 'usage:'" \ + "stderr: $RUN_ERR" + fi +} + +NL=' +' + +assert_refused "neither variable set" "GCP_PROJECT_ID is not set" +assert_refused "GCP_PROJECT_ID unset" "GCP_PROJECT_ID is not set" \ + TF_BACKEND_GCP="$BACKEND_A" +assert_refused "GCP_PROJECT_ID empty" "GCP_PROJECT_ID is not set" \ + GCP_PROJECT_ID="" TF_BACKEND_GCP="$BACKEND_A" +assert_refused "TF_BACKEND_GCP unset" "TF_BACKEND_GCP is not set" \ + GCP_PROJECT_ID="$PROJECT_A" +assert_refused "TF_BACKEND_GCP empty" "TF_BACKEND_GCP is not set" \ + GCP_PROJECT_ID="$PROJECT_A" TF_BACKEND_GCP="" +assert_refused "GCP_PROJECT_ID with uppercase" "GCP_PROJECT_ID is malformed" \ + GCP_PROJECT_ID="Example-Dev-Project" TF_BACKEND_GCP="$BACKEND_A" +assert_refused "GCP_PROJECT_ID too short" "GCP_PROJECT_ID is malformed" \ + GCP_PROJECT_ID="abc" TF_BACKEND_GCP="$BACKEND_A" +assert_refused "GCP_PROJECT_ID whitespace only" "GCP_PROJECT_ID is malformed" \ + GCP_PROJECT_ID=" " TF_BACKEND_GCP="$BACKEND_A" +assert_refused "GCP_PROJECT_ID with an embedded newline" "GCP_PROJECT_ID is malformed" \ + GCP_PROJECT_ID="${PROJECT_A}${NL}${PROJECT_B}" TF_BACKEND_GCP="$BACKEND_A" +assert_refused "TF_BACKEND_GCP is a bare bucket name" "has no bucket" \ + GCP_PROJECT_ID="$PROJECT_A" TF_BACKEND_GCP="example-tfstate-bucket" +assert_refused "TF_BACKEND_GCP sets its own prefix" "must not set prefix" \ + GCP_PROJECT_ID="$PROJECT_A" TF_BACKEND_GCP="${BACKEND_A}${NL}prefix = \"github-prod\"" +assert_refused "TF_VAR_project_id names a different project" "does not match GCP_PROJECT_ID" \ + GCP_PROJECT_ID="$PROJECT_A" TF_BACKEND_GCP="$BACKEND_A" TF_VAR_project_id="$PROJECT_B" + +echo +echo "Results: ${pass} passed, ${fail} failed" +[ "$fail" -eq 0 ]