fix(release): make durable runs recovery safe
This commit is contained in:
@@ -1,9 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from itertools import count
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
@@ -11,6 +15,7 @@ import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from filelock import FileLock
|
||||
|
||||
|
||||
META_ROOT = Path(__file__).resolve().parents[1]
|
||||
@@ -34,6 +39,7 @@ from server.app import ( # noqa: E402
|
||||
|
||||
|
||||
WORKSPACE_FINGERPRINT = "a" * 64
|
||||
CREATE_REQUESTS = count(1)
|
||||
|
||||
|
||||
class ReleaseRunStoreTests(unittest.TestCase):
|
||||
@@ -49,7 +55,8 @@ class ReleaseRunStoreTests(unittest.TestCase):
|
||||
self.temporary.cleanup()
|
||||
|
||||
def test_create_persists_private_immutable_plan_and_ordered_steps(self) -> None:
|
||||
run = self.store.create(
|
||||
run = create_run(
|
||||
self.store,
|
||||
input_snapshot=run_input(), plan_snapshot=release_plan()
|
||||
)
|
||||
|
||||
@@ -60,7 +67,7 @@ class ReleaseRunStoreTests(unittest.TestCase):
|
||||
record_path = self.root / f"{run['run_id']}.json"
|
||||
|
||||
self.assertEqual("govoplan.release-run", reopened["schema"])
|
||||
self.assertEqual(2, reopened["schema_version"])
|
||||
self.assertEqual(3, reopened["schema_version"])
|
||||
self.assertEqual(
|
||||
{"govoplan-core": "1.2.3", "govoplan-files": "1.2.4"},
|
||||
reopened["immutable"]["input"]["repo_versions"],
|
||||
@@ -78,10 +85,43 @@ class ReleaseRunStoreTests(unittest.TestCase):
|
||||
plan = release_plan()
|
||||
plan["dry_run_steps"] = []
|
||||
with self.assertRaisesRegex(ReleaseRunConflict, "step collection"):
|
||||
self.store.create(input_snapshot=run_input(), plan_snapshot=plan)
|
||||
create_run(self.store, input_snapshot=run_input(), plan_snapshot=plan)
|
||||
|
||||
def test_create_request_is_durable_idempotent_and_input_bound(self) -> None:
|
||||
request_id = "durable-create-request-0001"
|
||||
created = create_run(self.store, request_id=request_id)
|
||||
changed_plan = release_plan()
|
||||
changed_plan["dry_run_steps"][0]["title"] = "Dashboard drift" # type: ignore[index]
|
||||
|
||||
replayed = create_run(
|
||||
self.store,
|
||||
request_id=request_id,
|
||||
plan_snapshot=changed_plan,
|
||||
)
|
||||
found = self.store.find_created_run(
|
||||
request_id=request_id,
|
||||
input_snapshot=run_input(),
|
||||
)
|
||||
self.assertEqual(created["run_id"], replayed["run_id"])
|
||||
self.assertEqual(created["run_id"], found["run_id"] if found else None)
|
||||
self.assertEqual(
|
||||
"Inspect Core",
|
||||
replayed["immutable"]["plan"]["dry_run_steps"][0]["title"],
|
||||
)
|
||||
self.assertEqual(1, len(list(self.root.glob("*.json"))))
|
||||
|
||||
changed_input = run_input()
|
||||
changed_input["public_catalog"] = True
|
||||
with self.assertRaisesRegex(ReleaseRunConflict, "different inputs"):
|
||||
create_run(
|
||||
self.store,
|
||||
request_id=request_id,
|
||||
input_snapshot=changed_input,
|
||||
)
|
||||
|
||||
def test_attempt_transitions_are_idempotent_and_strictly_ordered(self) -> None:
|
||||
run_id = self.store.create(
|
||||
run_id = create_run(
|
||||
self.store,
|
||||
input_snapshot=run_input(), plan_snapshot=release_plan()
|
||||
)["run_id"]
|
||||
|
||||
@@ -122,7 +162,8 @@ class ReleaseRunStoreTests(unittest.TestCase):
|
||||
self.assertTrue(finished["state"]["steps"][1]["available"])
|
||||
|
||||
def test_failed_attempt_and_delayed_start_replays_are_idempotent(self) -> None:
|
||||
run_id = self.store.create(
|
||||
run_id = create_run(
|
||||
self.store,
|
||||
input_snapshot=run_input(), plan_snapshot=release_plan()
|
||||
)["run_id"]
|
||||
attempt_id = "failed-attempt-replay-0001"
|
||||
@@ -155,77 +196,129 @@ class ReleaseRunStoreTests(unittest.TestCase):
|
||||
len(retried["state"]["events"]), len(delayed["state"]["events"])
|
||||
)
|
||||
|
||||
def test_delayed_resume_request_is_idempotent_for_the_run_lifetime(self) -> None:
|
||||
run_id = self.store.create(
|
||||
def test_resume_requires_a_running_attempt_and_only_records_an_effect(self) -> None:
|
||||
run_id = create_run(
|
||||
self.store,
|
||||
input_snapshot=run_input(), plan_snapshot=release_plan()
|
||||
)["run_id"]
|
||||
original_request = "resume-original-request-0001"
|
||||
self.store.resume(run_id, request_id=original_request)
|
||||
for index in range(130):
|
||||
self.store.resume(
|
||||
run_id, request_id=f"resume-intervening-request-{index:04d}"
|
||||
)
|
||||
running = self.store.start_step(
|
||||
with self.assertRaisesRegex(ReleaseRunConflict, "only be resumed"):
|
||||
self.store.resume(run_id, request_id=original_request)
|
||||
before_start = self.store.get(run_id)
|
||||
|
||||
self.store.start_step(
|
||||
run_id,
|
||||
"core:preflight",
|
||||
attempt_id="resume-delayed-attempt-0001",
|
||||
)
|
||||
resumed = self.store.resume(run_id, request_id=original_request)
|
||||
duplicate = self.store.resume(run_id, request_id=original_request)
|
||||
self.assertEqual("running", duplicate["state"]["steps"][0]["state"])
|
||||
self.assertEqual("interrupted", duplicate["state"]["steps"][0]["state"])
|
||||
self.assertEqual(
|
||||
len(running["state"]["events"]), len(duplicate["state"]["events"])
|
||||
len(resumed["state"]["events"]), len(duplicate["state"]["events"])
|
||||
)
|
||||
raw = json.loads(
|
||||
(self.root / f"{run_id}.json").read_text(encoding="utf-8")
|
||||
)
|
||||
self.assertEqual(1, len(raw["state"]["processed_requests"]))
|
||||
self.assertLess(len(before_start["state"]["events"]), len(resumed["state"]["events"]))
|
||||
|
||||
def test_command_and_attempt_capacity_fail_closed_without_eviction(self) -> None:
|
||||
run_id = self.store.create(
|
||||
input_snapshot=run_input(), plan_snapshot=release_plan()
|
||||
)["run_id"]
|
||||
with patch("govoplan_release.release_run.MAX_COMMANDS", 2):
|
||||
self.store.resume(run_id, request_id="capacity-resume-request-0001")
|
||||
self.store.resume(run_id, request_id="capacity-resume-request-0002")
|
||||
with self.assertRaisesRegex(ReleaseRunConflict, "safety limit"):
|
||||
self.store.resume(run_id, request_id="capacity-resume-request-0003")
|
||||
duplicate = self.store.resume(
|
||||
run_id, request_id="capacity-resume-request-0001"
|
||||
)
|
||||
self.assertEqual("planned", duplicate["state"]["status"])
|
||||
|
||||
attempt_run_id = self.store.create(
|
||||
def test_attempt_start_reserves_capacity_for_ambiguous_effect_recovery(self) -> None:
|
||||
saturated_run_id = create_run(
|
||||
self.store,
|
||||
input_snapshot=run_input(), plan_snapshot=release_plan()
|
||||
)["run_id"]
|
||||
with patch("govoplan_release.release_run.MAX_COMMANDS", 1):
|
||||
self.store.start_step(
|
||||
attempt_run_id,
|
||||
"core:preflight",
|
||||
attempt_id="capacity-attempt-0001",
|
||||
)
|
||||
self.store.finish_step(
|
||||
attempt_run_id,
|
||||
"core:preflight",
|
||||
attempt_id="capacity-attempt-0001",
|
||||
succeeded=False,
|
||||
result_code="preflight_failed",
|
||||
)
|
||||
self.store.retry_step(
|
||||
attempt_run_id,
|
||||
"core:preflight",
|
||||
request_id="capacity-retry-request-0001",
|
||||
)
|
||||
with self.assertRaisesRegex(ReleaseRunConflict, "attempt history"):
|
||||
with self.assertRaisesRegex(ReleaseRunConflict, "recovery capacity"):
|
||||
self.store.start_step(
|
||||
attempt_run_id,
|
||||
saturated_run_id,
|
||||
"core:preflight",
|
||||
attempt_id="capacity-attempt-0002",
|
||||
attempt_id="capacity-prestart-attempt-0001",
|
||||
)
|
||||
delayed_attempt = self.store.start_step(
|
||||
attempt_run_id,
|
||||
"core:preflight",
|
||||
attempt_id="capacity-attempt-0001",
|
||||
untouched = self.store.get(saturated_run_id)
|
||||
self.assertEqual("pending", untouched["state"]["steps"][0]["state"])
|
||||
self.assertEqual(0, untouched["state"]["steps"][0]["attempt_count"])
|
||||
|
||||
terminal_run_id = create_run(
|
||||
self.store,
|
||||
input_snapshot=run_input(),
|
||||
plan_snapshot=mutating_first_plan(),
|
||||
)["run_id"]
|
||||
with patch("govoplan_release.release_run.MAX_COMMANDS", 2):
|
||||
self.store.start_step(
|
||||
terminal_run_id, "core:tag", attempt_id="capacity-terminal-attempt"
|
||||
)
|
||||
self.assertEqual("pending", delayed_attempt["state"]["steps"][0]["state"])
|
||||
self.store.resume(
|
||||
terminal_run_id, request_id="capacity-terminal-resume-0001"
|
||||
)
|
||||
with self.assertRaisesRegex(ReleaseRunConflict, "safety limit"):
|
||||
self.store.reconcile_step(
|
||||
terminal_run_id,
|
||||
"core:tag",
|
||||
request_id="capacity-unresolved-request-0001",
|
||||
outcome="unresolved",
|
||||
confirmation="RECONCILE",
|
||||
)
|
||||
completed = self.store.reconcile_step(
|
||||
terminal_run_id,
|
||||
"core:tag",
|
||||
request_id="capacity-terminal-request-0001",
|
||||
outcome="effect_succeeded",
|
||||
confirmation="RECONCILE",
|
||||
)
|
||||
self.assertEqual("completed", completed["state"]["status"])
|
||||
|
||||
def test_unresolved_reconciliation_is_bounded_and_preserves_terminal_slot(self) -> None:
|
||||
run_id = create_run(
|
||||
self.store,
|
||||
input_snapshot=run_input(),
|
||||
plan_snapshot=mutating_first_plan(),
|
||||
)["run_id"]
|
||||
with patch("govoplan_release.release_run.MAX_COMMANDS", 3):
|
||||
self.store.start_step(
|
||||
run_id, "core:tag", attempt_id="capacity-unresolved-attempt"
|
||||
)
|
||||
self.store.resume(
|
||||
run_id, request_id="capacity-unresolved-resume-0001"
|
||||
)
|
||||
unresolved = self.store.reconcile_step(
|
||||
run_id,
|
||||
"core:tag",
|
||||
request_id="capacity-unresolved-request-0001",
|
||||
outcome="unresolved",
|
||||
confirmation="RECONCILE",
|
||||
)
|
||||
duplicate = self.store.reconcile_step(
|
||||
run_id,
|
||||
"core:tag",
|
||||
request_id="capacity-unresolved-request-0001",
|
||||
outcome="unresolved",
|
||||
confirmation="RECONCILE",
|
||||
)
|
||||
self.assertEqual(
|
||||
len(unresolved["state"]["events"]),
|
||||
len(duplicate["state"]["events"]),
|
||||
)
|
||||
with self.assertRaisesRegex(ReleaseRunConflict, "already recorded"):
|
||||
self.store.reconcile_step(
|
||||
run_id,
|
||||
"core:tag",
|
||||
request_id="capacity-unresolved-request-0002",
|
||||
outcome="unresolved",
|
||||
confirmation="RECONCILE",
|
||||
)
|
||||
completed = self.store.reconcile_step(
|
||||
run_id,
|
||||
"core:tag",
|
||||
request_id="capacity-final-request-0001",
|
||||
outcome="effect_succeeded",
|
||||
confirmation="RECONCILE",
|
||||
)
|
||||
self.assertEqual("completed", completed["state"]["status"])
|
||||
|
||||
def test_reopen_and_resume_preserve_ambiguous_mutating_effect(self) -> None:
|
||||
run_id = self.store.create(
|
||||
run_id = create_run(
|
||||
self.store,
|
||||
input_snapshot=run_input(), plan_snapshot=mutating_first_plan()
|
||||
)["run_id"]
|
||||
self.store.start_step(run_id, "core:tag", attempt_id="attempt-tag-0001")
|
||||
@@ -281,7 +374,8 @@ class ReleaseRunStoreTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
def test_reconciliation_can_record_unresolved_or_verified_success(self) -> None:
|
||||
run_id = self.store.create(
|
||||
run_id = create_run(
|
||||
self.store,
|
||||
input_snapshot=run_input(), plan_snapshot=mutating_first_plan()
|
||||
)["run_id"]
|
||||
self.store.start_step(run_id, "core:tag", attempt_id="reconcile-attempt-0001")
|
||||
@@ -322,7 +416,8 @@ class ReleaseRunStoreTests(unittest.TestCase):
|
||||
self.assertEqual("completed", succeeded["state"]["status"])
|
||||
|
||||
def test_tampered_record_fails_closed_and_is_not_rewritten(self) -> None:
|
||||
run_id = self.store.create(
|
||||
run_id = create_run(
|
||||
self.store,
|
||||
input_snapshot=run_input(), plan_snapshot=release_plan()
|
||||
)["run_id"]
|
||||
path = self.root / f"{run_id}.json"
|
||||
@@ -338,7 +433,8 @@ class ReleaseRunStoreTests(unittest.TestCase):
|
||||
self.assertEqual("unavailable", self.store.list()[0]["status"])
|
||||
|
||||
def test_broad_record_mode_and_symlinked_root_fail_closed(self) -> None:
|
||||
run_id = self.store.create(
|
||||
run_id = create_run(
|
||||
self.store,
|
||||
input_snapshot=run_input(), plan_snapshot=release_plan()
|
||||
)["run_id"]
|
||||
path = self.root / f"{run_id}.json"
|
||||
@@ -357,8 +453,58 @@ class ReleaseRunStoreTests(unittest.TestCase):
|
||||
expected_workspace_fingerprint=WORKSPACE_FINGERPRINT,
|
||||
).list()
|
||||
|
||||
def test_symlinked_lock_and_lock_io_errors_fail_closed(self) -> None:
|
||||
self.store.list()
|
||||
lock_path = self.root / ".release-runs.lock"
|
||||
lock_path.unlink()
|
||||
target = self.root / "lock-target"
|
||||
target.write_text("do not follow", encoding="utf-8")
|
||||
lock_path.symlink_to(target)
|
||||
with self.assertRaisesRegex(ReleaseRunCorrupt, "lock path"):
|
||||
self.store.list()
|
||||
|
||||
lock_path.unlink()
|
||||
with patch(
|
||||
"govoplan_release.release_run.FileLock.acquire",
|
||||
side_effect=PermissionError("denied"),
|
||||
):
|
||||
with self.assertRaisesRegex(ReleaseRunCorrupt, "acquired safely"):
|
||||
self.store.list()
|
||||
|
||||
original_release = FileLock.release
|
||||
|
||||
def fail_operator_release(
|
||||
lock: FileLock, *args: object, **kwargs: object
|
||||
) -> None:
|
||||
if kwargs.get("force") is True:
|
||||
original_release(lock, *args, **kwargs) # type: ignore[arg-type]
|
||||
return
|
||||
raise PermissionError("denied")
|
||||
|
||||
with patch.object(
|
||||
FileLock,
|
||||
"release",
|
||||
autospec=True,
|
||||
side_effect=fail_operator_release,
|
||||
):
|
||||
with self.assertRaisesRegex(ReleaseRunCorrupt, "released safely"):
|
||||
self.store.list()
|
||||
|
||||
def test_atomic_write_oserror_is_normalized_and_leaves_no_record(self) -> None:
|
||||
with patch(
|
||||
"govoplan_release.release_run.os.replace",
|
||||
side_effect=PermissionError("denied"),
|
||||
):
|
||||
with self.assertRaisesRegex(ReleaseRunCorrupt, "persisted safely"):
|
||||
create_run(
|
||||
self.store,
|
||||
request_id="atomic-write-create-request-0001",
|
||||
)
|
||||
self.assertEqual([], list(self.root.glob("*.json")))
|
||||
|
||||
def test_state_specific_fields_and_boolean_counts_fail_closed(self) -> None:
|
||||
run_id = self.store.create(
|
||||
run_id = create_run(
|
||||
self.store,
|
||||
input_snapshot=run_input(), plan_snapshot=release_plan()
|
||||
)["run_id"]
|
||||
path = self.root / f"{run_id}.json"
|
||||
@@ -372,7 +518,8 @@ class ReleaseRunStoreTests(unittest.TestCase):
|
||||
self.store.get(run_id)
|
||||
|
||||
def test_valid_looking_mutable_state_tampering_fails_record_checksum(self) -> None:
|
||||
run_id = self.store.create(
|
||||
run_id = create_run(
|
||||
self.store,
|
||||
input_snapshot=run_input(), plan_snapshot=mutating_first_plan()
|
||||
)["run_id"]
|
||||
self.store.start_step(run_id, "core:tag", attempt_id="checksum-attempt-0001")
|
||||
@@ -396,7 +543,8 @@ class ReleaseRunStoreTests(unittest.TestCase):
|
||||
self.assertEqual(tampered, path.read_bytes())
|
||||
|
||||
def test_impossible_step_chronology_fails_even_with_recomputed_checksum(self) -> None:
|
||||
run_id = self.store.create(
|
||||
run_id = create_run(
|
||||
self.store,
|
||||
input_snapshot=run_input(), plan_snapshot=release_plan()
|
||||
)["run_id"]
|
||||
self.store.start_step(
|
||||
@@ -421,9 +569,13 @@ class ReleaseRunStoreTests(unittest.TestCase):
|
||||
self.store.get(run_id)
|
||||
|
||||
def test_record_and_retained_event_timestamps_are_strictly_ordered(self) -> None:
|
||||
run_id = self.store.create(
|
||||
run_id = create_run(
|
||||
self.store,
|
||||
input_snapshot=run_input(), plan_snapshot=release_plan()
|
||||
)["run_id"]
|
||||
self.store.start_step(
|
||||
run_id, "core:preflight", attempt_id="timestamp-attempt-0001"
|
||||
)
|
||||
self.store.resume(run_id, request_id="timestamp-resume-request-0001")
|
||||
path = self.root / f"{run_id}.json"
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
@@ -439,7 +591,8 @@ class ReleaseRunStoreTests(unittest.TestCase):
|
||||
self.store.get(run_id)
|
||||
|
||||
def test_attempt_count_must_match_lifetime_attempt_history(self) -> None:
|
||||
run_id = self.store.create(
|
||||
run_id = create_run(
|
||||
self.store,
|
||||
input_snapshot=run_input(), plan_snapshot=release_plan()
|
||||
)["run_id"]
|
||||
self.store.start_step(
|
||||
@@ -456,7 +609,8 @@ class ReleaseRunStoreTests(unittest.TestCase):
|
||||
self.store.get(run_id)
|
||||
|
||||
def test_workspace_fingerprint_is_enforced_for_every_store_operation(self) -> None:
|
||||
run_id = self.store.create(
|
||||
run_id = create_run(
|
||||
self.store,
|
||||
input_snapshot=run_input(), plan_snapshot=release_plan()
|
||||
)["run_id"]
|
||||
foreign = ReleaseRunStore(
|
||||
@@ -470,7 +624,11 @@ class ReleaseRunStoreTests(unittest.TestCase):
|
||||
with self.assertRaises(ReleaseRunNotFound):
|
||||
foreign.resume(run_id, request_id="foreign-resume-request-0001")
|
||||
with self.assertRaisesRegex(ReleaseRunConflict, "different workspace"):
|
||||
foreign.create(input_snapshot=run_input(), plan_snapshot=release_plan())
|
||||
create_run(
|
||||
foreign,
|
||||
input_snapshot=run_input(),
|
||||
plan_snapshot=release_plan(),
|
||||
)
|
||||
|
||||
def test_list_limit_is_applied_after_foreign_workspace_records_are_skipped(
|
||||
self,
|
||||
@@ -479,7 +637,8 @@ class ReleaseRunStoreTests(unittest.TestCase):
|
||||
"govoplan_release.release_run._timestamp",
|
||||
return_value="2026-07-22T00:00:00Z",
|
||||
):
|
||||
local_run = self.store.create(
|
||||
local_run = create_run(
|
||||
self.store,
|
||||
input_snapshot=run_input(), plan_snapshot=release_plan()
|
||||
)
|
||||
foreign_store = ReleaseRunStore(
|
||||
@@ -492,7 +651,8 @@ class ReleaseRunStoreTests(unittest.TestCase):
|
||||
"govoplan_release.release_run._timestamp",
|
||||
return_value="2026-07-22T00:00:01Z",
|
||||
):
|
||||
foreign_store.create(
|
||||
create_run(
|
||||
foreign_store,
|
||||
input_snapshot=foreign_input, plan_snapshot=release_plan()
|
||||
)
|
||||
|
||||
@@ -500,7 +660,8 @@ class ReleaseRunStoreTests(unittest.TestCase):
|
||||
self.assertEqual([local_run["run_id"]], [item["run_id"] for item in listed])
|
||||
|
||||
def test_concurrent_claims_have_one_winner_and_no_lost_update(self) -> None:
|
||||
run_id = self.store.create(
|
||||
run_id = create_run(
|
||||
self.store,
|
||||
input_snapshot=run_input(), plan_snapshot=release_plan()
|
||||
)["run_id"]
|
||||
barrier = threading.Barrier(2)
|
||||
@@ -535,7 +696,8 @@ class ReleaseRunStoreTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
def test_event_history_is_bounded_and_command_history_is_not_evicted(self) -> None:
|
||||
run_id = self.store.create(
|
||||
run_id = create_run(
|
||||
self.store,
|
||||
input_snapshot=run_input(), plan_snapshot=release_plan()
|
||||
)["run_id"]
|
||||
for index in range(90):
|
||||
@@ -588,7 +750,7 @@ class ReleaseRunApiTests(unittest.TestCase):
|
||||
created_response = client.post(
|
||||
"/api/release-runs",
|
||||
headers=headers,
|
||||
json=run_input(),
|
||||
json=api_run_input(request_id="api-create-request-0001"),
|
||||
)
|
||||
self.assertEqual(200, created_response.status_code)
|
||||
created = created_response.json()
|
||||
@@ -623,6 +785,59 @@ class ReleaseRunApiTests(unittest.TestCase):
|
||||
self.assertEqual(200, retried.status_code)
|
||||
self.assertEqual("pending", retried.json()["state"]["steps"][0]["state"])
|
||||
|
||||
def test_create_requires_request_id_and_replays_before_plan_rebuild(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
app = create_app(
|
||||
workspace_root=Path(temp_dir),
|
||||
run_state_root=Path(temp_dir) / "runs",
|
||||
token="token",
|
||||
)
|
||||
headers = {"X-Release-Console-Token": "token"}
|
||||
dashboard = patch("server.app.build_dashboard", return_value=object())
|
||||
planner = patch(
|
||||
"server.app.build_selective_release_plan",
|
||||
return_value=release_plan(),
|
||||
)
|
||||
with dashboard as build_dashboard, planner as build_plan, TestClient(
|
||||
app
|
||||
) as client:
|
||||
missing = client.post(
|
||||
"/api/release-runs",
|
||||
headers=headers,
|
||||
json={
|
||||
key: value
|
||||
for key, value in api_run_input().items()
|
||||
if key != "request_id"
|
||||
},
|
||||
)
|
||||
payload = api_run_input(request_id="api-idempotent-create-0001")
|
||||
created = client.post(
|
||||
"/api/release-runs", headers=headers, json=payload
|
||||
)
|
||||
self.assertEqual(200, created.status_code)
|
||||
build_dashboard.reset_mock()
|
||||
build_plan.reset_mock()
|
||||
|
||||
replayed = client.post(
|
||||
"/api/release-runs", headers=headers, json=payload
|
||||
)
|
||||
mismatched = client.post(
|
||||
"/api/release-runs",
|
||||
headers=headers,
|
||||
json=payload | {"public_catalog": True},
|
||||
)
|
||||
|
||||
self.assertEqual(422, missing.status_code)
|
||||
self.assertEqual(200, replayed.status_code)
|
||||
self.assertEqual(created.json()["run_id"], replayed.json()["run_id"])
|
||||
self.assertEqual(409, mismatched.status_code)
|
||||
self.assertIn("different inputs", mismatched.json()["detail"])
|
||||
build_dashboard.assert_not_called()
|
||||
build_plan.assert_not_called()
|
||||
self.assertEqual(
|
||||
1, len(list(app.state.release_runs.root.glob("*.json")))
|
||||
)
|
||||
|
||||
def test_create_rejects_unknown_or_unresolved_repository(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir) / "runs"
|
||||
@@ -641,6 +856,7 @@ class ReleaseRunApiTests(unittest.TestCase):
|
||||
"/api/release-runs",
|
||||
headers={"X-Release-Console-Token": "token"},
|
||||
json={
|
||||
"request_id": "api-unknown-create-request-0001",
|
||||
"channel": "stable",
|
||||
"repo_versions": {"govoplan-unknown": "1.0.0"},
|
||||
"public_catalog": False,
|
||||
@@ -654,19 +870,20 @@ class ReleaseRunApiTests(unittest.TestCase):
|
||||
def test_api_rejects_tampered_record_without_rewriting_it(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir) / "runs"
|
||||
store = ReleaseRunStore(
|
||||
root,
|
||||
expected_workspace_fingerprint=WORKSPACE_FINGERPRINT,
|
||||
)
|
||||
run_id = store.create(
|
||||
input_snapshot=run_input(), plan_snapshot=release_plan()
|
||||
)["run_id"]
|
||||
path = root / f"{run_id}.json"
|
||||
path.write_text("{}", encoding="utf-8")
|
||||
malformed = path.read_bytes()
|
||||
app = create_app(
|
||||
workspace_root=Path(temp_dir), run_state_root=root, token="token"
|
||||
)
|
||||
run_id = create_run(
|
||||
app.state.release_runs,
|
||||
request_id="api-tamper-create-request-0001",
|
||||
input_snapshot=run_input(
|
||||
workspace_fingerprint=app.state.workspace_fingerprint
|
||||
),
|
||||
plan_snapshot=release_plan(),
|
||||
)["run_id"]
|
||||
path = app.state.release_runs.root / f"{run_id}.json"
|
||||
path.write_text("{}", encoding="utf-8")
|
||||
malformed = path.read_bytes()
|
||||
with TestClient(app) as client:
|
||||
response = client.get(
|
||||
f"/api/release-runs/{run_id}",
|
||||
@@ -712,7 +929,9 @@ class ReleaseRunApiTests(unittest.TestCase):
|
||||
TestClient(app) as client,
|
||||
):
|
||||
created = client.post(
|
||||
"/api/release-runs", headers=headers, json=run_input()
|
||||
"/api/release-runs",
|
||||
headers=headers,
|
||||
json=api_run_input(request_id="api-reconcile-create-0001"),
|
||||
).json()
|
||||
run_id = created["run_id"]
|
||||
app.state.release_runs.start_step(
|
||||
@@ -807,14 +1026,19 @@ class ReleaseRunApiTests(unittest.TestCase):
|
||||
response = client.post(
|
||||
"/api/release-runs",
|
||||
headers={"X-Release-Console-Token": "token"},
|
||||
json=run_input(),
|
||||
json=api_run_input(request_id="api-explicit-create-0001"),
|
||||
)
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertEqual(
|
||||
release_workspace_fingerprint(first),
|
||||
response.json()["immutable"]["input"]["workspace_fingerprint"],
|
||||
)
|
||||
self.assertEqual(explicit_root.resolve(), app.state.release_runs.root)
|
||||
self.assertEqual(
|
||||
explicit_root.resolve()
|
||||
/ f"workspace-{release_workspace_fingerprint(first)}"
|
||||
/ "release-runs",
|
||||
app.state.release_runs.root,
|
||||
)
|
||||
|
||||
def test_shared_explicit_root_does_not_cross_workspace_boundary(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
@@ -837,9 +1061,16 @@ class ReleaseRunApiTests(unittest.TestCase):
|
||||
TestClient(first_app) as client,
|
||||
):
|
||||
created = client.post(
|
||||
"/api/release-runs", headers=headers, json=run_input()
|
||||
"/api/release-runs",
|
||||
headers=headers,
|
||||
json=api_run_input(request_id="api-shared-create-0001"),
|
||||
).json()
|
||||
|
||||
first_record = (
|
||||
first_app.state.release_runs.root / f"{created['run_id']}.json"
|
||||
)
|
||||
first_record.write_text("{}", encoding="utf-8")
|
||||
|
||||
second_app = create_app(
|
||||
workspace_root=second, run_state_root=root, token="token"
|
||||
)
|
||||
@@ -850,6 +1081,10 @@ class ReleaseRunApiTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual([], listed.json()["runs"])
|
||||
self.assertEqual(404, loaded.status_code)
|
||||
self.assertNotEqual(
|
||||
first_app.state.release_runs.root,
|
||||
second_app.state.release_runs.root,
|
||||
)
|
||||
|
||||
def test_ui_and_runbook_state_tracking_boundary_are_explicit(self) -> None:
|
||||
webui = (RELEASE_ROOT / "webui" / "index.html").read_text(encoding="utf-8")
|
||||
@@ -866,13 +1101,163 @@ class ReleaseRunApiTests(unittest.TestCase):
|
||||
self.assertIn("effect_succeeded", webui)
|
||||
self.assertIn("renderReleaseRunStoreUnavailable", webui)
|
||||
self.assertIn('const runsRequest = api("/api/release-runs")', webui)
|
||||
self.assertIn("pendingReleaseRunPayload", webui)
|
||||
self.assertIn("recoverPendingReleaseRun", webui)
|
||||
self.assertIn("inFlightRunCommandId", webui)
|
||||
self.assertIn("Run saved; list refresh unavailable", webui)
|
||||
self.assertIn("This foundation tracks state only.", runbook)
|
||||
self.assertIn("effect_absent", runbook)
|
||||
self.assertIn("$XDG_STATE_HOME", runbook)
|
||||
self.assertIn("same-directory", runbook)
|
||||
self.assertIn("caller-generated `request_id`", runbook)
|
||||
self.assertIn("reserves the two command-ledger", runbook)
|
||||
|
||||
def test_ui_reuses_uncertain_ids_and_separates_saved_run_list_failure(
|
||||
self,
|
||||
) -> None:
|
||||
node = shutil.which("node")
|
||||
if node is None:
|
||||
self.skipTest("Node.js is required for the Release Console UI check")
|
||||
webui = (RELEASE_ROOT / "webui" / "index.html").read_text(encoding="utf-8")
|
||||
function_names = (
|
||||
"pendingReleaseRunPayload",
|
||||
"readPendingReleaseRun",
|
||||
"clearPendingReleaseRun",
|
||||
"recoverPendingReleaseRun",
|
||||
"submitReleaseRunCreate",
|
||||
"refreshReleaseRunListAfterCreate",
|
||||
"readInFlightRunCommands",
|
||||
"inFlightRunCommandId",
|
||||
"clearInFlightRunCommand",
|
||||
"requestId",
|
||||
)
|
||||
functions = "\n".join(
|
||||
javascript_function(webui, name) for name in function_names
|
||||
)
|
||||
script = (
|
||||
"""
|
||||
const assert = (condition, message) => { if (!condition) throw new Error(message); };
|
||||
const values = new Map();
|
||||
const sessionStorage = {
|
||||
getItem: (key) => values.has(key) ? values.get(key) : null,
|
||||
setItem: (key, value) => values.set(key, String(value)),
|
||||
removeItem: (key) => values.delete(key),
|
||||
};
|
||||
let uuidSequence = 0;
|
||||
const window = { crypto: { randomUUID: () => `uuid-${++uuidSequence}` } };
|
||||
const pendingReleaseRunKey = "test-pending-create";
|
||||
const pendingRunCommandsKey = "test-pending-commands";
|
||||
const releaseState = { currentRun: null, runStoreAvailable: true };
|
||||
const elements = {
|
||||
runStatus: { textContent: "" },
|
||||
runOutput: {
|
||||
innerHTML: "",
|
||||
insertAdjacentHTML: (_position, html) => { elements.runOutput.innerHTML = html + elements.runOutput.innerHTML; },
|
||||
},
|
||||
releaseRun: { value: "" },
|
||||
};
|
||||
const pill = (label) => label;
|
||||
const escapeHtml = (value) => String(value);
|
||||
const restoreReleaseRunSelection = () => {};
|
||||
const renderReleaseRun = (run) => { elements.runOutput.innerHTML = `RUN:${run.run_id}`; };
|
||||
const renderReleaseRunList = () => {};
|
||||
let failCreate = true;
|
||||
let failList = true;
|
||||
const postedRequestIds = [];
|
||||
async function postJson(_path, payload) {
|
||||
postedRequestIds.push(payload.request_id);
|
||||
if (failCreate) throw new Error("uncertain transport failure");
|
||||
return { run_id: "rr-known", immutable: { input: {} }, state: { steps: [] } };
|
||||
}
|
||||
async function api(_path) {
|
||||
if (failList) throw new Error("list refresh failed");
|
||||
return { runs: [] };
|
||||
}
|
||||
"""
|
||||
+ functions
|
||||
+ """
|
||||
(async () => {
|
||||
const input = { repo_versions: { "govoplan-files": "1.2.4", "govoplan-core": "1.2.3" }, channel: "stable" };
|
||||
const first = pendingReleaseRunPayload(input);
|
||||
const same = pendingReleaseRunPayload({ ...input, repo_versions: { "govoplan-core": "1.2.3", "govoplan-files": "1.2.4" } });
|
||||
assert(first.request_id === same.request_id, "same create input did not reuse its request ID");
|
||||
const uncertain = await submitReleaseRunCreate(first, { automatic: true });
|
||||
assert(uncertain === false, "uncertain create unexpectedly succeeded");
|
||||
assert(readPendingReleaseRun().payload.request_id === first.request_id, "uncertain create ID was cleared");
|
||||
|
||||
failCreate = false;
|
||||
const recovered = await recoverPendingReleaseRun();
|
||||
assert(recovered === true, "pending create did not recover");
|
||||
assert(postedRequestIds.length === 2 && postedRequestIds.every((value) => value === first.request_id), "create replay changed request ID");
|
||||
assert(readPendingReleaseRun() === null, "successful replay did not clear pending create");
|
||||
assert(elements.runOutput.innerHTML.includes("Run saved; list refresh unavailable"), "list failure did not preserve saved state");
|
||||
assert(!elements.runOutput.innerHTML.includes("Run creation failed"), "list failure was labeled as create failure");
|
||||
assert(releaseState.currentRun.run_id === "rr-known", "saved run was not retained");
|
||||
|
||||
const commandOne = inFlightRunCommandId("retry:rr-known:step", "retry");
|
||||
const commandReplay = inFlightRunCommandId("retry:rr-known:step", "retry");
|
||||
assert(commandOne === commandReplay, "uncertain command did not reuse its request ID");
|
||||
clearInFlightRunCommand("retry:rr-known:step", commandOne);
|
||||
const commandAfterSuccess = inFlightRunCommandId("retry:rr-known:step", "retry");
|
||||
assert(commandAfterSuccess !== commandOne, "successful command did not clear its request ID");
|
||||
})().catch((error) => { console.error(error.stack || error.message); process.exit(1); });
|
||||
"""
|
||||
)
|
||||
result = subprocess.run( # noqa: S603
|
||||
[node, "-e", script],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
|
||||
|
||||
def run_input() -> dict[str, object]:
|
||||
def create_run(
|
||||
store: ReleaseRunStore,
|
||||
*,
|
||||
request_id: str | None = None,
|
||||
input_snapshot: dict[str, object] | None = None,
|
||||
plan_snapshot: dict[str, object] | None = None,
|
||||
) -> dict[str, object]:
|
||||
return store.create(
|
||||
request_id=request_id
|
||||
or f"test-create-request-{next(CREATE_REQUESTS):06d}",
|
||||
input_snapshot=input_snapshot if input_snapshot is not None else run_input(),
|
||||
plan_snapshot=plan_snapshot if plan_snapshot is not None else release_plan(),
|
||||
)
|
||||
|
||||
|
||||
def api_run_input(
|
||||
*, request_id: str | None = None, **overrides: object
|
||||
) -> dict[str, object]:
|
||||
payload = run_input()
|
||||
payload.pop("workspace_fingerprint")
|
||||
payload["request_id"] = request_id or (
|
||||
f"api-create-request-{next(CREATE_REQUESTS):06d}"
|
||||
)
|
||||
payload.update(overrides)
|
||||
return payload
|
||||
|
||||
|
||||
def javascript_function(source: str, name: str) -> str:
|
||||
match = re.search(
|
||||
rf"\n\s+(?:async\s+)?function\s+{re.escape(name)}\s*\(", source
|
||||
)
|
||||
if match is None:
|
||||
raise AssertionError(f"JavaScript function {name} was not found")
|
||||
following = re.search(
|
||||
r"\n\s+(?:async\s+)?function\s+[A-Za-z_$]",
|
||||
source[match.end() :],
|
||||
)
|
||||
if following is None:
|
||||
raise AssertionError(f"JavaScript function after {name} was not found")
|
||||
end = match.end() + following.start()
|
||||
return source[match.start() : end].strip()
|
||||
|
||||
|
||||
def run_input(
|
||||
*, workspace_fingerprint: str = WORKSPACE_FINGERPRINT
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"channel": "stable",
|
||||
"repo_versions": {
|
||||
@@ -883,7 +1268,7 @@ def run_input() -> dict[str, object]:
|
||||
"remote_tags": False,
|
||||
"public_catalog": False,
|
||||
"include_migrations": True,
|
||||
"workspace_fingerprint": WORKSPACE_FINGERPRINT,
|
||||
"workspace_fingerprint": workspace_fingerprint,
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user