diff --git a/ci/gcp-upgrade-smoke-contract.sh b/ci/gcp-upgrade-smoke-contract.sh index 0713e12..88a33fd 100755 --- a/ci/gcp-upgrade-smoke-contract.sh +++ b/ci/gcp-upgrade-smoke-contract.sh @@ -52,6 +52,15 @@ grep -Fq 'CLOUD_COMPOSE_SMOKE_RUN_ID must be set explicitly outside GitHub Actio fail "manual upgrade runs can accidentally reuse an implicit cleanup scope" grep -Fq '"$runner" gcp namespace --run-id "$run_id"' "$script" || fail "upgrade runner does not use the shared exact run-namespace codec" +grep -Fq '"$runner" gcp upgrade check-plan --plan "$plan_json"' "$script" || + fail "upgrade runner does not delegate Terraform plan policy to the compiled CI runner" +grep -Fq '"$runner" gcp upgrade check-transition \' "$script" || + fail "upgrade runner does not delegate state-transition policy to the compiled CI runner" +grep -Fq '"$runner" gcp upgrade capture-ids --phase "$phase" --state "$state_json"' "$script" || + fail "upgrade runner does not delegate immutable state identity capture to the compiled CI runner" +if sed -n '/assert_upgrade_plan()/,/^}/p; /assert_state_transition()/,/^}/p; /write_phase_ids()/,/^}/p' "$script" | grep -Fq 'jq '; then + fail "upgrade plan, identity capture, or state-transition policy remains implemented in jq" +fi grep -Fq 'name="cc-g-wp-${run_namespace}-up"' "$script" || fail "upgrade resource names do not use the exact run namespace" grep -Fq 'cloud-compose-gcp-upgrade-${run_namespace}' "$script" || @@ -63,10 +72,6 @@ grep -Fq '/mnt/disks/data/.cloud-compose-upgrade-sentinel' "$script" || fail "upgrade runner omits the persistent data-disk sentinel" grep -Fq '/mnt/disks/volumes/.cloud-compose-upgrade-sentinel' "$script" || fail "upgrade runner omits the Docker-volume disk sentinel" -grep -Fq 'capture_state_resource_attribute "$state_json" "$boot_disk_address" disk_id' "$script" || - fail "upgrade runner does not compare the replaced boot disk's immutable numeric identity" -grep -Fq 'capture_state_resource_attribute "$state_json" "$vm_address" instance_id' "$script" || - fail "upgrade runner does not compare the replaced VM's immutable numeric identity" [[ "$(grep -Fc 'sudo tee /mnt/disks/data/.cloud-compose-upgrade-sentinel' "$script")" -eq 1 ]] || fail "upgrade runner must write the data-disk sentinel exactly once" [[ "$(grep -Fc 'sudo tee /mnt/disks/volumes/.cloud-compose-upgrade-sentinel' "$script")" -eq 1 ]] || @@ -76,8 +81,6 @@ grep -Fq 'backend "local" {}' "$fixture" || fail "upgrade fixture does not declare the shared local backend" grep -Fq 'source = "../../.."' "$fixture" || fail "upgrade fixture does not exercise the GCP-only compatibility root" -grep -Fq 'readonly resource_prefix="module.app.module.gcp[0]"' "$script" || - fail "upgrade runner does not preserve the root GCP module's indexed state address" grep -Fq 'create = false' "$fixture" || fail "upgrade fixture still owns an ephemeral network" grep -Fq 'project_id = var.gcp_network_project_id' "$fixture" || @@ -385,35 +388,6 @@ EOF bash "$script" check-plan "$tmp/good-plan.json" -cat >"$tmp/state-identities.json" <<'EOF' -{ - "values": { - "root_module": { - "resources": [ - { - "address": "module.contract.google_compute_disk.boot", - "values": {"id": "projects/p/zones/z/disks/same-name", "disk_id": "111"} - }, - { - "address": "module.contract.google_compute_instance.vm", - "values": {"id": "projects/p/zones/z/instances/same-name", "instance_id": "222"} - } - ] - } - } -} -EOF -[[ "$(bash -c 'source "$1"; state_resource_attribute "$2" "$3" disk_id' _ \ - "$script" "$tmp/state-identities.json" 'module.contract.google_compute_disk.boot')" == "111" ]] || - fail "state identity reader did not select google_compute_disk.disk_id" -[[ "$(bash -c 'source "$1"; state_resource_attribute "$2" "$3" instance_id' _ \ - "$script" "$tmp/state-identities.json" 'module.contract.google_compute_instance.vm')" == "222" ]] || - fail "state identity reader did not select google_compute_instance.instance_id" -if bash -c 'source "$1"; capture_state_resource_attribute "$2" "$3" disk_id' _ \ - "$script" "$tmp/state-identities.json" 'module.contract.google_compute_instance.vm' >/dev/null 2>&1; then - fail "state identity capture accepted a missing immutable provider attribute" -fi - jq '(.resource_changes[] | select(.address | endswith("stackdriver[0]")) | .previous_address) = "wrong"' \ "$tmp/good-plan.json" >"$tmp/bad-move.json" if bash "$script" check-plan "$tmp/bad-move.json" >/dev/null 2>&1; then diff --git a/ci/gcp-upgrade-smoke.sh b/ci/gcp-upgrade-smoke.sh index 7f258e1..91caf7b 100755 --- a/ci/gcp-upgrade-smoke.sh +++ b/ci/gcp-upgrade-smoke.sh @@ -11,46 +11,6 @@ repo_root="$(cd -- "$script_dir/.." && pwd)" source "$script_dir/cloud-smoke.sh" readonly upgrade_base_sha="f33117cdbbf4a9c7d59006a4db986baef118e6bb" -readonly resource_prefix="module.app.module.gcp[0]" -readonly data_disk_address="${resource_prefix}.google_compute_disk.data" -readonly docker_disk_address="${resource_prefix}.google_compute_disk.docker-volumes" -readonly boot_disk_address="${resource_prefix}.google_compute_disk.boot" -readonly vm_address="${resource_prefix}.google_compute_instance.cloud-compose" -readonly legacy_start_address="${resource_prefix}.google_project_iam_member.gce-start[0]" -readonly legacy_suspend_address="${resource_prefix}.google_project_iam_member.gce-suspend" -readonly legacy_gsa_user_address="${resource_prefix}.google_service_account_iam_member.gsa-user" -readonly legacy_vm_token_creator_address="${resource_prefix}.google_service_account_iam_member.token-creator" -readonly legacy_app_token_creator_address="${resource_prefix}.google_service_account_iam_member.self_jwt_signer_policy" -readonly conditional_app_token_creator_address="${resource_prefix}.google_service_account_iam_member.vault_agent_jwt_signer_policy[0]" -readonly scoped_start_address="${resource_prefix}.google_compute_instance_iam_member.gce-start[0]" -readonly scoped_suspend_address="${resource_prefix}.google_compute_instance_iam_member.gce-suspend[0]" -readonly -a legacy_removed_from_addresses=( - "$legacy_start_address" - "$legacy_suspend_address" - "$legacy_gsa_user_address" - "$legacy_vm_token_creator_address" - "$legacy_app_token_creator_address" -) -readonly -a legacy_removed_to_addresses=( - "$legacy_start_address" - "$legacy_suspend_address" - "$legacy_gsa_user_address" - "$legacy_vm_token_creator_address" - "$conditional_app_token_creator_address" -) - -readonly -a moved_from_addresses=( - "${resource_prefix}.google_service_account.internal-services" - "${resource_prefix}.google_service_account_iam_member.internal-services-keys" - "${resource_prefix}.google_project_iam_member.stackdriver" - "${resource_prefix}.google_service_account_iam_member.app-keys" -) -readonly -a moved_to_addresses=( - "${resource_prefix}.google_service_account.internal-services[0]" - "${resource_prefix}.google_service_account_iam_member.internal-services-keys[0]" - "${resource_prefix}.google_project_iam_member.stackdriver[0]" - "${resource_prefix}.google_service_account_iam_member.app-keys[0]" -) usage() { cat <<'EOF' @@ -212,258 +172,40 @@ validate_upgrade_network() { } assert_upgrade_plan() { - local plan_json="$1" index previous_address current_address - - [[ -s "$plan_json" ]] || fail "plan JSON is missing: $plan_json" - jq -e '.format_version and (.resource_changes | type == "array")' "$plan_json" >/dev/null || - fail "Terraform plan JSON is invalid" - - for index in "${!moved_from_addresses[@]}"; do - previous_address="${moved_from_addresses[$index]}" - current_address="${moved_to_addresses[$index]}" - jq -e \ - --arg previous "$previous_address" \ - --arg current "$current_address" ' - any(.resource_changes[]?; - .address == $current and - .previous_address == $previous and - ((.change.actions | index("delete")) == null) - ) - ' "$plan_json" >/dev/null || - fail "plan did not preserve moved resource ${previous_address} -> ${current_address}" - done - - for current_address in "$data_disk_address" "$docker_disk_address"; do - jq -e --arg address "$current_address" ' - any(.resource_changes[]?; - .address == $address and .change.actions == ["no-op"] - ) - ' "$plan_json" >/dev/null || - fail "persistent disk was not a no-op in the upgrade plan: ${current_address}" - done - - for index in "${!legacy_removed_to_addresses[@]}"; do - previous_address="${legacy_removed_from_addresses[$index]}" - current_address="${legacy_removed_to_addresses[$index]}" - jq -e --arg address "$current_address" ' - any(.resource_changes[]?; - .address == $address and .change.actions == ["delete"] - ) - ' "$plan_json" >/dev/null || - fail "upgrade plan did not remove the legacy over-broad IAM binding: ${current_address}" - if [[ "$previous_address" != "$current_address" ]]; then - jq -e --arg previous "$previous_address" --arg current "$current_address" ' - any(.resource_changes[]?; - .address == $current and .previous_address == $previous - ) - ' "$plan_json" >/dev/null || - fail "plan did not preserve moved-resource provenance before removing ${previous_address}" - fi - done + local plan_json="$1" runner - for current_address in "$scoped_start_address" "$scoped_suspend_address"; do - jq -e --arg address "$current_address" ' - any(.resource_changes[]?; - .address == $address and .change.actions == ["create"] - ) - ' "$plan_json" >/dev/null || - fail "upgrade plan did not create the instance-scoped power binding: ${current_address}" - done - - jq -e \ - --arg boot "$boot_disk_address" \ - --arg vm "$vm_address" \ - --arg legacy_start "$legacy_start_address" \ - --arg legacy_suspend "$legacy_suspend_address" \ - --arg legacy_gsa_user "$legacy_gsa_user_address" \ - --arg legacy_vm_token_creator "$legacy_vm_token_creator_address" \ - --arg conditional_app_token_creator "$conditional_app_token_creator_address" ' - def replacement: - . == ["delete", "create"] or . == ["create", "delete"]; - - ([ - .resource_changes[]? | - select((.mode // "managed") == "managed") | - select(.change.actions | index("delete")) | - .address - ] | sort) == ([$boot, $vm, $legacy_start, $legacy_suspend, $legacy_gsa_user, $legacy_vm_token_creator, $conditional_app_token_creator] | sort) and - all(.resource_changes[]?; - if (.mode // "managed") == "data" then - true - elif .address == $boot or .address == $vm then - (.change.actions | replacement) - elif .address == $legacy_start or - .address == $legacy_suspend or - .address == $legacy_gsa_user or - .address == $legacy_vm_token_creator or - .address == $conditional_app_token_creator then - .change.actions == ["delete"] - else - ((.change.actions | index("delete")) == null) - end - ) - ' "$plan_json" >/dev/null || - fail "upgrade plan contained a managed-resource deletion beyond the expected VM/boot replacements and legacy IAM removals" + runner="${CLOUD_COMPOSE_CI_BIN:-$repo_root/.bin/cloud-compose-ci}" + if [[ ! -x "$runner" ]]; then + fail "compiled CI runner is missing at ${runner}; run 'make cloud-compose-ci' first" + return 1 + fi + "$runner" gcp upgrade check-plan --plan "$plan_json" } assert_state_transition() { - local old_ids="$1" new_ids="$2" old_state_list="$3" new_state_list="$4" - local key address index previous_address current_address - - for key in data_disk docker_disk internal_service_account internal_service_keys stackdriver app_key_admin; do - jq -ne \ - --slurpfile old "$old_ids" \ - --slurpfile new "$new_ids" \ - --arg key "$key" ' - ($old[0][$key] | type == "string" and length > 0) and - $old[0][$key] == $new[0][$key] - ' >/dev/null || - fail "resource identity changed across the upgrade: ${key}" - done + local old_ids="$1" new_ids="$2" old_state_list="$3" new_state_list="$4" runner - for key in boot_disk vm; do - jq -ne \ - --slurpfile old "$old_ids" \ - --slurpfile new "$new_ids" \ - --arg key "$key" ' - ($old[0][$key] | type == "string" and length > 0) and - ($new[0][$key] | type == "string" and length > 0) and - $old[0][$key] != $new[0][$key] - ' >/dev/null || - fail "expected replacement did not change resource identity: ${key}" - done - - for key in legacy_start legacy_suspend; do - jq -e --arg key "$key" '.[$key] | type == "string" and length > 0' "$old_ids" >/dev/null || - fail "baseline state did not capture the legacy project-wide power binding: ${key}" - done - for key in scoped_start scoped_suspend; do - jq -e --arg key "$key" '.[$key] | type == "string" and length > 0' "$new_ids" >/dev/null || - fail "upgraded state did not capture the instance-scoped power binding: ${key}" - done - - for address in "${moved_from_addresses[@]}"; do - grep -Fxq "$address" "$old_state_list" || - fail "baseline state did not contain expected legacy address: ${address}" - if grep -Fxq "$address" "$new_state_list"; then - fail "upgraded state retained legacy address: ${address}" - fi - done - for address in "${moved_to_addresses[@]}"; do - grep -Fxq "$address" "$new_state_list" || - fail "upgraded state did not contain moved address: ${address}" - done - for index in "${!legacy_removed_from_addresses[@]}"; do - previous_address="${legacy_removed_from_addresses[$index]}" - current_address="${legacy_removed_to_addresses[$index]}" - grep -Fxq "$previous_address" "$old_state_list" || - fail "baseline state did not contain expected legacy IAM binding: ${previous_address}" - if grep -Fxq "$previous_address" "$new_state_list" || grep -Fxq "$current_address" "$new_state_list"; then - fail "upgraded state retained legacy IAM binding: ${previous_address}" - fi - done - for address in "$scoped_start_address" "$scoped_suspend_address"; do - if grep -Fxq "$address" "$old_state_list"; then - fail "baseline state unexpectedly contained current instance-scoped power binding: ${address}" - fi - grep -Fxq "$address" "$new_state_list" || - fail "upgraded state did not contain instance-scoped power binding: ${address}" - done -} - -state_resource_attribute() { - local state_json="$1" address="$2" attribute="${3:-id}" - - jq -er --arg address "$address" --arg attribute "$attribute" ' - def all_resources: - .resources[]?, - (.child_modules[]? | all_resources); - - .values.root_module | - all_resources | - select(.address == $address) | - .values[$attribute] | - select(type == "string" and length > 0) - ' "$state_json" -} - -capture_state_resource_attribute() { - local state_json="$1" address="$2" attribute="${3:-id}" - local value - - if ! value="$(state_resource_attribute "$state_json" "$address" "$attribute")"; then - fail "state did not expose ${attribute} for ${address}" + runner="${CLOUD_COMPOSE_CI_BIN:-$repo_root/.bin/cloud-compose-ci}" + if [[ ! -x "$runner" ]]; then + fail "compiled CI runner is missing at ${runner}; run 'make cloud-compose-ci' first" return 1 fi - printf '%s\n' "$value" + "$runner" gcp upgrade check-transition \ + --old-ids "$old_ids" \ + --new-ids "$new_ids" \ + --old-state "$old_state_list" \ + --new-state "$new_state_list" } write_phase_ids() { - local phase="$1" state_json="$2" destination="$3" - local internal_service_account internal_service_keys stackdriver app_key_admin start_binding suspend_binding - local data_disk_id docker_disk_id boot_disk_id vm_id internal_service_account_id - local internal_service_keys_id stackdriver_id app_key_admin_id start_binding_id suspend_binding_id - - case "$phase" in - old) - internal_service_account="${moved_from_addresses[0]}" - internal_service_keys="${moved_from_addresses[1]}" - stackdriver="${moved_from_addresses[2]}" - app_key_admin="${moved_from_addresses[3]}" - start_binding="$legacy_start_address" - suspend_binding="$legacy_suspend_address" - ;; - new) - internal_service_account="${moved_to_addresses[0]}" - internal_service_keys="${moved_to_addresses[1]}" - stackdriver="${moved_to_addresses[2]}" - app_key_admin="${moved_to_addresses[3]}" - start_binding="$scoped_start_address" - suspend_binding="$scoped_suspend_address" - ;; - *) fail "unknown state-capture phase: $phase" ;; - esac - - data_disk_id="$(capture_state_resource_attribute "$state_json" "$data_disk_address")" || return 1 - docker_disk_id="$(capture_state_resource_attribute "$state_json" "$docker_disk_address")" || return 1 - boot_disk_id="$(capture_state_resource_attribute "$state_json" "$boot_disk_address" disk_id)" || return 1 - vm_id="$(capture_state_resource_attribute "$state_json" "$vm_address" instance_id)" || return 1 - internal_service_account_id="$(capture_state_resource_attribute "$state_json" "$internal_service_account")" || return 1 - internal_service_keys_id="$(capture_state_resource_attribute "$state_json" "$internal_service_keys")" || return 1 - stackdriver_id="$(capture_state_resource_attribute "$state_json" "$stackdriver")" || return 1 - app_key_admin_id="$(capture_state_resource_attribute "$state_json" "$app_key_admin")" || return 1 - start_binding_id="$(capture_state_resource_attribute "$state_json" "$start_binding")" || return 1 - suspend_binding_id="$(capture_state_resource_attribute "$state_json" "$suspend_binding")" || return 1 + local phase="$1" state_json="$2" destination="$3" runner - jq -n \ - --arg data_disk "$data_disk_id" \ - --arg docker_disk "$docker_disk_id" \ - --arg boot_disk "$boot_disk_id" \ - --arg vm "$vm_id" \ - --arg internal_service_account "$internal_service_account_id" \ - --arg internal_service_keys "$internal_service_keys_id" \ - --arg stackdriver "$stackdriver_id" \ - --arg app_key_admin "$app_key_admin_id" \ - --arg start_binding "$start_binding_id" \ - --arg suspend_binding "$suspend_binding_id" \ - --arg phase "$phase" ' - ({ - data_disk: $data_disk, - docker_disk: $docker_disk, - boot_disk: $boot_disk, - vm: $vm, - internal_service_account: $internal_service_account, - internal_service_keys: $internal_service_keys, - stackdriver: $stackdriver, - app_key_admin: $app_key_admin - }) + if $phase == "old" then { - legacy_start: $start_binding, - legacy_suspend: $suspend_binding - } else { - scoped_start: $start_binding, - scoped_suspend: $suspend_binding - } end - ' >"$destination" + runner="${CLOUD_COMPOSE_CI_BIN:-$repo_root/.bin/cloud-compose-ci}" + if [[ ! -x "$runner" ]]; then + fail "compiled CI runner is missing at ${runner}; run 'make cloud-compose-ci' first" + return 1 + fi + "$runner" gcp upgrade capture-ids --phase "$phase" --state "$state_json" >"$destination" chmod 0600 "$destination" } @@ -1045,12 +787,10 @@ upgrade_main() { ;; check-plan) [[ "$#" -eq 2 ]] || { usage >&2; return 2; } - require_cmd jq assert_upgrade_plan "$2" ;; check-transition) [[ "$#" -eq 5 ]] || { usage >&2; return 2; } - require_cmd jq assert_state_transition "$2" "$3" "$4" "$5" ;; *) diff --git a/cmd/cloud-compose-ci/main.go b/cmd/cloud-compose-ci/main.go index f452877..754f0e0 100644 --- a/cmd/cloud-compose-ci/main.go +++ b/cmd/cloud-compose-ci/main.go @@ -3,6 +3,7 @@ package main import ( "context" + "encoding/json" "flag" "fmt" "io" @@ -15,6 +16,7 @@ import ( "github.com/libops/cloud-compose/internal/gcpcleanup" "github.com/libops/cloud-compose/internal/providercleanup" "github.com/libops/cloud-compose/internal/runnamespace" + "github.com/libops/cloud-compose/internal/upgradecheck" ) type providerSweeper interface { @@ -60,6 +62,8 @@ func run( return runGCPNamespace(args[2:], stdout, stderr) case "sweep": return runGCPSweep(ctx, args[2:], getenv, stderr, commander, redactor) + case "upgrade": + return runGCPUpgradeCheck(args[2:], stdout, stderr) default: printUsage(stderr) return 2 @@ -76,6 +80,123 @@ func run( } } +func runGCPUpgradeCheck(args []string, stdout, stderr io.Writer) int { + if len(args) == 0 { + printUsage(stderr) + return 2 + } + + switch args[0] { + case "check-plan": + flags := flag.NewFlagSet("cloud-compose-ci gcp upgrade check-plan", flag.ContinueOnError) + flags.SetOutput(stderr) + planPath := flags.String("plan", "", "Terraform plan JSON path") + if err := flags.Parse(args[1:]); err != nil { + return 2 + } + if flags.NArg() != 0 || strings.TrimSpace(*planPath) == "" { + fmt.Fprintln(stderr, "--plan is required and positional arguments are not accepted") + return 2 + } + + planFile, err := os.Open(*planPath) + if err != nil { + fmt.Fprintf(stderr, "open Terraform plan JSON: %s\n", err) + return 1 + } + defer planFile.Close() + if err := upgradecheck.ValidatePlan(planFile); err != nil { + fmt.Fprintf(stderr, "GCP upgrade plan check failed: %s\n", err) + return 1 + } + return 0 + case "capture-ids": + flags := flag.NewFlagSet("cloud-compose-ci gcp upgrade capture-ids", flag.ContinueOnError) + flags.SetOutput(stderr) + phase := flags.String("phase", "", "state phase: old or new") + statePath := flags.String("state", "", "Terraform state JSON path") + if err := flags.Parse(args[1:]); err != nil { + return 2 + } + if flags.NArg() != 0 || strings.TrimSpace(*phase) == "" || strings.TrimSpace(*statePath) == "" { + fmt.Fprintln(stderr, "--phase and --state are required and positional arguments are not accepted") + return 2 + } + + stateFile, err := os.Open(*statePath) + if err != nil { + fmt.Fprintf(stderr, "open Terraform state JSON: %s\n", err) + return 1 + } + defer stateFile.Close() + identities, err := upgradecheck.CaptureIDs(stateFile, *phase) + if err != nil { + fmt.Fprintf(stderr, "GCP upgrade state capture failed: %s\n", err) + return 1 + } + encoder := json.NewEncoder(stdout) + encoder.SetIndent("", " ") + if err := encoder.Encode(identities); err != nil { + fmt.Fprintf(stderr, "write GCP upgrade resource identities: %s\n", err) + return 1 + } + return 0 + case "check-transition": + flags := flag.NewFlagSet("cloud-compose-ci gcp upgrade check-transition", flag.ContinueOnError) + flags.SetOutput(stderr) + oldIDsPath := flags.String("old-ids", "", "baseline resource-identity JSON path") + newIDsPath := flags.String("new-ids", "", "upgraded resource-identity JSON path") + oldStatePath := flags.String("old-state", "", "baseline Terraform state-list path") + newStatePath := flags.String("new-state", "", "upgraded Terraform state-list path") + if err := flags.Parse(args[1:]); err != nil { + return 2 + } + if flags.NArg() != 0 || + strings.TrimSpace(*oldIDsPath) == "" || + strings.TrimSpace(*newIDsPath) == "" || + strings.TrimSpace(*oldStatePath) == "" || + strings.TrimSpace(*newStatePath) == "" { + fmt.Fprintln(stderr, "--old-ids, --new-ids, --old-state, and --new-state are required and positional arguments are not accepted") + return 2 + } + + files := make([]*os.File, 0, 4) + for _, input := range []struct { + label string + path string + }{ + {label: "baseline resource identities", path: *oldIDsPath}, + {label: "upgraded resource identities", path: *newIDsPath}, + {label: "baseline state list", path: *oldStatePath}, + {label: "upgraded state list", path: *newStatePath}, + } { + file, err := os.Open(input.path) + if err != nil { + for _, opened := range files { + _ = opened.Close() + } + fmt.Fprintf(stderr, "open %s: %s\n", input.label, err) + return 1 + } + files = append(files, file) + } + defer func() { + for _, file := range files { + _ = file.Close() + } + }() + + if err := upgradecheck.ValidateTransition(files[0], files[1], files[2], files[3]); err != nil { + fmt.Fprintf(stderr, "GCP upgrade state-transition check failed: %s\n", err) + return 1 + } + return 0 + default: + printUsage(stderr) + return 2 + } +} + func runIDValidate(args []string, stderr io.Writer) int { flags := flag.NewFlagSet("cloud-compose-ci run validate", flag.ContinueOnError) flags.SetOutput(stderr) @@ -297,4 +418,7 @@ func printUsage(writer io.Writer) { fmt.Fprintln(writer, " cloud-compose-ci linode sweep [--scope application|config-management] --target TARGET (--run-id RUN_ID | --all-runs)") fmt.Fprintln(writer, " cloud-compose-ci gcp namespace --run-id RUN_ID") fmt.Fprintln(writer, " cloud-compose-ci gcp sweep [--project PROJECT] [--region REGION] [--target gcp-wp] (--run-id RUN_ID | --all-runs)") + fmt.Fprintln(writer, " cloud-compose-ci gcp upgrade check-plan --plan PLAN_JSON") + fmt.Fprintln(writer, " cloud-compose-ci gcp upgrade capture-ids --phase old|new --state STATE_JSON") + fmt.Fprintln(writer, " cloud-compose-ci gcp upgrade check-transition --old-ids FILE --new-ids FILE --old-state FILE --new-state FILE") } diff --git a/cmd/cloud-compose-ci/main_test.go b/cmd/cloud-compose-ci/main_test.go index b76b2d0..dbaf59b 100644 --- a/cmd/cloud-compose-ci/main_test.go +++ b/cmd/cloud-compose-ci/main_test.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "log/slog" + "path/filepath" "slices" "strings" "testing" @@ -245,6 +246,134 @@ func TestRunGCPNamespaceReportsOutputFailure(t *testing.T) { } } +func TestRunGCPUpgradeChecks(t *testing.T) { + t.Parallel() + + fixture := func(name string) string { + return filepath.Join("..", "..", "internal", "upgradecheck", "testdata", name) + } + tests := []struct { + name string + args []string + wantOutput bool + }{ + { + name: "plan", + args: []string{ + "gcp", "upgrade", "check-plan", + "--plan", fixture("good-plan.json"), + }, + }, + { + name: "capture baseline identities", + args: []string{ + "gcp", "upgrade", "capture-ids", + "--phase", "old", + "--state", fixture("old-state.json"), + }, + wantOutput: true, + }, + { + name: "state transition", + args: []string{ + "gcp", "upgrade", "check-transition", + "--old-ids", fixture("old-ids.json"), + "--new-ids", fixture("new-ids.json"), + "--old-state", fixture("old-state.txt"), + "--new-state", fixture("new-state.txt"), + }, + }, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + var stdout bytes.Buffer + var stderr bytes.Buffer + status := run( + context.Background(), + test.args, + environmentGetter(nil), + &stdout, + &stderr, + &emptyGCloud{}, + gcpcleanup.NewRedactor(), + nil, + ) + if status != 0 { + t.Fatalf("run() status = %d, stderr = %s", status, stderr.String()) + } + if test.wantOutput && !strings.Contains(stdout.String(), `"boot_disk": "boot-old"`) { + t.Fatalf("stdout omits captured identities: %q", stdout.String()) + } + if !test.wantOutput && stdout.Len() != 0 { + t.Fatalf("unexpected stdout: %q", stdout.String()) + } + if stderr.Len() != 0 { + t.Fatalf("unexpected output: stdout=%q stderr=%q", stdout.String(), stderr.String()) + } + }) + } +} + +func TestRunGCPUpgradeChecksRequireExplicitFiles(t *testing.T) { + t.Parallel() + + for _, args := range [][]string{ + {"gcp", "upgrade"}, + {"gcp", "upgrade", "check-plan"}, + {"gcp", "upgrade", "capture-ids"}, + {"gcp", "upgrade", "check-transition"}, + } { + args := args + t.Run(strings.Join(args, " "), func(t *testing.T) { + t.Parallel() + var stdout bytes.Buffer + var stderr bytes.Buffer + status := run( + context.Background(), + args, + environmentGetter(nil), + &stdout, + &stderr, + &emptyGCloud{}, + gcpcleanup.NewRedactor(), + nil, + ) + if status != 2 { + t.Fatalf("run() status = %d; want 2", status) + } + }) + } +} + +func TestRunGCPUpgradeCaptureReportsOutputFailure(t *testing.T) { + t.Parallel() + + var stderr bytes.Buffer + status := run( + context.Background(), + []string{ + "gcp", "upgrade", "capture-ids", + "--phase", "old", + "--state", filepath.Join("..", "..", "internal", "upgradecheck", "testdata", "old-state.json"), + }, + environmentGetter(nil), + failingWriter{}, + &stderr, + &emptyGCloud{}, + gcpcleanup.NewRedactor(), + nil, + ) + if status != 1 { + t.Fatalf("run() status = %d; want 1", status) + } + if !strings.Contains(stderr.String(), "write GCP upgrade resource identities") { + t.Fatalf("stderr omits output failure: %s", stderr.String()) + } +} + func TestRunIDValidateRequiresCanonicalRunIDWithoutOutput(t *testing.T) { t.Parallel() for _, test := range []struct { diff --git a/internal/upgradecheck/check.go b/internal/upgradecheck/check.go new file mode 100644 index 0000000..7275d5b --- /dev/null +++ b/internal/upgradecheck/check.go @@ -0,0 +1,484 @@ +// Package upgradecheck validates the Terraform plan and state transition used +// by the hosted GCP upgrade smoke test. +package upgradecheck + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "io" + "slices" + "strings" +) + +const resourcePrefix = "module.app.module.gcp[0]" + +var ( + dataDiskAddress = resourcePrefix + ".google_compute_disk.data" + dockerDiskAddress = resourcePrefix + ".google_compute_disk.docker-volumes" + bootDiskAddress = resourcePrefix + ".google_compute_disk.boot" + vmAddress = resourcePrefix + ".google_compute_instance.cloud-compose" + + legacyStartAddress = resourcePrefix + ".google_project_iam_member.gce-start[0]" + legacySuspendAddress = resourcePrefix + ".google_project_iam_member.gce-suspend" + legacyGSAUserAddress = resourcePrefix + ".google_service_account_iam_member.gsa-user" + legacyVMTokenCreatorAddress = resourcePrefix + ".google_service_account_iam_member.token-creator" + legacyAppTokenCreatorAddress = resourcePrefix + ".google_service_account_iam_member.self_jwt_signer_policy" + conditionalAppTokenAddress = resourcePrefix + ".google_service_account_iam_member.vault_agent_jwt_signer_policy[0]" + scopedStartAddress = resourcePrefix + ".google_compute_instance_iam_member.gce-start[0]" + scopedSuspendAddress = resourcePrefix + ".google_compute_instance_iam_member.gce-suspend[0]" +) + +type addressMove struct { + previous string + current string +} + +var preservedMoves = []addressMove{ + { + previous: resourcePrefix + ".google_service_account.internal-services", + current: resourcePrefix + ".google_service_account.internal-services[0]", + }, + { + previous: resourcePrefix + ".google_service_account_iam_member.internal-services-keys", + current: resourcePrefix + ".google_service_account_iam_member.internal-services-keys[0]", + }, + { + previous: resourcePrefix + ".google_project_iam_member.stackdriver", + current: resourcePrefix + ".google_project_iam_member.stackdriver[0]", + }, + { + previous: resourcePrefix + ".google_service_account_iam_member.app-keys", + current: resourcePrefix + ".google_service_account_iam_member.app-keys[0]", + }, +} + +var removedLegacyBindings = []addressMove{ + {previous: legacyStartAddress, current: legacyStartAddress}, + {previous: legacySuspendAddress, current: legacySuspendAddress}, + {previous: legacyGSAUserAddress, current: legacyGSAUserAddress}, + {previous: legacyVMTokenCreatorAddress, current: legacyVMTokenCreatorAddress}, + {previous: legacyAppTokenCreatorAddress, current: conditionalAppTokenAddress}, +} + +type plan struct { + FormatVersion json.RawMessage `json:"format_version"` + ResourceChanges json.RawMessage `json:"resource_changes"` +} + +type resourceChange struct { + Address string `json:"address"` + PreviousAddress string `json:"previous_address"` + Mode string `json:"mode"` + Change struct { + Actions []string `json:"actions"` + } `json:"change"` +} + +type stateDocument struct { + Values struct { + RootModule stateModule `json:"root_module"` + } `json:"values"` +} + +type stateModule struct { + Resources []stateResource `json:"resources"` + ChildModules []stateModule `json:"child_modules"` +} + +type stateResource struct { + Address string `json:"address"` + Values map[string]json.RawMessage `json:"values"` +} + +type identitySource struct { + key string + address string + attribute string +} + +// ValidatePlan verifies that an upgrade plan preserves durable resources, +// performs only the expected replacements and removes the legacy IAM grants. +func ValidatePlan(reader io.Reader) error { + if reader == nil { + return errors.New("plan reader is required") + } + + decoder := json.NewDecoder(reader) + var document plan + if err := decoder.Decode(&document); err != nil { + return fmt.Errorf("decode Terraform plan JSON: %w", err) + } + if err := requireEOF(decoder); err != nil { + return err + } + if len(document.FormatVersion) == 0 || string(document.FormatVersion) == "null" { + return errors.New("Terraform plan JSON is missing format_version") + } + + var formatVersion string + if err := json.Unmarshal(document.FormatVersion, &formatVersion); err != nil || strings.TrimSpace(formatVersion) == "" { + return errors.New("Terraform plan JSON has an invalid format_version") + } + if len(document.ResourceChanges) == 0 || string(document.ResourceChanges) == "null" { + return errors.New("Terraform plan JSON is missing resource_changes") + } + + var changes []resourceChange + if err := json.Unmarshal(document.ResourceChanges, &changes); err != nil { + return fmt.Errorf("decode Terraform resource_changes: %w", err) + } + + for _, move := range preservedMoves { + if !anyChange(changes, func(change resourceChange) bool { + return change.Address == move.current && + change.PreviousAddress == move.previous && + !slices.Contains(change.Change.Actions, "delete") + }) { + return fmt.Errorf("plan did not preserve moved resource %s -> %s", move.previous, move.current) + } + } + + for _, address := range []string{dataDiskAddress, dockerDiskAddress} { + if !anyChange(changes, func(change resourceChange) bool { + return change.Address == address && slices.Equal(change.Change.Actions, []string{"no-op"}) + }) { + return fmt.Errorf("persistent disk was not a no-op in the upgrade plan: %s", address) + } + } + + for _, removal := range removedLegacyBindings { + if !anyChange(changes, func(change resourceChange) bool { + return change.Address == removal.current && slices.Equal(change.Change.Actions, []string{"delete"}) + }) { + return fmt.Errorf("upgrade plan did not remove the legacy over-broad IAM binding: %s", removal.current) + } + if removal.previous != removal.current && !anyChange(changes, func(change resourceChange) bool { + return change.Address == removal.current && change.PreviousAddress == removal.previous + }) { + return fmt.Errorf("plan did not preserve moved-resource provenance before removing %s", removal.previous) + } + } + + for _, address := range []string{scopedStartAddress, scopedSuspendAddress} { + if !anyChange(changes, func(change resourceChange) bool { + return change.Address == address && slices.Equal(change.Change.Actions, []string{"create"}) + }) { + return fmt.Errorf("upgrade plan did not create the instance-scoped power binding: %s", address) + } + } + + expectedManagedDeletions := []string{ + bootDiskAddress, + vmAddress, + legacyStartAddress, + legacySuspendAddress, + legacyGSAUserAddress, + legacyVMTokenCreatorAddress, + conditionalAppTokenAddress, + } + slices.Sort(expectedManagedDeletions) + var actualManagedDeletions []string + + for _, change := range changes { + if change.Mode == "data" { + continue + } + if slices.Contains(change.Change.Actions, "delete") { + actualManagedDeletions = append(actualManagedDeletions, change.Address) + } + + switch change.Address { + case bootDiskAddress, vmAddress: + if !isReplacement(change.Change.Actions) { + return fmt.Errorf("expected replacement action for %s", change.Address) + } + case legacyStartAddress, + legacySuspendAddress, + legacyGSAUserAddress, + legacyVMTokenCreatorAddress, + conditionalAppTokenAddress: + if !slices.Equal(change.Change.Actions, []string{"delete"}) { + return fmt.Errorf("expected delete-only action for %s", change.Address) + } + default: + if slices.Contains(change.Change.Actions, "delete") { + return fmt.Errorf("upgrade plan contained an unexpected managed-resource deletion: %s", change.Address) + } + } + } + + slices.Sort(actualManagedDeletions) + if !slices.Equal(actualManagedDeletions, expectedManagedDeletions) { + return fmt.Errorf( + "upgrade plan managed-resource deletions differ: got %v, want %v", + actualManagedDeletions, + expectedManagedDeletions, + ) + } + return nil +} + +// CaptureIDs extracts the immutable resource identities needed to compare the +// baseline ("old") and upgraded ("new") Terraform states. +func CaptureIDs(reader io.Reader, phase string) (map[string]string, error) { + if reader == nil { + return nil, errors.New("Terraform state reader is required") + } + + var phaseSources []identitySource + switch strings.ToLower(strings.TrimSpace(phase)) { + case "old": + phaseSources = []identitySource{ + {key: "internal_service_account", address: preservedMoves[0].previous, attribute: "id"}, + {key: "internal_service_keys", address: preservedMoves[1].previous, attribute: "id"}, + {key: "stackdriver", address: preservedMoves[2].previous, attribute: "id"}, + {key: "app_key_admin", address: preservedMoves[3].previous, attribute: "id"}, + {key: "legacy_start", address: legacyStartAddress, attribute: "id"}, + {key: "legacy_suspend", address: legacySuspendAddress, attribute: "id"}, + } + case "new": + phaseSources = []identitySource{ + {key: "internal_service_account", address: preservedMoves[0].current, attribute: "id"}, + {key: "internal_service_keys", address: preservedMoves[1].current, attribute: "id"}, + {key: "stackdriver", address: preservedMoves[2].current, attribute: "id"}, + {key: "app_key_admin", address: preservedMoves[3].current, attribute: "id"}, + {key: "scoped_start", address: scopedStartAddress, attribute: "id"}, + {key: "scoped_suspend", address: scopedSuspendAddress, attribute: "id"}, + } + default: + return nil, fmt.Errorf("unsupported state-capture phase %q", phase) + } + + decoder := json.NewDecoder(reader) + var document stateDocument + if err := decoder.Decode(&document); err != nil { + return nil, fmt.Errorf("decode Terraform state JSON: %w", err) + } + if err := requireEOF(decoder); err != nil { + return nil, err + } + + resources := make(map[string]stateResource) + if err := indexStateModule(document.Values.RootModule, resources); err != nil { + return nil, err + } + sources := append([]identitySource{ + {key: "data_disk", address: dataDiskAddress, attribute: "id"}, + {key: "docker_disk", address: dockerDiskAddress, attribute: "id"}, + {key: "boot_disk", address: bootDiskAddress, attribute: "disk_id"}, + {key: "vm", address: vmAddress, attribute: "instance_id"}, + }, phaseSources...) + + identities := make(map[string]string, len(sources)) + for _, source := range sources { + resource, ok := resources[source.address] + if !ok { + return nil, fmt.Errorf("state did not contain resource %s", source.address) + } + raw, ok := resource.Values[source.attribute] + if !ok { + return nil, fmt.Errorf("state did not expose %s for %s", source.attribute, source.address) + } + var value string + if err := json.Unmarshal(raw, &value); err != nil || strings.TrimSpace(value) == "" { + return nil, fmt.Errorf("state did not expose a non-empty string %s for %s", source.attribute, source.address) + } + identities[source.key] = value + } + return identities, nil +} + +// ValidateTransition verifies immutable resource identities and Terraform state +// addresses before and after the hosted GCP upgrade. +func ValidateTransition(oldIDsReader, newIDsReader, oldStateReader, newStateReader io.Reader) error { + oldIDs, err := decodeIDs(oldIDsReader) + if err != nil { + return fmt.Errorf("decode baseline resource identities: %w", err) + } + newIDs, err := decodeIDs(newIDsReader) + if err != nil { + return fmt.Errorf("decode upgraded resource identities: %w", err) + } + + for _, key := range []string{ + "data_disk", + "docker_disk", + "internal_service_account", + "internal_service_keys", + "stackdriver", + "app_key_admin", + } { + oldValue, err := requiredID(oldIDs, key) + if err != nil { + return fmt.Errorf("baseline resource identity %s: %w", key, err) + } + newValue, err := requiredID(newIDs, key) + if err != nil { + return fmt.Errorf("upgraded resource identity %s: %w", key, err) + } + if oldValue != newValue { + return fmt.Errorf("resource identity changed across the upgrade: %s", key) + } + } + + for _, key := range []string{"boot_disk", "vm"} { + oldValue, err := requiredID(oldIDs, key) + if err != nil { + return fmt.Errorf("baseline resource identity %s: %w", key, err) + } + newValue, err := requiredID(newIDs, key) + if err != nil { + return fmt.Errorf("upgraded resource identity %s: %w", key, err) + } + if oldValue == newValue { + return fmt.Errorf("expected replacement did not change resource identity: %s", key) + } + } + + for _, key := range []string{"legacy_start", "legacy_suspend"} { + if _, err := requiredID(oldIDs, key); err != nil { + return fmt.Errorf("baseline state did not capture the legacy project-wide power binding %s: %w", key, err) + } + } + for _, key := range []string{"scoped_start", "scoped_suspend"} { + if _, err := requiredID(newIDs, key); err != nil { + return fmt.Errorf("upgraded state did not capture the instance-scoped power binding %s: %w", key, err) + } + } + + oldState, err := decodeStateList(oldStateReader) + if err != nil { + return fmt.Errorf("decode baseline state list: %w", err) + } + newState, err := decodeStateList(newStateReader) + if err != nil { + return fmt.Errorf("decode upgraded state list: %w", err) + } + + for _, move := range preservedMoves { + if !oldState[move.previous] { + return fmt.Errorf("baseline state did not contain expected legacy address: %s", move.previous) + } + if newState[move.previous] { + return fmt.Errorf("upgraded state retained legacy address: %s", move.previous) + } + if !newState[move.current] { + return fmt.Errorf("upgraded state did not contain moved address: %s", move.current) + } + } + + for _, removal := range removedLegacyBindings { + if !oldState[removal.previous] { + return fmt.Errorf("baseline state did not contain expected legacy IAM binding: %s", removal.previous) + } + if newState[removal.previous] || newState[removal.current] { + return fmt.Errorf("upgraded state retained legacy IAM binding: %s", removal.previous) + } + } + + for _, address := range []string{scopedStartAddress, scopedSuspendAddress} { + if oldState[address] { + return fmt.Errorf("baseline state unexpectedly contained current instance-scoped power binding: %s", address) + } + if !newState[address] { + return fmt.Errorf("upgraded state did not contain instance-scoped power binding: %s", address) + } + } + return nil +} + +func decodeIDs(reader io.Reader) (map[string]json.RawMessage, error) { + if reader == nil { + return nil, errors.New("resource identity reader is required") + } + decoder := json.NewDecoder(reader) + var values map[string]json.RawMessage + if err := decoder.Decode(&values); err != nil { + return nil, err + } + if err := requireEOF(decoder); err != nil { + return nil, err + } + if values == nil { + return nil, errors.New("resource identities must be a JSON object") + } + return values, nil +} + +func requiredID(values map[string]json.RawMessage, key string) (string, error) { + raw, ok := values[key] + if !ok { + return "", errors.New("value is missing") + } + var value string + if err := json.Unmarshal(raw, &value); err != nil { + return "", errors.New("value must be a string") + } + if strings.TrimSpace(value) == "" { + return "", errors.New("value must not be empty") + } + return value, nil +} + +func decodeStateList(reader io.Reader) (map[string]bool, error) { + if reader == nil { + return nil, errors.New("state list reader is required") + } + addresses := make(map[string]bool) + scanner := bufio.NewScanner(reader) + for scanner.Scan() { + address := strings.TrimSpace(scanner.Text()) + if address != "" { + addresses[address] = true + } + } + if err := scanner.Err(); err != nil { + return nil, err + } + return addresses, nil +} + +func indexStateModule(module stateModule, resources map[string]stateResource) error { + for _, resource := range module.Resources { + if strings.TrimSpace(resource.Address) == "" { + return errors.New("Terraform state contains a resource without an address") + } + if _, exists := resources[resource.Address]; exists { + return fmt.Errorf("Terraform state contains duplicate resource address %s", resource.Address) + } + resources[resource.Address] = resource + } + for _, child := range module.ChildModules { + if err := indexStateModule(child, resources); err != nil { + return err + } + } + return nil +} + +func anyChange(changes []resourceChange, predicate func(resourceChange) bool) bool { + for _, change := range changes { + if predicate(change) { + return true + } + } + return false +} + +func isReplacement(actions []string) bool { + return slices.Equal(actions, []string{"delete", "create"}) || + slices.Equal(actions, []string{"create", "delete"}) +} + +func requireEOF(decoder *json.Decoder) error { + var trailing any + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + if err == nil { + return errors.New("JSON input contains more than one value") + } + return fmt.Errorf("decode trailing JSON: %w", err) + } + return nil +} diff --git a/internal/upgradecheck/check_test.go b/internal/upgradecheck/check_test.go new file mode 100644 index 0000000..fc7fb8b --- /dev/null +++ b/internal/upgradecheck/check_test.go @@ -0,0 +1,367 @@ +package upgradecheck + +import ( + "bytes" + "encoding/json" + "maps" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestValidatePlan(t *testing.T) { + t.Parallel() + + good := readFixture(t, "good-plan.json") + tests := []struct { + name string + mutate func(t *testing.T, plan map[string]any) + wantErr string + }{ + {name: "expected upgrade"}, + { + name: "moved resource loses provenance", + mutate: func(t *testing.T, plan map[string]any) { + change := planChange(t, plan, resourcePrefix+".google_project_iam_member.stackdriver[0]") + delete(change, "previous_address") + }, + wantErr: "did not preserve moved resource", + }, + { + name: "durable disk is replaced", + mutate: func(t *testing.T, plan map[string]any) { + setActions(t, planChange(t, plan, dockerDiskAddress), "delete", "create") + }, + wantErr: "persistent disk was not a no-op", + }, + { + name: "unexpected managed resource is deleted", + mutate: func(_ *testing.T, plan map[string]any) { + changes := plan["resource_changes"].([]any) + plan["resource_changes"] = append(changes, map[string]any{ + "address": "module.app.google_compute_disk.unexpected", + "change": map[string]any{"actions": []any{"delete"}}, + }) + }, + wantErr: "unexpected managed-resource deletion", + }, + { + name: "legacy binding is retained", + mutate: func(t *testing.T, plan map[string]any) { + setActions(t, planChange(t, plan, legacyStartAddress), "no-op") + }, + wantErr: "did not remove the legacy over-broad IAM binding", + }, + { + name: "renamed legacy binding loses provenance", + mutate: func(t *testing.T, plan map[string]any) { + change := planChange(t, plan, conditionalAppTokenAddress) + delete(change, "previous_address") + }, + wantErr: "did not preserve moved-resource provenance", + }, + { + name: "scoped binding is missing", + mutate: func(t *testing.T, plan map[string]any) { + changes := plan["resource_changes"].([]any) + filtered := changes[:0] + for _, raw := range changes { + change := raw.(map[string]any) + if change["address"] != scopedSuspendAddress { + filtered = append(filtered, raw) + } + } + plan["resource_changes"] = filtered + }, + wantErr: "did not create the instance-scoped power binding", + }, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + var document map[string]any + if err := json.Unmarshal(good, &document); err != nil { + t.Fatalf("decode fixture: %v", err) + } + if test.mutate != nil { + test.mutate(t, document) + } + encoded, err := json.Marshal(document) + if err != nil { + t.Fatalf("encode test plan: %v", err) + } + + err = ValidatePlan(bytes.NewReader(encoded)) + if test.wantErr == "" { + if err != nil { + t.Fatalf("ValidatePlan(): %v", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), test.wantErr) { + t.Fatalf("ValidatePlan() error = %v; want %q", err, test.wantErr) + } + }) + } +} + +func TestValidatePlanRejectsMalformedDocuments(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + wantErr string + }{ + {name: "invalid JSON", input: `{`, wantErr: "decode Terraform plan JSON"}, + {name: "missing format", input: `{"resource_changes":[]}`, wantErr: "missing format_version"}, + {name: "missing changes", input: `{"format_version":"1.2"}`, wantErr: "missing resource_changes"}, + {name: "wrong changes type", input: `{"format_version":"1.2","resource_changes":{}}`, wantErr: "decode Terraform resource_changes"}, + {name: "trailing value", input: `{"format_version":"1.2","resource_changes":[]} {}`, wantErr: "more than one value"}, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + err := ValidatePlan(strings.NewReader(test.input)) + if err == nil || !strings.Contains(err.Error(), test.wantErr) { + t.Fatalf("ValidatePlan() error = %v; want %q", err, test.wantErr) + } + }) + } +} + +func TestCaptureIDs(t *testing.T) { + t.Parallel() + + for _, test := range []struct { + phase string + stateFixture string + expectFixture string + }{ + {phase: "old", stateFixture: "old-state.json", expectFixture: "old-ids.json"}, + {phase: "new", stateFixture: "new-state.json", expectFixture: "new-ids.json"}, + } { + test := test + t.Run(test.phase, func(t *testing.T) { + t.Parallel() + got, err := CaptureIDs(bytes.NewReader(readFixture(t, test.stateFixture)), test.phase) + if err != nil { + t.Fatalf("CaptureIDs(): %v", err) + } + + var want map[string]string + if err := json.Unmarshal(readFixture(t, test.expectFixture), &want); err != nil { + t.Fatalf("decode expected identities: %v", err) + } + if !maps.Equal(got, want) { + t.Fatalf("CaptureIDs() = %#v; want %#v", got, want) + } + }) + } +} + +func TestCaptureIDsRejectsInvalidState(t *testing.T) { + t.Parallel() + + oldState := readFixture(t, "old-state.json") + var missingAttribute map[string]any + if err := json.Unmarshal(oldState, &missingAttribute); err != nil { + t.Fatalf("decode state fixture: %v", err) + } + root := missingAttribute["values"].(map[string]any)["root_module"].(map[string]any) + resources := root["child_modules"].([]any)[0].(map[string]any)["resources"].([]any) + for _, raw := range resources { + resource := raw.(map[string]any) + if resource["address"] == bootDiskAddress { + delete(resource["values"].(map[string]any), "disk_id") + } + } + encodedMissingAttribute, err := json.Marshal(missingAttribute) + if err != nil { + t.Fatalf("encode invalid state: %v", err) + } + + tests := []struct { + name string + phase string + input []byte + wantErr string + }{ + {name: "unknown phase", phase: "future", input: oldState, wantErr: "unsupported state-capture phase"}, + {name: "malformed state", phase: "old", input: []byte(`{`), wantErr: "decode Terraform state JSON"}, + {name: "missing immutable attribute", phase: "old", input: encodedMissingAttribute, wantErr: "did not expose disk_id"}, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + _, err := CaptureIDs(bytes.NewReader(test.input), test.phase) + if err == nil || !strings.Contains(err.Error(), test.wantErr) { + t.Fatalf("CaptureIDs() error = %v; want %q", err, test.wantErr) + } + }) + } +} + +func TestValidateTransition(t *testing.T) { + t.Parallel() + + oldIDs := readFixture(t, "old-ids.json") + newIDs := readFixture(t, "new-ids.json") + oldState := readFixture(t, "old-state.txt") + newState := readFixture(t, "new-state.txt") + + tests := []struct { + name string + mutateOldIDs func(map[string]any) + mutateNewIDs func(map[string]any) + mutateOldState func(string) string + mutateNewState func(string) string + wantErr string + }{ + {name: "expected transition"}, + { + name: "durable disk identity changes", + mutateNewIDs: func(values map[string]any) { + values["docker_disk"] = "docker-replaced" + }, + wantErr: "resource identity changed", + }, + { + name: "boot disk is not replaced", + mutateNewIDs: func(values map[string]any) { + values["boot_disk"] = "boot-old" + }, + wantErr: "expected replacement did not change", + }, + { + name: "legacy address remains", + mutateNewState: func(value string) string { + return value + resourcePrefix + ".google_service_account.internal-services\n" + }, + wantErr: "retained legacy address", + }, + { + name: "legacy power binding remains", + mutateNewState: func(value string) string { + return value + legacyStartAddress + "\n" + }, + wantErr: "retained legacy IAM binding", + }, + { + name: "scoped power binding is missing", + mutateNewState: func(value string) string { + return strings.ReplaceAll(value, scopedSuspendAddress+"\n", "") + }, + wantErr: "did not contain instance-scoped power binding", + }, + { + name: "baseline legacy binding is missing", + mutateOldState: func(value string) string { + return strings.ReplaceAll(value, legacySuspendAddress+"\n", "") + }, + wantErr: "did not contain expected legacy IAM binding", + }, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + oldDocument := decodeObject(t, oldIDs) + newDocument := decodeObject(t, newIDs) + if test.mutateOldIDs != nil { + test.mutateOldIDs(oldDocument) + } + if test.mutateNewIDs != nil { + test.mutateNewIDs(newDocument) + } + oldStateValue := string(oldState) + newStateValue := string(newState) + if test.mutateOldState != nil { + oldStateValue = test.mutateOldState(oldStateValue) + } + if test.mutateNewState != nil { + newStateValue = test.mutateNewState(newStateValue) + } + + err := ValidateTransition( + bytes.NewReader(encodeObject(t, oldDocument)), + bytes.NewReader(encodeObject(t, newDocument)), + strings.NewReader(oldStateValue), + strings.NewReader(newStateValue), + ) + if test.wantErr == "" { + if err != nil { + t.Fatalf("ValidateTransition(): %v", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), test.wantErr) { + t.Fatalf("ValidateTransition() error = %v; want %q", err, test.wantErr) + } + }) + } +} + +func readFixture(t testing.TB, name string) []byte { + t.Helper() + content, err := os.ReadFile(filepath.Join("testdata", name)) + if err != nil { + t.Fatalf("read fixture %s: %v", name, err) + } + return content +} + +func decodeObject(t testing.TB, content []byte) map[string]any { + t.Helper() + var value map[string]any + if err := json.Unmarshal(content, &value); err != nil { + t.Fatalf("decode JSON object: %v", err) + } + return value +} + +func encodeObject(t testing.TB, value map[string]any) []byte { + t.Helper() + encoded, err := json.Marshal(value) + if err != nil { + t.Fatalf("encode JSON object: %v", err) + } + return encoded +} + +func planChange(t testing.TB, plan map[string]any, address string) map[string]any { + t.Helper() + changes, ok := plan["resource_changes"].([]any) + if !ok { + t.Fatal("resource_changes fixture is not an array") + } + for _, raw := range changes { + change, ok := raw.(map[string]any) + if ok && change["address"] == address { + return change + } + } + t.Fatalf("plan fixture does not contain %s", address) + return nil +} + +func setActions(t testing.TB, change map[string]any, actions ...string) { + t.Helper() + changeBlock, ok := change["change"].(map[string]any) + if !ok { + t.Fatal("plan fixture change block is not an object") + } + values := make([]any, len(actions)) + for index, action := range actions { + values[index] = action + } + changeBlock["actions"] = values +} diff --git a/internal/upgradecheck/testdata/good-plan.json b/internal/upgradecheck/testdata/good-plan.json new file mode 100644 index 0000000..0d7bea2 --- /dev/null +++ b/internal/upgradecheck/testdata/good-plan.json @@ -0,0 +1,75 @@ +{ + "format_version": "1.2", + "resource_changes": [ + { + "address": "module.app.module.gcp[0].google_service_account.internal-services[0]", + "previous_address": "module.app.module.gcp[0].google_service_account.internal-services", + "change": {"actions": ["no-op"]} + }, + { + "address": "module.app.module.gcp[0].google_service_account_iam_member.internal-services-keys[0]", + "previous_address": "module.app.module.gcp[0].google_service_account_iam_member.internal-services-keys", + "change": {"actions": ["no-op"]} + }, + { + "address": "module.app.module.gcp[0].google_project_iam_member.stackdriver[0]", + "previous_address": "module.app.module.gcp[0].google_project_iam_member.stackdriver", + "change": {"actions": ["no-op"]} + }, + { + "address": "module.app.module.gcp[0].google_project_iam_member.gce-start[0]", + "change": {"actions": ["delete"]} + }, + { + "address": "module.app.module.gcp[0].google_project_iam_member.gce-suspend", + "change": {"actions": ["delete"]} + }, + { + "address": "module.app.module.gcp[0].google_service_account_iam_member.gsa-user", + "change": {"actions": ["delete"]} + }, + { + "address": "module.app.module.gcp[0].google_service_account_iam_member.token-creator", + "change": {"actions": ["delete"]} + }, + { + "address": "module.app.module.gcp[0].google_service_account_iam_member.vault_agent_jwt_signer_policy[0]", + "previous_address": "module.app.module.gcp[0].google_service_account_iam_member.self_jwt_signer_policy", + "change": {"actions": ["delete"]} + }, + { + "address": "module.app.module.gcp[0].google_service_account_iam_member.app-keys[0]", + "previous_address": "module.app.module.gcp[0].google_service_account_iam_member.app-keys", + "change": {"actions": ["no-op"]} + }, + { + "address": "module.app.module.gcp[0].google_compute_instance_iam_member.gce-start[0]", + "change": {"actions": ["create"]} + }, + { + "address": "module.app.module.gcp[0].google_compute_instance_iam_member.gce-suspend[0]", + "change": {"actions": ["create"]} + }, + { + "address": "module.app.module.gcp[0].google_compute_disk.data", + "change": {"actions": ["no-op"]} + }, + { + "address": "module.app.module.gcp[0].google_compute_disk.docker-volumes", + "change": {"actions": ["no-op"]} + }, + { + "address": "module.app.module.gcp[0].google_compute_disk.boot", + "change": {"actions": ["delete", "create"]} + }, + { + "address": "module.app.module.gcp[0].google_compute_instance.cloud-compose", + "change": {"actions": ["delete", "create"]} + }, + { + "address": "module.app.module.gcp[0].data.google_project_iam_custom_role.gce-suspend", + "mode": "data", + "change": {"actions": ["delete"]} + } + ] +} diff --git a/internal/upgradecheck/testdata/new-ids.json b/internal/upgradecheck/testdata/new-ids.json new file mode 100644 index 0000000..3db5536 --- /dev/null +++ b/internal/upgradecheck/testdata/new-ids.json @@ -0,0 +1,12 @@ +{ + "data_disk": "data-1", + "docker_disk": "docker-1", + "boot_disk": "boot-new", + "vm": "vm-new", + "internal_service_account": "sa-1", + "internal_service_keys": "keys-1", + "stackdriver": "metrics-1", + "app_key_admin": "app-keys-1", + "scoped_start": "scoped-start-1", + "scoped_suspend": "scoped-suspend-1" +} diff --git a/internal/upgradecheck/testdata/new-state.json b/internal/upgradecheck/testdata/new-state.json new file mode 100644 index 0000000..e539d4e --- /dev/null +++ b/internal/upgradecheck/testdata/new-state.json @@ -0,0 +1,52 @@ +{ + "values": { + "root_module": { + "child_modules": [ + { + "resources": [ + { + "address": "module.app.module.gcp[0].google_compute_disk.data", + "values": {"id": "data-1"} + }, + { + "address": "module.app.module.gcp[0].google_compute_disk.docker-volumes", + "values": {"id": "docker-1"} + }, + { + "address": "module.app.module.gcp[0].google_compute_disk.boot", + "values": {"id": "same-boot-name", "disk_id": "boot-new"} + }, + { + "address": "module.app.module.gcp[0].google_compute_instance.cloud-compose", + "values": {"id": "same-vm-name", "instance_id": "vm-new"} + }, + { + "address": "module.app.module.gcp[0].google_service_account.internal-services[0]", + "values": {"id": "sa-1"} + }, + { + "address": "module.app.module.gcp[0].google_service_account_iam_member.internal-services-keys[0]", + "values": {"id": "keys-1"} + }, + { + "address": "module.app.module.gcp[0].google_project_iam_member.stackdriver[0]", + "values": {"id": "metrics-1"} + }, + { + "address": "module.app.module.gcp[0].google_service_account_iam_member.app-keys[0]", + "values": {"id": "app-keys-1"} + }, + { + "address": "module.app.module.gcp[0].google_compute_instance_iam_member.gce-start[0]", + "values": {"id": "scoped-start-1"} + }, + { + "address": "module.app.module.gcp[0].google_compute_instance_iam_member.gce-suspend[0]", + "values": {"id": "scoped-suspend-1"} + } + ] + } + ] + } + } +} diff --git a/internal/upgradecheck/testdata/new-state.txt b/internal/upgradecheck/testdata/new-state.txt new file mode 100644 index 0000000..8757edd --- /dev/null +++ b/internal/upgradecheck/testdata/new-state.txt @@ -0,0 +1,6 @@ +module.app.module.gcp[0].google_service_account.internal-services[0] +module.app.module.gcp[0].google_service_account_iam_member.internal-services-keys[0] +module.app.module.gcp[0].google_project_iam_member.stackdriver[0] +module.app.module.gcp[0].google_compute_instance_iam_member.gce-start[0] +module.app.module.gcp[0].google_compute_instance_iam_member.gce-suspend[0] +module.app.module.gcp[0].google_service_account_iam_member.app-keys[0] diff --git a/internal/upgradecheck/testdata/old-ids.json b/internal/upgradecheck/testdata/old-ids.json new file mode 100644 index 0000000..2131b92 --- /dev/null +++ b/internal/upgradecheck/testdata/old-ids.json @@ -0,0 +1,12 @@ +{ + "data_disk": "data-1", + "docker_disk": "docker-1", + "boot_disk": "boot-old", + "vm": "vm-old", + "internal_service_account": "sa-1", + "internal_service_keys": "keys-1", + "stackdriver": "metrics-1", + "app_key_admin": "app-keys-1", + "legacy_start": "legacy-start-1", + "legacy_suspend": "legacy-suspend-1" +} diff --git a/internal/upgradecheck/testdata/old-state.json b/internal/upgradecheck/testdata/old-state.json new file mode 100644 index 0000000..490dd8a --- /dev/null +++ b/internal/upgradecheck/testdata/old-state.json @@ -0,0 +1,52 @@ +{ + "values": { + "root_module": { + "child_modules": [ + { + "resources": [ + { + "address": "module.app.module.gcp[0].google_compute_disk.data", + "values": {"id": "data-1"} + }, + { + "address": "module.app.module.gcp[0].google_compute_disk.docker-volumes", + "values": {"id": "docker-1"} + }, + { + "address": "module.app.module.gcp[0].google_compute_disk.boot", + "values": {"id": "same-boot-name", "disk_id": "boot-old"} + }, + { + "address": "module.app.module.gcp[0].google_compute_instance.cloud-compose", + "values": {"id": "same-vm-name", "instance_id": "vm-old"} + }, + { + "address": "module.app.module.gcp[0].google_service_account.internal-services", + "values": {"id": "sa-1"} + }, + { + "address": "module.app.module.gcp[0].google_service_account_iam_member.internal-services-keys", + "values": {"id": "keys-1"} + }, + { + "address": "module.app.module.gcp[0].google_project_iam_member.stackdriver", + "values": {"id": "metrics-1"} + }, + { + "address": "module.app.module.gcp[0].google_service_account_iam_member.app-keys", + "values": {"id": "app-keys-1"} + }, + { + "address": "module.app.module.gcp[0].google_project_iam_member.gce-start[0]", + "values": {"id": "legacy-start-1"} + }, + { + "address": "module.app.module.gcp[0].google_project_iam_member.gce-suspend", + "values": {"id": "legacy-suspend-1"} + } + ] + } + ] + } + } +} diff --git a/internal/upgradecheck/testdata/old-state.txt b/internal/upgradecheck/testdata/old-state.txt new file mode 100644 index 0000000..4cca186 --- /dev/null +++ b/internal/upgradecheck/testdata/old-state.txt @@ -0,0 +1,9 @@ +module.app.module.gcp[0].google_service_account.internal-services +module.app.module.gcp[0].google_service_account_iam_member.internal-services-keys +module.app.module.gcp[0].google_project_iam_member.stackdriver +module.app.module.gcp[0].google_project_iam_member.gce-start[0] +module.app.module.gcp[0].google_project_iam_member.gce-suspend +module.app.module.gcp[0].google_service_account_iam_member.gsa-user +module.app.module.gcp[0].google_service_account_iam_member.token-creator +module.app.module.gcp[0].google_service_account_iam_member.self_jwt_signer_policy +module.app.module.gcp[0].google_service_account_iam_member.app-keys