diff --git a/.github/workflows/sync-cloud-run-env.yml b/.github/workflows/sync-cloud-run-env.yml index 4f95ca2..07a7e3d 100644 --- a/.github/workflows/sync-cloud-run-env.yml +++ b/.github/workflows/sync-cloud-run-env.yml @@ -11,6 +11,10 @@ on: options: - configured - hk-verify + configured_service: + description: "Optional exact inventory service_name for configured target; blank keeps all configured targets." + required: false + type: string cloud_run_region: description: "Cloud Run region for hk-verify. Leave blank to use repository/environment variables." required: false @@ -70,6 +74,7 @@ jobs: QSL_ENABLE_CLOUD_RUN_AUTOMATION: ${{ vars.QSL_ENABLE_CLOUD_RUN_AUTOMATION }} CLOUD_RUN_CLEANUP_ENABLED: ${{ vars.CLOUD_RUN_CLEANUP_ENABLED }} WORKFLOW_TARGET: ${{ inputs.target || 'configured' }} + INPUT_CONFIGURED_SERVICE: ${{ inputs.configured_service }} INPUT_CLOUD_RUN_REGION: ${{ inputs.cloud_run_region }} INPUT_CLOUD_RUN_SERVICE: ${{ inputs.cloud_run_service }} INPUT_ACCOUNT_GROUP: ${{ inputs.account_group }} @@ -342,11 +347,31 @@ jobs: run: | set -euo pipefail sync_plan_json="$(uv run --no-sync python scripts/build_cloud_run_env_sync_plan.py --json)" - { - echo "sync_plan_json<<__SYNC_PLAN_JSON__" - printf '%s\n' "${sync_plan_json}" - echo "__SYNC_PLAN_JSON__" - } >> "$GITHUB_OUTPUT" + export SYNC_PLAN_JSON="${sync_plan_json}" + python - <<'PY' + import json + import os + import re + from pathlib import Path + + plan = json.loads(os.environ["SYNC_PLAN_JSON"]) + selected = os.environ.get("INPUT_CONFIGURED_SERVICE", "") + if os.environ.get("WORKFLOW_TARGET", "configured") == "configured" and selected: + if not re.fullmatch(r"[a-z][a-z0-9-]{0,62}", selected) or plan.get("mode") != "per_service": + raise SystemExit("configured_service requires an exact private inventory service_name") + matches = [target for target in plan["targets"] if target.get("service_name") == selected] + if len(matches) != 1: + raise SystemExit("configured_service must match exactly one admitted inventory target") + plan["targets"] = matches + # Reconciliation also reads inventory and legacy env; scope every source. + inventory = json.dumps({"targets": [{"service_name": selected}]}) + with Path(os.environ["GITHUB_ENV"]).open("a") as handle: + handle.write(f"CLOUD_RUN_SERVICE_TARGETS_JSON={inventory}\n") + handle.write(f"CLOUD_RUN_SERVICE={selected}\nCLOUD_RUN_SERVICES={selected}\n") + with Path(os.environ["GITHUB_OUTPUT"]).open("a") as handle: + handle.write("sync_plan_json<<__SYNC_PLAN_JSON__\n") + handle.write(json.dumps(plan) + "\n__SYNC_PLAN_JSON__\n") + PY - name: Validate env sync inputs if: steps.config.outputs.env_sync_enabled == 'true' @@ -1289,7 +1314,9 @@ jobs: --scheduler-location="${scheduler_location}" --delete-legacy-schedulers ) - python3 scripts/reconcile_cloud_runtime.py "${reconcile_args[@]}" + if [ "${WORKFLOW_TARGET:-configured}" != "configured" ] || [ -z "${INPUT_CONFIGURED_SERVICE:-}" ]; then + python3 scripts/reconcile_cloud_runtime.py "${reconcile_args[@]}" + fi for update in "${scheduler_updates[@]}"; do IFS=$'\t' read -r cloud_run_service _ <<< "${update}" @@ -1309,7 +1336,7 @@ jobs: done - name: Prune old Cloud Run revisions - if: steps.config.outputs.deploy_enabled == 'true' && env.CLOUD_RUN_CLEANUP_ENABLED == 'true' + if: steps.config.outputs.deploy_enabled == 'true' && env.CLOUD_RUN_CLEANUP_ENABLED == 'true' && (env.WORKFLOW_TARGET != 'configured' || env.INPUT_CONFIGURED_SERVICE == '') env: SYNC_PLAN_JSON: ${{ steps.strategy_requirements.outputs.sync_plan_json }} run: | @@ -1398,7 +1425,7 @@ jobs: done - name: Clean up old Cloud Run images - if: steps.config.outputs.deploy_enabled == 'true' && env.CLOUD_RUN_CLEANUP_ENABLED == 'true' + if: steps.config.outputs.deploy_enabled == 'true' && env.CLOUD_RUN_CLEANUP_ENABLED == 'true' && (env.WORKFLOW_TARGET != 'configured' || env.INPUT_CONFIGURED_SERVICE == '') run: | set -euo pipefail diff --git a/tests/test_cloud_run_warmup_workflow.py b/tests/test_cloud_run_warmup_workflow.py index 24c6073..8684013 100644 --- a/tests/test_cloud_run_warmup_workflow.py +++ b/tests/test_cloud_run_warmup_workflow.py @@ -66,3 +66,103 @@ def test_lifecycle_observes_the_exact_existing_service_binding() -> None: assert "cloud-run-region: ${{ env.CLOUD_RUN_REGION }}" in publish assert "cloud-run-service: ${{ matrix.target.service }}" in publish assert "scheduler-location: ${{ env.RUNTIME_HEARTBEAT_SCHEDULER_LOCATION }}" in publish + + +def _resolve_sync_plan(tmp_path, monkeypatch, *, selector="service-b", target="configured", services=None, mode="per_service"): + import json + import os + import subprocess + import sys + import textwrap + + workflow = Path(".github/workflows/sync-cloud-run-env.yml").read_text(encoding="utf-8") + step = workflow.split(" - name: Resolve admissible Cloud Run targets\n", 1)[1].split(" - name:", 1)[0] + script = textwrap.dedent(step.split(" run: |\n", 1)[1]) + plan = {"mode": mode, "targets": [{"service_name": service, "env": {"RUNTIME_TARGET_ENABLED": "false"}} for service in (services or ["service-a", "service-b"])]} + stub = tmp_path / "uv" + stub.write_text(f"#!{sys.executable}\nimport os\nprint(os.environ['TEST_PLAN'])\n") + stub.chmod(0o700) + (tmp_path / "python").symlink_to(sys.executable) + output = tmp_path / "output" + github_env = tmp_path / "env" + output.touch() + github_env.touch() + monkeypatch.setenv("PATH", f"{tmp_path}:{os.environ['PATH']}") + env = { + "PATH": os.environ["PATH"], + "TEST_PLAN": json.dumps(plan), + "WORKFLOW_TARGET": target, + "INPUT_CONFIGURED_SERVICE": selector, + "GITHUB_OUTPUT": str(output), + "GITHUB_ENV": str(github_env), + } + result = subprocess.run(["bash", "-c", script], env=env, capture_output=True, text=True) + return result, output.read_text(), github_env.read_text(), plan + + +def test_configured_service_selects_one_target_and_all_downstream_sources(tmp_path, monkeypatch) -> None: + import json + from scripts.reconcile_cloud_runtime import load_targets + + result, output, github_env, _ = _resolve_sync_plan(tmp_path, monkeypatch) + assert result.returncode == 0, result.stderr + plan = json.loads(output.split("\n", 1)[1].split("\n", 1)[0]) + assert [target["service_name"] for target in plan["targets"]] == ["service-b"] + assert "CLOUD_RUN_SERVICES=service-b\n" in github_env + assert "CLOUD_RUN_SERVICE=service-b\n" in github_env + inventory = json.loads(github_env.split("CLOUD_RUN_SERVICE_TARGETS_JSON=", 1)[1].split("\n", 1)[0]) + assert inventory == {"targets": [{"service_name": "service-b"}]} + scoped_env = dict(line.split("=", 1) for line in github_env.splitlines()) + scoped_env["SYNC_PLAN_JSON"] = json.dumps(plan) + assert [target.service_name for target in load_targets(env=scoped_env)] == ["service-b"] + assert plan["targets"][0]["env"] == {"RUNTIME_TARGET_ENABLED": "false"} + assert result.stdout == "" + + +def test_configured_service_rejects_unmatched_duplicate_or_noninventory(tmp_path, monkeypatch) -> None: + for index, kwargs in enumerate(( + {"selector": "missing"}, + {"services": ["service-b", "service-b"]}, + {"selector": " service-b"}, + {"selector": "service-b\nOTHER=value"}, + {"mode": "legacy"}, + )): + case_path = tmp_path / str(index) + case_path.mkdir() + result, output, github_env, _ = _resolve_sync_plan(case_path, monkeypatch, **kwargs) + assert result.returncode != 0 + assert output == "" + assert github_env == "" + assert "service-b" not in result.stderr + + +def test_empty_selector_and_hk_verify_preserve_existing_targets(tmp_path, monkeypatch) -> None: + import json + + for index, kwargs in enumerate(({"selector": ""}, {"target": "hk-verify"})): + case_path = tmp_path / str(index) + case_path.mkdir() + result, output, github_env, original = _resolve_sync_plan(case_path, monkeypatch, **kwargs) + assert result.returncode == 0, result.stderr + assert json.loads(output.split("\n", 1)[1].split("\n", 1)[0]) == original + assert github_env == "" + + +def test_single_target_sync_skips_global_cleanup_before_other_service_mutation() -> None: + import subprocess + import textwrap + + workflow = Path(".github/workflows/sync-cloud-run-env.yml").read_text(encoding="utf-8") + assert " configured_service:\n" in workflow + assert "INPUT_CONFIGURED_SERVICE: ${{ inputs.configured_service }}" in workflow + scope_guard = "(env.WORKFLOW_TARGET != 'configured' || env.INPUT_CONFIGURED_SERVICE == '')" + for name in ("Prune old Cloud Run revisions", "Clean up old Cloud Run images"): + step = workflow.split(f" - name: {name}\n", 1)[1].split(" - name:", 1)[0] + assert scope_guard in step.split(" run:", 1)[0] + cleanup = workflow.split(" reconcile_args=(", 1)[1].split(' for update in "${scheduler_updates[@]}";', 1)[0] + assert 'if [ "${WORKFLOW_TARGET:-configured}" != "configured" ] || [ -z "${INPUT_CONFIGURED_SERVICE:-}" ]; then' in cleanup + script = 'python3() { return 83; }\n' + textwrap.dedent(" reconcile_args=(" + cleanup) + for target, selector, expected in (("configured", "service-b", 0), ("configured", "", 83), ("hk-verify", "service-b", 83)): + result = subprocess.run(["/bin/bash", "-c", script], env={"WORKFLOW_TARGET": target, "INPUT_CONFIGURED_SERVICE": selector}, capture_output=True) + assert result.returncode == expected + assert workflow.index("Resolve admissible Cloud Run targets") < workflow.index("Verify deployed runtime target admission before traffic shift")