diff --git a/.github/workflows/sync-cloud-run-env.yml b/.github/workflows/sync-cloud-run-env.yml index e86cb27..76b54e9 100644 --- a/.github/workflows/sync-cloud-run-env.yml +++ b/.github/workflows/sync-cloud-run-env.yml @@ -276,8 +276,16 @@ jobs: summarize() { "$@" | sha256sum | cut -d ' ' -f1 } + active_traffic_digest() { + gcloud run services describe "${CLOUD_RUN_SERVICE}" \ + --project="${GCP_PROJECT_ID}" \ + --region="${CLOUD_RUN_REGION}" \ + --format='json(status.traffic)' \ + | python3 -c 'import json, sys; traffic = json.load(sys.stdin) or []; active = [{"revisionName": row.get("revisionName"), "percent": int(row.get("percent", 0))} for row in traffic if isinstance(row, dict) and int(row.get("percent", 0)) > 0]; print(json.dumps(sorted(active, key=lambda row: (str(row["revisionName"]), row["percent"])), separators=(",", ":")))' \ + | sha256sum | cut -d ' ' -f1 + } { - echo "traffic=$(summarize gcloud run services describe "${CLOUD_RUN_SERVICE}" --project="${GCP_PROJECT_ID}" --region="${CLOUD_RUN_REGION}" --format='json(status.traffic)')" + echo "traffic=$(active_traffic_digest)" echo "config=$(summarize gcloud run services describe "${CLOUD_RUN_SERVICE}" --project="${GCP_PROJECT_ID}" --region="${CLOUD_RUN_REGION}" --format='json(spec.template.spec.serviceAccountName,spec.template.spec.containerConcurrency,spec.template.spec.timeoutSeconds,spec.template.spec.containers.resources,spec.template.spec.containers.env.name,spec.template.spec.containers.env.valueFrom.secretKeyRef.name)')" echo "scheduler=$(summarize gcloud scheduler jobs list --project="${GCP_PROJECT_ID}" --location="${CLOUD_SCHEDULER_LOCATION:-${CLOUD_RUN_REGION}}" --sort-by=name --format='json(name,state,schedule,timeZone,httpTarget.uri,httpTarget.httpMethod,httpTarget.oidcToken.serviceAccountEmail,httpTarget.oidcToken.audience)')" echo "iam=$(summarize gcloud run services get-iam-policy "${CLOUD_RUN_SERVICE}" --project="${GCP_PROJECT_ID}" --region="${CLOUD_RUN_REGION}" --format='json(bindings.role,bindings.members)')" @@ -354,6 +362,15 @@ jobs: run: | set -euo pipefail summarize() { "$@" | sha256sum | cut -d ' ' -f1; } + active_traffic_digest() { + gcloud run services describe "${CLOUD_RUN_SERVICE}" \ + --project="${GCP_PROJECT_ID}" \ + --region="${CLOUD_RUN_REGION}" \ + --format='json(status.traffic)' \ + | python3 -c 'import json, sys; traffic = json.load(sys.stdin) or []; active = [{"revisionName": row.get("revisionName"), "percent": int(row.get("percent", 0))} for row in traffic if isinstance(row, dict) and int(row.get("percent", 0)) > 0]; print(json.dumps(sorted(active, key=lambda row: (str(row["revisionName"]), row["percent"])), separators=(",", ":")))' \ + | sha256sum | cut -d ' ' -f1 + } + fail_readback() { echo "No-traffic deployment readback failed: $1" >&2; exit 1; } traffic="$(awk -F= '$1 == "traffic" { print $2 }' "${BASELINE_PATH}")" config="$(awk -F= '$1 == "config" { print $2 }' "${BASELINE_PATH}")" scheduler="$(awk -F= '$1 == "scheduler" { print $2 }' "${BASELINE_PATH}")" @@ -362,11 +379,13 @@ jobs: ready_revision="$(gcloud run services describe "${CLOUD_RUN_SERVICE}" --project="${GCP_PROJECT_ID}" --region="${CLOUD_RUN_REGION}" --format='value(status.latestReadyRevisionName)')" revision_sha="$(gcloud run revisions describe "${revision_name}" --project="${GCP_PROJECT_ID}" --region="${CLOUD_RUN_REGION}" --format='value(metadata.labels.commit-sha)')" revision_image="$(gcloud run revisions describe "${revision_name}" --project="${GCP_PROJECT_ID}" --region="${CLOUD_RUN_REGION}" --format='value(spec.containers.image)')" - [ "${revision_name}" = "${ready_revision}" ] && [ "${revision_sha}" = "${EXPECTED_SHA}" ] && [[ "${revision_image}" == *"${EXPECTED_IMAGE_DIGEST}"* ]] - [ "${traffic}" = "$(summarize gcloud run services describe "${CLOUD_RUN_SERVICE}" --project="${GCP_PROJECT_ID}" --region="${CLOUD_RUN_REGION}" --format='json(status.traffic)')" ] - [ "${config}" = "$(summarize gcloud run services describe "${CLOUD_RUN_SERVICE}" --project="${GCP_PROJECT_ID}" --region="${CLOUD_RUN_REGION}" --format='json(spec.template.spec.serviceAccountName,spec.template.spec.containerConcurrency,spec.template.spec.timeoutSeconds,spec.template.spec.containers.resources,spec.template.spec.containers.env.name,spec.template.spec.containers.env.valueFrom.secretKeyRef.name)')" ] - [ "${scheduler}" = "$(summarize gcloud scheduler jobs list --project="${GCP_PROJECT_ID}" --location="${CLOUD_SCHEDULER_LOCATION:-${CLOUD_RUN_REGION}}" --sort-by=name --format='json(name,state,schedule,timeZone,httpTarget.uri,httpTarget.httpMethod,httpTarget.oidcToken.serviceAccountEmail,httpTarget.oidcToken.audience)')" ] - [ "${iam}" = "$(summarize gcloud run services get-iam-policy "${CLOUD_RUN_SERVICE}" --project="${GCP_PROJECT_ID}" --region="${CLOUD_RUN_REGION}" --format='json(bindings.role,bindings.members)')" ] + [ "${revision_name}" = "${ready_revision}" ] || fail_readback "created revision is not ready" + [ "${revision_sha}" = "${EXPECTED_SHA}" ] || fail_readback "revision commit SHA mismatch" + [[ "${revision_image}" == *"${EXPECTED_IMAGE_DIGEST}"* ]] || fail_readback "revision image digest mismatch" + [ "${traffic}" = "$(active_traffic_digest)" ] || fail_readback "effective traffic changed" + [ "${config}" = "$(summarize gcloud run services describe "${CLOUD_RUN_SERVICE}" --project="${GCP_PROJECT_ID}" --region="${CLOUD_RUN_REGION}" --format='json(spec.template.spec.serviceAccountName,spec.template.spec.containerConcurrency,spec.template.spec.timeoutSeconds,spec.template.spec.containers.resources,spec.template.spec.containers.env.name,spec.template.spec.containers.env.valueFrom.secretKeyRef.name)')" ] || fail_readback "configuration changed" + [ "${scheduler}" = "$(summarize gcloud scheduler jobs list --project="${GCP_PROJECT_ID}" --location="${CLOUD_SCHEDULER_LOCATION:-${CLOUD_RUN_REGION}}" --sort-by=name --format='json(name,state,schedule,timeZone,httpTarget.uri,httpTarget.httpMethod,httpTarget.oidcToken.serviceAccountEmail,httpTarget.oidcToken.audience)')" ] || fail_readback "Scheduler changed" + [ "${iam}" = "$(summarize gcloud run services get-iam-policy "${CLOUD_RUN_SERVICE}" --project="${GCP_PROJECT_ID}" --region="${CLOUD_RUN_REGION}" --format='json(bindings.role,bindings.members)')" ] || fail_readback "IAM changed" { echo "## No-traffic deployment readback" echo "- Revision commit SHA and image digest verified." diff --git a/tests/test_sync_cloud_run_env_workflow.py b/tests/test_sync_cloud_run_env_workflow.py index 1d7c54b..4995c4e 100644 --- a/tests/test_sync_cloud_run_env_workflow.py +++ b/tests/test_sync_cloud_run_env_workflow.py @@ -289,6 +289,8 @@ def test_sync_cloud_run_env_workflow_is_fail_closed_and_no_traffic_by_default(): assert "--no-traffic" in deploy_block assert "Capture read-only deployment baseline" in workflow assert "Verify no-traffic deployment readback" in workflow + assert "active_traffic_digest()" in workflow + assert 'fail_readback "effective traffic changed"' in workflow assert 'inputs.allow_configuration_sync }}" != "true"' in workflow assert 'inputs.allow_traffic_promotion == true' in workflow assert 'inputs.allow_cleanup == true' in workflow