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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ $(CLOUD_COMPOSE_CI_BIN): $(GO_MODULE_FILES) $(GO_SOURCES)
@mkdir -p "$(dir $(CLOUD_COMPOSE_CI_BIN))"
go build -trimpath -o "$(CLOUD_COMPOSE_CI_BIN)" ./cmd/cloud-compose-ci

gcp-upgrade-smoke-contract:
gcp-upgrade-smoke-contract: cloud-compose-ci
bash ci/gcp-upgrade-smoke-contract.sh

artifact-install-contract:
Expand Down
31 changes: 20 additions & 11 deletions ci/cloud-smoke.sh
Original file line number Diff line number Diff line change
Expand Up @@ -554,7 +554,8 @@ target_workdir() {
}

target_var_args() {
local root="$1" key_path="$2" target="$3" public_key provider template
local root="$1" key_path="$2" target="$3" run_id="$4" run_namespace="$5"
local public_key provider template
local source_ref source_sha256 source_cache_key checksum_dir checksum_file archive_tmp

provider="$(target_provider "$target")"
Expand Down Expand Up @@ -603,8 +604,11 @@ target_var_args() {
fi
printf '%s\0%s\0' "-var" "cloud_compose_source_sha256=${source_sha256}"
fi
if grep -q 'variable "smoke_run_id"' "$root/variables.tf" && [[ -n "$(smoke_run_id)" ]]; then
printf '%s\0%s\0' "-var" "smoke_run_id=$(smoke_run_id)"
if grep -q 'variable "smoke_run_id"' "$root/variables.tf" && [[ -n "$run_id" ]]; then
printf '%s\0%s\0' "-var" "smoke_run_id=${run_id}"
fi
if grep -q 'variable "smoke_run_namespace"' "$root/variables.tf" && [[ -n "$run_namespace" ]]; then
printf '%s\0%s\0' "-var" "smoke_run_namespace=${run_namespace}"
fi
if grep -q 'variable "gcp_project_id"' "$root/variables.tf"; then
printf '%s\0%s\0' "-var" "gcp_project_id=${GCLOUD_PROJECT:-}"
Expand Down Expand Up @@ -846,7 +850,7 @@ run_target() (
set -euo pipefail

local target="$1"
local root workdir key_path home_dir output_json public_key run_id
local root workdir key_path home_dir output_json public_key run_id run_namespace
local -a auto_args var_args

root="$(target_root "$target")"
Expand All @@ -861,10 +865,10 @@ run_target() (

ensure_key "$key_path"
run_id="$(smoke_run_id)"
# Validate GCP cleanup ownership before Terraform can create resources. The
# reader phase intentionally keeps writing the legacy resource namespace.
gcp_run_namespace "$target" "$run_id" >/dev/null
mapfile -d '' -t var_args < <(target_var_args "$root" "$key_path" "$target")
# Compute outside process substitution so an invalid hosted run ID aborts
# before Terraform can create resources under an undiscoverable namespace.
run_namespace="$(gcp_run_namespace "$target" "$run_id")"
mapfile -d '' -t var_args < <(target_var_args "$root" "$key_path" "$target" "$run_id" "$run_namespace")

auto_args=()
if [[ -n "${GITHUB_ACTIONS:-}" || "${CLOUD_COMPOSE_SMOKE_AUTO_APPROVE:-}" == "true" ]]; then
Expand Down Expand Up @@ -951,15 +955,20 @@ destroy_target() (
set -euo pipefail

local target="$1"
local root workdir key_path destroy_status destroy_timeout cleanup_status
local root workdir key_path destroy_status destroy_timeout cleanup_status run_id run_namespace
local -a auto_args var_args

root="$(target_root "$target")"
target_env "$target"

workdir="$(target_workdir "$target")"
key_path="$workdir/id_ed25519"
mapfile -d '' -t var_args < <(target_var_args "$root" "$key_path" "$target")
run_id="$(smoke_run_id)"
run_namespace=""
if [[ -n "$run_id" ]]; then
run_namespace="$(gcp_run_namespace "$target" "$run_id")"
fi
mapfile -d '' -t var_args < <(target_var_args "$root" "$key_path" "$target" "$run_id" "$run_namespace")

auto_args=()
if [[ -n "${GITHUB_ACTIONS:-}" || "${CLOUD_COMPOSE_SMOKE_AUTO_APPROVE:-}" == "true" ]]; then
Expand All @@ -986,7 +995,7 @@ destroy_target() (
fi

cleanup_status=0
provider_tag_cleanup "$target" "$(smoke_run_id)" || cleanup_status=$?
provider_tag_cleanup "$target" "$run_id" || cleanup_status=$?

if [[ "$destroy_status" -ne 0 && "$cleanup_status" -eq 0 ]]; then
echo "Provider tag cleanup completed for ${target} after Terraform destroy failed"
Expand Down
11 changes: 10 additions & 1 deletion ci/gcp-upgrade-smoke-contract.sh
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,15 @@ grep -Fq 'CLOUD_COMPOSE_SMOKE_RUN_ID must match GITHUB_RUN_ID in GitHub Actions'
fail "hosted cleanup ownership is not bound to the actual GitHub run id"
grep -Fq 'CLOUD_COMPOSE_SMOKE_RUN_ID must be set explicitly outside GitHub Actions' "$script" ||
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 '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" ||
fail "upgrade working directories do not use the exact run namespace"
if grep -Fq 'sanitize_run_fragment' "$script"; then
fail "upgrade runner still truncates run IDs to the legacy namespace"
fi
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" ||
Expand Down Expand Up @@ -214,7 +223,7 @@ cleanup_log="$tmp/cleanup.log"
set +e
GITHUB_ACTIONS='' \
GITHUB_RUN_ID='' \
CLOUD_COMPOSE_SMOKE_RUN_ID=contract-run \
CLOUD_COMPOSE_SMOKE_RUN_ID=123456789 \
CLOUD_COMPOSE_UPGRADE_CURRENT_REF=HEAD \
GCLOUD_PROJECT=contract-project \
GCLOUD_NETWORK_PROJECT_ID=contract-project \
Expand Down
26 changes: 13 additions & 13 deletions ci/gcp-upgrade-smoke.sh
Original file line number Diff line number Diff line change
Expand Up @@ -73,15 +73,15 @@ fail() {
return 1
}

sanitize_run_fragment() {
local value="$1"
exact_run_namespace() {
local run_id="$1" runner

value="$(printf '%s' "$value" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9-]/-/g' | cut -c1-8)"
if [[ -z "$value" ]]; then
fail "the smoke run id did not contain a usable name fragment"
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 namespace --run-id "$run_id"
}

upgrade_run_id() {
Expand Down Expand Up @@ -832,7 +832,7 @@ run_upgrade() (
require_env GCLOUD_POWER_START_ROLE
require_env GCLOUD_POWER_SUSPEND_ROLE

local requested_base current_ref current_sha base_sha run_id run_fragment name
local requested_base current_ref current_sha base_sha run_id run_namespace name
local work_root old_source new_source state_path old_data new_data key_path public_key
local runner_ipv4 runner_cidr region zone plan_file plan_json nonce
local old_root new_root old_output new_output old_home new_home
Expand All @@ -850,11 +850,11 @@ run_upgrade() (
[[ "$current_sha" =~ ^[0-9a-f]{40}$ ]] || fail "current upgrade ref did not resolve to a full commit"

run_id="$(upgrade_run_id)"
run_fragment="$(sanitize_run_fragment "$run_id")"
name="cc-g-wp-${run_fragment}-up"
run_namespace="$(exact_run_namespace "$run_id")"
name="cc-g-wp-${run_namespace}-up"
export CLOUD_COMPOSE_SMOKE_RUN_ID="$run_id"

work_root="${CLOUD_COMPOSE_GCP_UPGRADE_WORKDIR:-${RUNNER_TEMP:-/tmp}/cloud-compose-gcp-upgrade-${run_fragment}}"
work_root="${CLOUD_COMPOSE_GCP_UPGRADE_WORKDIR:-${RUNNER_TEMP:-/tmp}/cloud-compose-gcp-upgrade-${run_namespace}}"
[[ "$work_root" == /* ]] || fail "CLOUD_COMPOSE_GCP_UPGRADE_WORKDIR must be an absolute path"
old_source="$work_root/source-0.10.2"
new_source="$work_root/source-current"
Expand Down Expand Up @@ -1008,13 +1008,13 @@ destroy_upgrade() {
require_env GCLOUD_NETWORK_NAME
require_env GCLOUD_SUBNETWORK_NAME

local current_sha run_id run_fragment work_root old_source new_source state_path old_data new_data
local current_sha run_id run_namespace work_root old_source new_source state_path old_data new_data

current_sha="$(git -C "$repo_root" rev-parse --verify "${CLOUD_COMPOSE_UPGRADE_CURRENT_REF:-HEAD}^{commit}")"
run_id="$(upgrade_run_id)"
run_fragment="$(sanitize_run_fragment "$run_id")"
run_namespace="$(exact_run_namespace "$run_id")"
export CLOUD_COMPOSE_SMOKE_RUN_ID="$run_id"
work_root="${CLOUD_COMPOSE_GCP_UPGRADE_WORKDIR:-${RUNNER_TEMP:-/tmp}/cloud-compose-gcp-upgrade-${run_fragment}}"
work_root="${CLOUD_COMPOSE_GCP_UPGRADE_WORKDIR:-${RUNNER_TEMP:-/tmp}/cloud-compose-gcp-upgrade-${run_namespace}}"
[[ "$work_root" == /* ]] || fail "CLOUD_COMPOSE_GCP_UPGRADE_WORKDIR must be an absolute path"
old_source="$work_root/source-0.10.2"
new_source="$work_root/source-current"
Expand Down
34 changes: 20 additions & 14 deletions docs/runtime-contracts.md
Original file line number Diff line number Diff line change
Expand Up @@ -756,20 +756,26 @@ workflow, including cases where cancellation prevented the in-job destroy from
finishing. Never allow pull-request branches in a cleanup environment's
deployment policy.

GCP run namespaces require a staged compatibility change because the privileged
fallback always executes the default branch. The compiled runner recognizes the
legacy first-eight-character namespace and the reserved exact namespace that
encodes a canonical numeric run ID as nine fixed-width base36 characters. That
width preserves both separators and the random suffix for every supported GCP
template within the 21-character resource-name limit. In this reader phase the
fresh GCP driver calls the pure `cloud-compose-ci gcp namespace` command before
Terraform argument construction to require a non-empty canonical run ID no
larger than 44 bits, while deliberately continuing to write legacy names. Manual GCP
smoke applies must therefore set `CLOUD_COMPOSE_SMOKE_RUN_ID`; non-GCP smoke
naming and invocation remain unchanged. Merge the
dual-reader before changing the smoke Terraform writer. Retain the legacy reader
until all branches and resources created by the old writer have expired; never
introduce a new writer that the trusted fallback cannot discover.
GCP smoke writers encode the complete canonical numeric GitHub Actions run ID as
a fixed-width, nine-character lowercase base36 namespace. The fresh and upgrade
drivers obtain that value from the compiled `cloud-compose-ci gcp namespace`
command before Terraform can create resources. A manual fresh GCP smoke apply
must set `CLOUD_COMPOSE_SMOKE_RUN_ID`; an empty, malformed, noncanonical, or
greater-than-44-bit value fails before apply. Non-GCP smoke invocation remains
unchanged. The namespace width preserves both
separators and at least one random suffix character for every supported GCP
template within the 21-character resource-name limit. The original decimal run
ID remains the cleanup input and the source of compatibility tags; DigitalOcean
and Linode naming and invocation are unchanged. Manual GCP smoke applies must
set `CLOUD_COMPOSE_SMOKE_RUN_ID`; the smoke context validates that the supplied
namespace decodes to that same canonical, at-most-44-bit run ID.

The privileged fallback always executes the default branch, so its compiled
runner remains a dual reader: it queries both the legacy first-eight-character
namespace and the exact namespace for a run. Retain the legacy reader until all
branches and resources created by the old writer have expired. A malformed,
noncanonical, or greater-than-44-bit run ID fails before apply rather than
creating a namespace that the trusted fallback cannot discover.

Use separate provider identities for smoke and fallback cleanup:

Expand Down
40 changes: 39 additions & 1 deletion internal/contracttest/cloud_smoke_cleanup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ func TestCloudSmokeGCPApplyRequiresCanonicalRunID(t *testing.T) {
driverPath := filepath.Join(root, "ci/cloud-smoke.sh")
driver := readRepositoryFile(t, root, "ci/cloud-smoke.sh")

validation := `gcp_run_namespace "$target" "$run_id" >/dev/null`
validation := `run_namespace="$(gcp_run_namespace "$target" "$run_id")"`
validationIndex := strings.Index(driver, validation)
argumentsIndex := strings.Index(driver, `mapfile -d '' -t var_args < <(target_var_args`)
applyIndex := strings.Index(driver, `terraform -chdir="$root" apply`)
Expand Down Expand Up @@ -138,6 +138,44 @@ func TestCloudSmokeGCPApplyRequiresCanonicalRunID(t *testing.T) {
}
}

func TestCloudSmokeGCPWriterUsesExactRunNamespace(t *testing.T) {
t.Parallel()
root := repositoryRoot(t)
driver := readRepositoryFile(t, root, "ci/cloud-smoke.sh")
gcpVariables := readRepositoryFile(t, root, "tests/smoke/gcp/variables.tf")
gcpFixture := readRepositoryFile(t, root, "tests/smoke/gcp/main.tf")
contextVariables := readRepositoryFile(t, root, "tests/smoke/modules/context/variables.tf")
contextFixture := readRepositoryFile(t, root, "tests/smoke/modules/context/main.tf")

for label, marker := range map[string]string{
"compiled namespace command": `"$runner" gcp namespace --run-id "$run_id"`,
"captured raw run ID": `run_id="$(smoke_run_id)"`,
"captured exact namespace": `run_namespace="$(gcp_run_namespace "$target" "$run_id")"`,
"raw Terraform input": `"smoke_run_id=${run_id}"`,
"exact Terraform input": `"smoke_run_namespace=${run_namespace}"`,
"raw cleanup ownership": `provider_tag_cleanup "$target" "$run_id"`,
} {
requireContains(t, driver, marker, label)
}

namespaceIndex := strings.Index(driver, `run_namespace="$(gcp_run_namespace "$target" "$run_id")"`)
argumentsIndex := strings.Index(driver, `mapfile -d '' -t var_args < <(target_var_args`)
if namespaceIndex < 0 || argumentsIndex < 0 || namespaceIndex >= argumentsIndex {
t.Fatal("fresh GCP smoke does not validate its exact namespace before Terraform argument process substitution")
}

for label, text := range map[string]string{
"GCP root variables": gcpVariables,
"context variables": contextVariables,
"GCP context plumbing": gcpFixture,
"context naming decision": contextFixture,
} {
requireContains(t, text, "smoke_run_namespace", label)
}
requireContains(t, contextFixture, `local.cloud_provider == "gcp" ? var.smoke_run_namespace : ""`, "GCP-only exact namespace selection")
requireContains(t, contextFixture, `local.smoke_run_id != "" ? "gha-run-${local.smoke_run_id}" : ""`, "legacy run-id cleanup tag")
}

func TestCloudSmokeGCPWrapperFailsClosedWithoutRunID(t *testing.T) {
t.Parallel()
root := repositoryRoot(t)
Expand Down
3 changes: 2 additions & 1 deletion internal/gcpcleanup/cleanup.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,8 @@ type failureList struct {
errors []error
}

// NameFilter returns the legacy anchored resource-name filter shared with the current smoke Terraform fixture.
// NameFilter returns the legacy anchored resource-name filter retained for
// compatibility with smoke resources created before exact namespaces.
func NameFilter(target, runID string) (string, error) {
prefix, err := targetNamePrefix(target)
if err != nil {
Expand Down
1 change: 1 addition & 0 deletions tests/smoke/gcp/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ module "context" {
ssh_public_key = var.ssh_public_key
operator_ssh_public_keys = var.operator_ssh_public_keys
smoke_run_id = var.smoke_run_id
smoke_run_namespace = var.smoke_run_namespace
docker_compose_branch = var.docker_compose_branch
ingress_port = var.ingress_port
}
Expand Down
11 changes: 11 additions & 0 deletions tests/smoke/gcp/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,17 @@ variable "smoke_run_id" {
description = "Optional GitHub Actions run id used to tag and name disposable smoke-test resources."
}

variable "smoke_run_namespace" {
type = string
default = ""
description = "Optional output of cloud-compose-ci gcp namespace used in GCP disposable resource names."

validation {
condition = var.smoke_run_namespace == "" || can(regex("^[0-9a-z]{9}$", var.smoke_run_namespace))
error_message = "smoke_run_namespace must be empty or exactly nine lowercase base36 characters."
}
}

variable "docker_compose_branch" {
type = string
default = "main"
Expand Down
Loading