From 66a1f14ced8fb1506b65c02425e517d05097b823 Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Mon, 19 Oct 2026 14:07:13 +0100 Subject: [PATCH 1/2] fix(scripts): require GCP_PROJECT_ID and TF_BACKEND_GCP in gcp-import-dev-state.sh The script hardcoded the dev GCP project ID and the Terraform state bucket as unconditional literals, so it could only ever query and import one project, and pointing it at another environment's backend silently imported the dev project's resources into that state. Read the project from GCP_PROJECT_ID and the backend config from TF_BACKEND_GCP, the same values deploy-gcp.yml takes from vars.GCP_PROJECT_ID and secrets.TF_BACKEND_GCP, with no default. The script now exits 2 with a usage message before touching terraform, gcloud or a backend file when either is unset, the project ID is malformed, the backend has no bucket line or sets its own prefix, or a TF_VAR_project_id in the environment names a different project. The project is exported as TF_VAR_project_id so terraform and gcloud agree. Since the backend config is now a secret supplied by the operator, it is written to a private mktemp file removed on exit instead of a fixed, world-readable /tmp path. Add scripts/test-gcp-import-dev-state.sh, which runs the script end to end against stubbed terraform and gcloud, and wire it into ci.yml. Closes #305 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 21 ++ scripts/delete-owned-cloud-sql-instance.sh | 2 +- scripts/gcp-import-dev-state.sh | 71 ++++- scripts/test-gcp-import-dev-state.sh | 293 +++++++++++++++++++++ 4 files changed, 381 insertions(+), 6 deletions(-) create mode 100755 scripts/test-gcp-import-dev-state.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b885e287e..d8b91cc77 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1184,6 +1184,26 @@ jobs: - name: Run smoke-gate self-test run: bash scripts/test-wait-for-healthy.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 @@ -1206,6 +1226,7 @@ jobs: - cloud-sql-delete-scope - aws-tfstate-platform-key - azure-kv-access-policy + - 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 914846d78..a0679748f 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 c5cbc61d7..d1fbecc0e 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 000000000..44fde48f5 --- /dev/null +++ b/scripts/test-gcp-import-dev-state.sh @@ -0,0 +1,293 @@ +#!/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). 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 + + bad="$(grep '^terraform .* import ' "$CALLS" | grep -v -- "${project}" || true)" + local id_projects + id_projects="$(grep '^terraform .* import ' "$CALLS" | grep -o 'projects/[^/ ]*' | sort -u | tr '\n' ' ')" + if [ -z "$bad" ] && [ "$id_projects" = "projects/${project} " ]; then + ok "$label: every import ID names ${project} and no other project" + else + ko "$label: every import ID names ${project} and no other project" \ + "imports without it: $bad" "projects/ prefixes seen: $id_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 ] From ec5c461b565a22f2767fd987e98ed6e6c0c2ab0b Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Fri, 23 Oct 2026 01:28:39 +0000 Subject: [PATCH 2/2] test(scripts): check gcp import IDs alone and run the target under $BASH Address the two non-blocking review findings on #1119: - The "every import ID names the project" check grepped whole stub log lines, which always carry TF_VAR_project_id=, so it could not fail. A project hard-coded in the router, NAT or project IAM member IDs stayed green. Strip the log prefix and address, then require every ID to lead with the project (projects/

/...,

/... or "

roles/...") and every service-account email in an ID to be in the project. - Run the script under test with "$BASH" instead of the first `bash` on the stub PATH, so the bash >= 4 gate checks the bash that actually runs it (on macOS a Homebrew bash 5 ran the target under /bin/bash 3.2 and reported FAIL instead of SKIP). Co-Authored-By: Claude Opus 5.5 --- scripts/test-gcp-import-dev-state.sh | 31 ++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/scripts/test-gcp-import-dev-state.sh b/scripts/test-gcp-import-dev-state.sh index 44fde48f5..fff9ccd83 100755 --- a/scripts/test-gcp-import-dev-state.sh +++ b/scripts/test-gcp-import-dev-state.sh @@ -24,9 +24,11 @@ # file. # # Half 1 needs bash >= 4, because the script under test uses `declare -A` -# (the bash 3.2 abort is tracked separately). 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. +# (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. @@ -131,7 +133,7 @@ run_target() { STUB_CALLS="$CALLS" \ STUB_CAPTURED_BACKEND="$CAPTURED_BACKEND" \ "$@" \ - bash "$TARGET" >"${WORK}/out" 2>"${WORK}/err" || RUN_EXIT=$? + "$BASH" "$TARGET" >"${WORK}/out" 2>"${WORK}/err" || RUN_EXIT=$? RUN_ERR="$(cat "${WORK}/err")" } @@ -180,14 +182,25 @@ assert_targets() { ko "$label: no gcloud call names another project" "seen: $projects_named" fi - bad="$(grep '^terraform .* import ' "$CALLS" | grep -v -- "${project}" || true)" - local id_projects - id_projects="$(grep '^terraform .* import ' "$CALLS" | grep -o 'projects/[^/ ]*' | sort -u | tr '\n' ' ')" - if [ -z "$bad" ] && [ "$id_projects" = "projects/${project} " ]; then + # 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" \ - "imports without it: $bad" "projects/ prefixes seen: $id_projects" + "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)"