diff --git a/PUBLIC_SOURCE_EXPORT.md b/PUBLIC_SOURCE_EXPORT.md index 6f63664..4564cc4 100644 --- a/PUBLIC_SOURCE_EXPORT.md +++ b/PUBLIC_SOURCE_EXPORT.md @@ -1,3 +1,3 @@ # Public source export -This source snapshot was generated from the private canonical repository at revision `d013be0be977c530311fae02934026709d8a952f`. It contains no private Git history or operational evidence. Changes are published from the canonical repository. +This source snapshot was generated from the private canonical repository at revision `1e4c6557cffe03959a4837cda9bc9ad6b35cf6bc`. It contains no private Git history or operational evidence. Changes are published from the canonical repository. diff --git a/PUBLIC_SOURCE_MANIFEST.json b/PUBLIC_SOURCE_MANIFEST.json index 2b3460b..016a479 100644 --- a/PUBLIC_SOURCE_MANIFEST.json +++ b/PUBLIC_SOURCE_MANIFEST.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "sourceRevision": "d013be0be977c530311fae02934026709d8a952f", + "sourceRevision": "1e4c6557cffe03959a4837cda9bc9ad6b35cf6bc", "files": [ { "path": ".dockerignore", @@ -1619,8 +1619,8 @@ }, { "path": "backend/tests/test_runner_build_launcher_refresh.py", - "sha256": "887c1efb5a51e4c63eecd09a7927dfa4048e66a0dce4ceaf77b11f49f59eae5c", - "bytes": 22199 + "sha256": "2268e8e71c8c049cfc8d98539ab09cd6db1db4acefdd7ab00187410fbc57641a", + "bytes": 30038 }, { "path": "backend/tests/test_runner_capacity_controller.py", diff --git a/backend/tests/test_runner_build_launcher_refresh.py b/backend/tests/test_runner_build_launcher_refresh.py index 5bbbc45..926624f 100644 --- a/backend/tests/test_runner_build_launcher_refresh.py +++ b/backend/tests/test_runner_build_launcher_refresh.py @@ -122,7 +122,7 @@ def launcher(monkeypatch, tmp_path): monkeypatch.setattr(module, "_guard_state_unchanged", lambda _j: True) monkeypatch.setattr(module, "_fresh_recovery_host_gate", lambda: None) monkeypatch.setattr(module, "_resource_proof", lambda *_a: {"ready": True}) - monkeypatch.setattr(module, "verify_profile_task_images", lambda *_a: True) + monkeypatch.setattr(module, "verify_profile_task_images", lambda *_a, **_k: True) monkeypatch.setattr( module, "verified_topology", @@ -181,7 +181,10 @@ def launcher(monkeypatch, tmp_path): if args[:3] == ["docker", "image", "inspect"]: return json.dumps([{"Id": args[-1]}]) if args[:2] == ["docker", "inspect"]: - result = [item for item in containers.values() if item["Id"] == args[-1]] + result = [ + item for name, item in containers.items() + if name in args[2:] or item["Id"] in args[2:] + ] if not result: raise module.RunnerFleetSpecializeError( "runner_fleet_specialization_command_failed" @@ -230,6 +233,155 @@ def launcher(monkeypatch, tmp_path): return module, containers, rows, admission, effects, model +def test_refresh_uses_real_outer_image_postcondition_in_created_phase(launcher, monkeypatch): + module, containers, rows, _, _, _ = launcher + before = copy.deepcopy(containers) + registrations = copy.deepcopy(rows) + monkeypatch.setattr( + module, "verify_profile_task_images", + lambda profiles, **kwargs: module._verify_specialized_outer_images(profiles, **kwargs), + ) + preflight = module._build_launcher_preflight("refresh") + result = module._apply_build_launcher("refresh", preflight["preflightSha256"]) + assert result["status"] == "applied" + assert rows == registrations + for name in before: + if name != "itworx-ci-build": + assert containers[name] == before[name] + + +@pytest.mark.parametrize("change", [ + "forged_receipt", "partial_profiles", "changed_release", "wrong_stage", + "running_candidate", "foreign_candidate_id", "wrong_candidate_image", + "wrong_operation_label", "changed_mount", "running_backup", "changed_other_limit", +]) +def test_created_launcher_scope_rejects_drift_and_keeps_normal_admission_blocked(launcher, monkeypatch, change): + module, containers, _, _, effects, _ = launcher + + def checked(profiles, **kwargs): + receipt = module._build_launcher_receipt() + if receipt is None or receipt["status"] != "created": + return module._verify_specialized_outer_images(profiles, **kwargs) + assert module._verify_specialized_outer_images(profiles) is False + assert module.verified_topology()["ready"] is False + assert module._verify_specialized_outer_images(profiles, **kwargs) is True + original_containers = copy.deepcopy(containers) + original_receipt = copy.deepcopy(receipt) + release = sys.modules["status_server"]._require_controller_release() + original_release = copy.deepcopy(release) + effects_before = copy.deepcopy(effects) + candidate = containers["itworx-ci-build"] + scoped = dict(kwargs) + selected = profiles + if change == "forged_receipt": + scoped["pending_build_launcher"] = {**receipt, "operationId": "9" * 64} + elif change == "partial_profiles": + selected = ("build",) + elif change == "changed_release": + release["sourceRevision"] = "9" * 40 + elif change == "wrong_stage": + modified = {**receipt, "status": "prepared"} + module._write_private_atomic(module._build_launcher_receipt_path(), module._canonical_bytes(modified)) + scoped["pending_build_launcher"] = modified + elif change == "running_candidate": + candidate["State"]["Running"] = True + elif change == "foreign_candidate_id": + candidate["Id"] = "9" * 64 + elif change == "wrong_candidate_image": + candidate["Image"] = module.RUNNER_IMAGE_ID + elif change == "wrong_operation_label": + candidate["Config"]["Labels"]["tech.itworx.ci.launcher-operation"] = "9" * 64 + elif change == "changed_mount": + candidate["Mounts"][1]["RW"] = True + elif change == "running_backup": + containers[receipt["backupName"]]["State"]["Running"] = True + else: + containers["itworx-ci-backend"]["HostConfig"]["Memory"] += 1 + try: + assert module._verify_specialized_outer_images(selected, **scoped) is False + assert effects == effects_before + finally: + containers.clear() + containers.update(original_containers) + release.clear() + release.update(original_release) + module._write_private_atomic(module._build_launcher_receipt_path(), module._canonical_bytes(original_receipt)) + return module._verify_specialized_outer_images(profiles, **kwargs) + + monkeypatch.setattr(module, "verify_profile_task_images", checked) + preflight = module._build_launcher_preflight("refresh") + result = module._apply_build_launcher("refresh", preflight["preflightSha256"]) + assert result["status"] == "applied" + + +def test_final_created_snapshot_refuses_other_runner_identity_change(launcher, monkeypatch): + module, containers, _, _, _, _ = launcher + + def check_then_drift(profiles, **kwargs): + valid = module._verify_specialized_outer_images(profiles, **kwargs) + if kwargs.get("pending_build_launcher") is not None: + containers["itworx-ci-backend"]["Id"] = "9" * 64 + return valid + + monkeypatch.setattr(module, "verify_profile_task_images", check_then_drift) + preflight = module._build_launcher_preflight("refresh") + with pytest.raises(module.RunnerFleetSpecializeError, match="runner_build_launcher_outcome_unknown"): + module._apply_build_launcher("refresh", preflight["preflightSha256"]) + assert module._build_launcher_receipt()["status"] == "rollback_unknown" + receipt = module._build_launcher_receipt() + diagnostic = json.loads((module.SPECIALIZED_STATE_ROOT / ("build-launcher-failure-" + receipt["operationId"] + ".json")).read_bytes()) + assert diagnostic["failureStage"] == "final_fences" + assert diagnostic["failureCode"] == "runner_build_launcher_recovery_fleet_changed" + + +@pytest.mark.parametrize("failure,expected_code", [ + ("runner_build_launcher_postcondition_failed", "runner_build_launcher_postcondition_failed"), + ("synthetic_private_detail_not_for_receipts", "runner_build_launcher_internal_failure"), +]) +def test_private_rollback_diagnostics_retain_only_finite_codes(launcher, monkeypatch, failure, expected_code): + module, _, _, _, _, _ = launcher + + def fail_after_creation(_profiles, **kwargs): + if kwargs.get("pending_build_launcher") is not None: + raise RuntimeError(failure) + return True + + monkeypatch.setattr(module, "verify_profile_task_images", fail_after_creation) + preflight = module._build_launcher_preflight("refresh") + with pytest.raises(module.RunnerFleetSpecializeError, match="runner_build_launcher_failed_rolled_back"): + module._apply_build_launcher("refresh", preflight["preflightSha256"]) + receipt = module._build_launcher_receipt() + assert receipt["status"] == "rolled_back" + diagnostic = json.loads((module.SPECIALIZED_STATE_ROOT / ("build-launcher-failure-" + receipt["operationId"] + ".json")).read_bytes()) + assert diagnostic["failureStage"] == "task_images" + assert diagnostic["failureCode"] == expected_code + assert "failureStage" not in receipt and "failureCode" not in receipt + assert "synthetic_private_detail_not_for_receipts" not in module._build_launcher_receipt_path().read_text() + + +def test_diagnostic_storage_failure_does_not_prevent_guarded_rollback(launcher, monkeypatch): + module, containers, _, _, _, _ = launcher + before = copy.deepcopy(containers) + original_write = module._write_private_atomic + + def write_without_diagnostics(path, raw): + if path.name.startswith("build-launcher-failure-"): + raise OSError("synthetic_private_storage_detail") + original_write(path, raw) + + def fail_after_creation(_profiles, **kwargs): + return kwargs.get("pending_build_launcher") is None + + monkeypatch.setattr(module, "_write_private_atomic", write_without_diagnostics) + monkeypatch.setattr(module, "verify_profile_task_images", fail_after_creation) + preflight = module._build_launcher_preflight("refresh") + with pytest.raises(module.RunnerFleetSpecializeError, match="runner_build_launcher_failed_rolled_back"): + module._apply_build_launcher("refresh", preflight["preflightSha256"]) + assert module._build_launcher_receipt()["status"] == "rolled_back" + assert containers == before + assert not list(module.SPECIALIZED_STATE_ROOT.glob("build-launcher-failure-*.json")) + + def test_refresh_and_explicit_rollback_preserve_fleet_and_archived_model(launcher): module, containers, rows, _, effects, model = launcher before = copy.deepcopy(containers)