diff --git a/scripts/resume_schwab_single_state.py b/scripts/resume_schwab_single_state.py index 5543585e..f3ef9ad8 100644 --- a/scripts/resume_schwab_single_state.py +++ b/scripts/resume_schwab_single_state.py @@ -22,13 +22,23 @@ class TemplateDifference(ValueError): def __init__(self, desired, serving): super().__init__("template_differs") fields = ("containers", "containerConcurrency", "timeoutSeconds", "serviceAccountName", "volumes") - self.comparisons = {name: desired.get(name) == serving.get(name) for name in fields} - self.comparisons["other_fields"] = {k: v for k, v in desired.items() if k not in fields} == {k: v for k, v in serving.items() if k not in fields} + self.comparisons = {name: "match" if desired.get(name) == serving.get(name) else "mismatch" for name in fields} + self.comparisons["other_fields"] = "match" if {k: v for k, v in desired.items() if k not in fields} == {k: v for k, v in serving.items() if k not in fields} else "mismatch" left, right = desired.get("containers", []), serving.get("containers", []) if len(left) == len(right) == 1: names = ("image", "env", "resources", "ports", "startupProbe", "command", "args", "name") - self.comparisons.update({"container_" + name: left[0].get(name) == right[0].get(name) for name in names}) - self.comparisons["container_other_fields"] = {k: v for k, v in left[0].items() if k not in names} == {k: v for k, v in right[0].items() if k not in names} + self.comparisons.update({"container_" + name: "match" if left[0].get(name) == right[0].get(name) else "mismatch" for name in names}) + self.comparisons["container_other_fields"] = "match" if {k: v for k, v in left[0].items() if k not in names} == {k: v for k, v in right[0].items() if k not in names} else "mismatch" + + +def _runtime_spec(spec): + """Use the previously verified Cloud Run revision representation rules.""" + spec = copy.deepcopy(spec) + spec.pop("servingState", None) + for container in spec["containers"]: + container.pop("name", None) + container["env"] = sorted(container.get("env", []), key=lambda item: item["name"]) + return spec def _command(args): @@ -61,7 +71,7 @@ def inspect(environ, *, describe=metadata._describe): if target != paused or build_live_continuity(target["live_continuity"]).baseline_target_sha256 != runtime_target_fingerprint(target): raise ValueError("target_mismatch") desired = before.get("spec", {}).get("template", {}).get("spec") - if desired != revision.get("spec"): + if not isinstance(desired, dict) or _runtime_spec(desired) != _runtime_spec(revision["spec"]): if not isinstance(desired, dict) or not isinstance(revision.get("spec"), dict): raise ValueError("template_differs") raise TemplateDifference(desired, revision["spec"]) @@ -91,9 +101,9 @@ def execute(environ, *, describe=metadata._describe, command=_command): scheduler = "unknown" except Exception: pass - return {"status": "ready_for_review", "template_matches_serving": True, - "paused": True, "declaration_state": declared_state, "scheduler_run": scheduler, - "cloud_write_attempted": False} + return {"status": "ready_for_review", "template_comparison": "match", + "continuity_state": "PAUSED", "declaration_state": declared_state, "scheduler_run": scheduler, + "cloud_write": "none"} if mode != "apply" or declared_state != "ACTIVE_LKG": raise ValueError("active_declaration_required") run_id = environ.get("GITHUB_RUN_ID", "") @@ -126,7 +136,7 @@ def execute(environ, *, describe=metadata._describe, command=_command): for item in expected_spec["containers"][0]["env"]: if item.get("name") == "RUNTIME_TARGET_JSON": item["value"] = value - if staged.get("spec") != expected_spec: + if _runtime_spec(staged.get("spec", {})) != _runtime_spec(expected_spec): raise ValueError("candidate_configuration_mismatch") ready = {x.get("type"): x.get("status") for x in staged.get("status", {}).get("conditions", [])} if ready.get("Ready") != "True": @@ -146,13 +156,13 @@ def execute(environ, *, describe=metadata._describe, command=_command): confirmed = describe("services", SERVICE, PROJECT, REGION) except Exception: raise RuntimeError("adoption_readback_unknown_do_not_retry") from None - if (metadata._serving(readback, SERVICE) != candidate or adopted.get("spec") != expected_spec + if (metadata._serving(readback, SERVICE) != candidate or _runtime_spec(adopted.get("spec", {})) != _runtime_spec(expected_spec) or metadata._serving(confirmed, SERVICE) != candidate or readback.get("metadata", {}).get("resourceVersion") != confirmed.get("metadata", {}).get("resourceVersion")): raise ValueError("adoption_readback_mismatch") - return {"status": "active_readback", "template_matches_serving": True, - "paused": False, "declaration_state": declared_state, "cloud_write_attempted": True, - "broker_requested": False, "natural_cycle_observed": False} + return {"status": "active_readback", "template_comparison": "match", + "continuity_state": "ACTIVE_LKG", "declaration_state": declared_state, "cloud_write": "attempted", + "broker_read": "none", "natural_cycle": "not_observed"} def main(): diff --git a/tests/test_resume_schwab_single_state.py b/tests/test_resume_schwab_single_state.py index 8913d9fb..f6e29f42 100644 --- a/tests/test_resume_schwab_single_state.py +++ b/tests/test_resume_schwab_single_state.py @@ -42,11 +42,26 @@ def test_template_difference_exposes_only_fixed_boolean_comparisons(): desired = {"containers": [{"image": "PRIVATE_A", "env": [{"name": "PRIVATE_FIELD"}]}]} serving = {"containers": [{"image": "PRIVATE_B", "env": [{"name": "PRIVATE_FIELD"}]}]} result = resume.TemplateDifference(desired, serving).comparisons - assert result["container_image"] is False and result["container_env"] is True - assert all(type(value) is bool for value in result.values()) + assert result["container_image"] == "mismatch" and result["container_env"] == "match" + assert all(value in {"match", "mismatch"} for value in result.values()) assert "PRIVATE" not in json.dumps(result) +def test_runtime_comparison_ignores_only_known_server_representation(): + original = {"containers": [{"name": "generated", "image": "immutable", + "env": [{"name": "B", "value": "2"}, {"name": "A", "value": "1"}]}], + "servingState": "active", "timeoutSeconds": 300} + equivalent = {"containers": [{"image": "immutable", + "env": [{"name": "A", "value": "1"}, {"name": "B", "value": "2"}]}], + "timeoutSeconds": 300} + assert resume._runtime_spec(original) == resume._runtime_spec(equivalent) + equivalent["containers"][0]["image"] = "other" + assert resume._runtime_spec(original) != resume._runtime_spec(equivalent) + equivalent["containers"][0]["image"] = "immutable" + equivalent["timeoutSeconds"] = 10 + assert resume._runtime_spec(original) != resume._runtime_spec(equivalent) + + def test_apply_writes_only_state_then_adopts_and_reads_back(case, monkeypatch): service, revision, target = case declared = copy.deepcopy(target) @@ -68,7 +83,7 @@ def describe(kind, name, *_): if state["phase"] == "adopted": result["status"]["traffic"] = [{"revisionName": candidate, "percent": 100}] return result result = resume.execute({"RESUME_OPERATION": "apply", "GITHUB_RUN_ID": "123"}, describe=describe, command=command) - assert result["status"] == "active_readback" and not result["natural_cycle_observed"] + assert result["status"] == "active_readback" and result["natural_cycle"] == "not_observed" assert len(state["calls"]) == 2 assert "--no-traffic" in state["calls"][0] update = next(x for x in state["calls"][0] if x.startswith("--update-env-vars="))