feat(observatory): seal blocked run preparations
This commit is contained in:
@@ -3,10 +3,13 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from k1link.laboratory.m49_tgs_full_shadow import (
|
||||
M49TgsFullShadowResult,
|
||||
@@ -34,6 +37,49 @@ ARTIFACT = importlib.util.module_from_spec(ARTIFACT_SPEC)
|
||||
ARTIFACT_SPEC.loader.exec_module(ARTIFACT)
|
||||
|
||||
|
||||
def _create_artifact_git_repository(root: Path) -> tuple[str, dict[Path, bytes]]:
|
||||
subprocess.run(["git", "init", "-q", str(root)], check=True, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "config", "user.email", "mission-core-test@example.invalid"],
|
||||
cwd=root,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "config", "user.name", "Mission Core Test"],
|
||||
cwd=root,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
committed: dict[Path, bytes] = {}
|
||||
for index, relative in enumerate(ARTIFACT.SOURCES):
|
||||
payload = f"committed source {index}: {relative.as_posix()}\n".encode()
|
||||
path = root / relative
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(payload)
|
||||
committed[relative] = payload
|
||||
subprocess.run(
|
||||
["git", "add", "--", *ARTIFACT.SOURCES],
|
||||
cwd=root,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "commit", "-q", "-m", "fixture"],
|
||||
cwd=root,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
revision = subprocess.run(
|
||||
["git", "rev-parse", "HEAD"],
|
||||
cwd=root,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout.strip()
|
||||
return revision, committed
|
||||
|
||||
|
||||
def test_full_shadow_exact_multiset_and_costmap_priority() -> None:
|
||||
native = np.asarray(
|
||||
[[2.0, 0.0, 0.0, 0.0], [2.0, 0.0, 0.0, 0.0], [3.0, 0.0, 1.0, 0.0], [4.0, 0.0, 2.0, 0.0]],
|
||||
@@ -47,11 +93,10 @@ def test_full_shadow_exact_multiset_and_costmap_priority() -> None:
|
||||
|
||||
|
||||
def test_full_shadow_worker_artifact_is_deterministic_and_cpu_only(tmp_path: Path) -> None:
|
||||
revision = "a" * 40
|
||||
revision = ARTIFACT.git_revision()
|
||||
first = ARTIFACT.build("m49-tgs-full-test", tmp_path / "one", revision=revision)
|
||||
second = ARTIFACT.build("m49-tgs-full-test", tmp_path / "two", revision=revision)
|
||||
assert first["sha256"] == second["sha256"]
|
||||
import tarfile
|
||||
|
||||
with tarfile.open(first["artifact"], "r:gz") as archive:
|
||||
release = archive.extractfile("payload/release.json")
|
||||
@@ -64,6 +109,67 @@ def test_full_shadow_worker_artifact_is_deterministic_and_cpu_only(tmp_path: Pat
|
||||
assert "gpu_requested = $false" in runner_text
|
||||
|
||||
|
||||
def test_full_shadow_explicit_revision_ignores_current_file_drift(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
repository = tmp_path / "repository"
|
||||
revision, committed = _create_artifact_git_repository(repository)
|
||||
drifted_source = ARTIFACT.SOURCES[0]
|
||||
(repository / drifted_source).write_bytes(b"dirty current-worktree replacement\n")
|
||||
monkeypatch.setattr(ARTIFACT, "REPOSITORY_ROOT", repository)
|
||||
|
||||
explicit = ARTIFACT.build(
|
||||
"m49-tgs-full-clean-revision",
|
||||
tmp_path / "explicit",
|
||||
revision=revision,
|
||||
)
|
||||
default_head = ARTIFACT.build("m49-tgs-full-clean-revision", tmp_path / "default")
|
||||
|
||||
assert explicit["code_revision"] == revision
|
||||
assert default_head["code_revision"] == revision
|
||||
assert explicit["sha256"] == default_head["sha256"]
|
||||
with tarfile.open(explicit["artifact"], "r:gz") as archive:
|
||||
source = archive.extractfile(f"payload/{drifted_source.name}")
|
||||
release_stream = archive.extractfile("payload/release.json")
|
||||
assert source is not None and release_stream is not None
|
||||
assert source.read() == committed[drifted_source]
|
||||
release = json.loads(release_stream.read())
|
||||
assert release["code_revision"] == revision
|
||||
assert release["files"][drifted_source.name]["sha256"] == hashlib.sha256(
|
||||
committed[drifted_source]
|
||||
).hexdigest()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("revision", ["HEAD", "../HEAD", "--all", "A" * 40])
|
||||
def test_full_shadow_artifact_rejects_unsafe_or_symbolic_revision(
|
||||
revision: str,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
with pytest.raises(ARTIFACT.ArtifactBuildError, match="artifact revision is invalid"):
|
||||
ARTIFACT.build("m49-tgs-full-invalid-revision", tmp_path, revision=revision)
|
||||
|
||||
|
||||
def test_full_shadow_artifact_rejects_missing_revision(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
repository = tmp_path / "repository"
|
||||
revision, _ = _create_artifact_git_repository(repository)
|
||||
missing_revision = "f" * 40 if revision != "f" * 40 else "e" * 40
|
||||
monkeypatch.setattr(ARTIFACT, "REPOSITORY_ROOT", repository)
|
||||
|
||||
with pytest.raises(
|
||||
ARTIFACT.ArtifactBuildError,
|
||||
match="artifact revision does not identify an existing commit",
|
||||
):
|
||||
ARTIFACT.build(
|
||||
"m49-tgs-full-missing-revision",
|
||||
tmp_path / "artifact",
|
||||
revision=missing_revision,
|
||||
)
|
||||
|
||||
|
||||
def test_full_shadow_seal_binds_visual_and_semantic_timelines(tmp_path: Path) -> None:
|
||||
source = tmp_path / "worker"
|
||||
source.mkdir()
|
||||
|
||||
@@ -0,0 +1,615 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from threading import Barrier
|
||||
from time import monotonic
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from k1link.observatory import (
|
||||
MAX_RUN_PREPARATION_RECORDS,
|
||||
RUN_PREPARATION_DATABASE_NAME,
|
||||
LaboratorySetupRegistry,
|
||||
ObservatoryRunPreparationCapacityError,
|
||||
ObservatoryRunPreparationConflictError,
|
||||
ObservatoryRunPreparationIntegrityError,
|
||||
ObservatoryRunPreparationIntent,
|
||||
ObservatoryRunPreparationLedger,
|
||||
load_observatory_run_preparation_ledger,
|
||||
)
|
||||
from k1link.observatory import run_preparations as run_preparations_module
|
||||
from k1link.sessions import SessionNotFoundError
|
||||
from k1link.sessions.models import SessionSummary
|
||||
from k1link.web.observatory_api import build_observatory_router
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
|
||||
REGISTRY_PATH = REPOSITORY_ROOT / "config" / "observatory-laboratory-setups.json"
|
||||
SOURCE_SESSION_ID = "20260720T065719Z_viewer_live"
|
||||
SOURCE_LABEL = "RAVNOVES00"
|
||||
CURRENT_SOURCE_SESSION_ID = "20260828T130511Z_viewer_live"
|
||||
SNAPSHOT_A = "a" * 64
|
||||
SNAPSHOT_B = "b" * 64
|
||||
|
||||
|
||||
def _registry() -> LaboratorySetupRegistry:
|
||||
return LaboratorySetupRegistry.from_file(
|
||||
REGISTRY_PATH,
|
||||
repository_root=REPOSITORY_ROOT,
|
||||
)
|
||||
|
||||
|
||||
def _source(
|
||||
session_id: str = SOURCE_SESSION_ID,
|
||||
label: str = SOURCE_LABEL,
|
||||
) -> SessionSummary:
|
||||
return SessionSummary(
|
||||
session_id=session_id,
|
||||
display_name=label,
|
||||
status="ready",
|
||||
started_at_utc="2026-07-20T06:57:19Z",
|
||||
completed_at_utc="2026-07-20T07:10:47Z",
|
||||
duration_seconds=808.0,
|
||||
modalities=("point-cloud", "trajectory", "video"),
|
||||
source_count=3,
|
||||
total_bytes=1,
|
||||
replayable=True,
|
||||
origin="recorded",
|
||||
)
|
||||
|
||||
|
||||
def _definition() -> dict[str, object]:
|
||||
setup = _registry().catalog(_source())["setups"][0]
|
||||
assert isinstance(setup, dict)
|
||||
definition = setup["run_definition"]
|
||||
assert isinstance(definition, dict)
|
||||
return definition
|
||||
|
||||
|
||||
def _intent(
|
||||
*,
|
||||
idempotency_key: str = "operator-request-001",
|
||||
source_catalog_sha256: str = SNAPSHOT_A,
|
||||
) -> ObservatoryRunPreparationIntent:
|
||||
definition = _definition()
|
||||
return ObservatoryRunPreparationIntent(
|
||||
idempotency_key=idempotency_key,
|
||||
source_session_id=SOURCE_SESSION_ID,
|
||||
source_catalog_sha256=source_catalog_sha256,
|
||||
setup_id="m49-tgs-full-shadow-v1",
|
||||
definition_id=str(definition["definition_id"]),
|
||||
definition_version=int(definition["version"]),
|
||||
definition_sha256=str(definition["definition_sha256"]),
|
||||
blocker_code="laboratory-runner-adapter-not-installed",
|
||||
blocker_message=(
|
||||
"Повторный запуск этого сетапа ещё не подключён к общему контуру расчёта."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class _ReadOnlySourceStore:
|
||||
def __init__(self, source: SessionSummary | None = None) -> None:
|
||||
resolved = source or _source()
|
||||
self.sessions = {resolved.session_id: resolved}
|
||||
self.snapshot_sha256 = SNAPSHOT_A
|
||||
self.snapshot_reads = 0
|
||||
|
||||
def get_session(self, session_id: str):
|
||||
try:
|
||||
summary = self.sessions[session_id]
|
||||
except KeyError as exc:
|
||||
raise SessionNotFoundError(session_id) from exc
|
||||
return SimpleNamespace(summary=summary)
|
||||
|
||||
def get_session_with_catalog_snapshot(self, session_id: str):
|
||||
self.snapshot_reads += 1
|
||||
return self.get_session(session_id), self.snapshot_sha256
|
||||
|
||||
|
||||
def _client(
|
||||
tmp_path: Path,
|
||||
*,
|
||||
store: _ReadOnlySourceStore | None = None,
|
||||
) -> tuple[TestClient, ObservatoryRunPreparationLedger, _ReadOnlySourceStore]:
|
||||
source_store = store or _ReadOnlySourceStore()
|
||||
ledger = ObservatoryRunPreparationLedger(
|
||||
tmp_path / "mission-core",
|
||||
clock=lambda: "2026-08-30T20:00:00.000Z",
|
||||
)
|
||||
app = FastAPI()
|
||||
app.include_router(
|
||||
build_observatory_router(
|
||||
source_store, # type: ignore[arg-type]
|
||||
setup_registry=_registry(),
|
||||
run_preparation_ledger=ledger,
|
||||
)
|
||||
)
|
||||
return TestClient(app), ledger, source_store
|
||||
|
||||
|
||||
def _request(
|
||||
*,
|
||||
idempotency_key: str = "operator-request-001",
|
||||
definition_sha256: str | None = None,
|
||||
) -> dict[str, object]:
|
||||
definition = _definition()
|
||||
return {
|
||||
"schema_version": "missioncore.observatory-run-preparation-request/v1",
|
||||
"idempotency_key": idempotency_key,
|
||||
"source_session_id": SOURCE_SESSION_ID,
|
||||
"setup_id": "m49-tgs-full-shadow-v1",
|
||||
"definition_sha256": definition_sha256 or definition["definition_sha256"],
|
||||
}
|
||||
|
||||
|
||||
def _create_custom_ledger_schema(
|
||||
data_dir: Path,
|
||||
*,
|
||||
idempotency_declaration: str,
|
||||
extra_sql: str = "",
|
||||
) -> None:
|
||||
data_dir.mkdir(exist_ok=True)
|
||||
with sqlite3.connect(data_dir / RUN_PREPARATION_DATABASE_NAME) as connection:
|
||||
connection.executescript(
|
||||
f"""
|
||||
CREATE TABLE observatory_run_preparations (
|
||||
preparation_id TEXT PRIMARY KEY,
|
||||
idempotency_key {idempotency_declaration},
|
||||
request_sha256 TEXT NOT NULL,
|
||||
receipt_sha256 TEXT NOT NULL,
|
||||
source_session_id TEXT NOT NULL,
|
||||
source_catalog_sha256 TEXT NOT NULL,
|
||||
setup_id TEXT NOT NULL,
|
||||
definition_id TEXT NOT NULL,
|
||||
definition_version INTEGER NOT NULL CHECK (definition_version > 0),
|
||||
definition_sha256 TEXT NOT NULL,
|
||||
state TEXT NOT NULL CHECK (state = 'blocked'),
|
||||
blocker_code TEXT NOT NULL,
|
||||
blocker_message TEXT NOT NULL,
|
||||
created_at_utc TEXT NOT NULL
|
||||
);
|
||||
{extra_sql}
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def test_preparation_ledger_is_durable_and_exactly_idempotent(tmp_path: Path) -> None:
|
||||
data_dir = tmp_path / "mission-core"
|
||||
ledger = ObservatoryRunPreparationLedger(
|
||||
data_dir,
|
||||
clock=lambda: "2026-08-30T20:00:00.000Z",
|
||||
)
|
||||
|
||||
first, first_created = ledger.prepare(_intent())
|
||||
second, second_created = ledger.prepare(_intent())
|
||||
restored = ObservatoryRunPreparationLedger(data_dir).get(first.preparation_id)
|
||||
restored_by_key = ObservatoryRunPreparationLedger(data_dir).get_by_idempotency_key(
|
||||
first.idempotency_key
|
||||
)
|
||||
|
||||
assert first_created is True
|
||||
assert second_created is False
|
||||
assert second == first
|
||||
assert restored == first
|
||||
assert restored_by_key == first
|
||||
assert first.state == "blocked"
|
||||
assert first.as_dict()["run_id"] is None
|
||||
assert first.as_dict()["dispatch_receipt"] is None
|
||||
assert first.as_dict()["submission_allowed"] is False
|
||||
assert first.as_dict()["receipt_sha256"] == first.receipt_sha256
|
||||
with sqlite3.connect(data_dir / RUN_PREPARATION_DATABASE_NAME) as connection:
|
||||
count = connection.execute(
|
||||
"SELECT COUNT(*) FROM observatory_run_preparations"
|
||||
).fetchone()[0]
|
||||
assert count == 1
|
||||
assert (data_dir / RUN_PREPARATION_DATABASE_NAME).stat().st_mode & 0o777 == 0o600
|
||||
|
||||
|
||||
def test_preparation_idempotency_key_replays_original_resolved_source_identity(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
ledger = ObservatoryRunPreparationLedger(tmp_path)
|
||||
first, _created = ledger.prepare(_intent())
|
||||
|
||||
replayed, replay_created = ledger.prepare(
|
||||
_intent(source_catalog_sha256=SNAPSHOT_B)
|
||||
)
|
||||
|
||||
assert replay_created is False
|
||||
assert replayed == first
|
||||
assert replayed.source_catalog_sha256 == SNAPSHOT_A
|
||||
|
||||
with pytest.raises(ObservatoryRunPreparationConflictError, match="idempotency"):
|
||||
ledger.prepare(replace(_intent(), definition_sha256="c" * 64))
|
||||
|
||||
|
||||
def test_preparation_ledger_detects_stored_identity_drift(tmp_path: Path) -> None:
|
||||
ledger = ObservatoryRunPreparationLedger(tmp_path)
|
||||
record, _created = ledger.prepare(_intent())
|
||||
with sqlite3.connect(ledger.database_path) as connection:
|
||||
connection.execute(
|
||||
"UPDATE observatory_run_preparations SET source_catalog_sha256 = ?",
|
||||
(SNAPSHOT_B,),
|
||||
)
|
||||
connection.commit()
|
||||
|
||||
with pytest.raises(
|
||||
ObservatoryRunPreparationIntegrityError,
|
||||
match="receipt fingerprint",
|
||||
):
|
||||
ledger.get(record.preparation_id)
|
||||
|
||||
|
||||
def test_preparation_ledger_rejects_an_incompatible_existing_schema(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
tmp_path.mkdir(exist_ok=True)
|
||||
with sqlite3.connect(tmp_path / RUN_PREPARATION_DATABASE_NAME) as connection:
|
||||
connection.execute(
|
||||
"CREATE TABLE observatory_run_preparations (preparation_id TEXT)"
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
ObservatoryRunPreparationIntegrityError,
|
||||
match="initialization|schema",
|
||||
):
|
||||
ObservatoryRunPreparationLedger(tmp_path)
|
||||
|
||||
|
||||
def test_preparation_ledger_rejects_partial_idempotency_uniqueness(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_create_custom_ledger_schema(
|
||||
tmp_path,
|
||||
idempotency_declaration="TEXT NOT NULL",
|
||||
extra_sql=(
|
||||
"CREATE UNIQUE INDEX partial_idempotency "
|
||||
"ON observatory_run_preparations(idempotency_key) "
|
||||
"WHERE state = 'blocked';"
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(ObservatoryRunPreparationIntegrityError, match="uniqueness"):
|
||||
ObservatoryRunPreparationLedger(tmp_path)
|
||||
|
||||
|
||||
def test_preparation_ledger_rejects_non_binary_idempotency_collation(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_create_custom_ledger_schema(
|
||||
tmp_path,
|
||||
idempotency_declaration="TEXT COLLATE NOCASE NOT NULL UNIQUE",
|
||||
)
|
||||
|
||||
with pytest.raises(ObservatoryRunPreparationIntegrityError, match="uniqueness"):
|
||||
ObservatoryRunPreparationLedger(tmp_path)
|
||||
|
||||
|
||||
def test_preparation_ledger_enforces_record_quota_but_keeps_retries_available(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
ledger = ObservatoryRunPreparationLedger(tmp_path, max_records=1)
|
||||
first, _created = ledger.prepare(_intent())
|
||||
|
||||
replayed, replay_created = ledger.prepare(_intent())
|
||||
assert replay_created is False
|
||||
assert replayed == first
|
||||
with pytest.raises(ObservatoryRunPreparationCapacityError, match="quota"):
|
||||
ledger.prepare(_intent(idempotency_key="operator-request-002"))
|
||||
|
||||
|
||||
def test_preparation_ledger_concurrent_retry_commits_one_receipt(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
first_ledger = ObservatoryRunPreparationLedger(tmp_path)
|
||||
second_ledger = ObservatoryRunPreparationLedger(tmp_path)
|
||||
barrier = Barrier(2)
|
||||
|
||||
def prepare(
|
||||
ledger: ObservatoryRunPreparationLedger,
|
||||
snapshot_sha256: str,
|
||||
) -> tuple[str, str, bool]:
|
||||
barrier.wait()
|
||||
record, created = ledger.prepare(
|
||||
_intent(source_catalog_sha256=snapshot_sha256)
|
||||
)
|
||||
return record.preparation_id, record.source_catalog_sha256, created
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
first_future = executor.submit(prepare, first_ledger, SNAPSHOT_A)
|
||||
second_future = executor.submit(prepare, second_ledger, SNAPSHOT_B)
|
||||
first = first_future.result(timeout=10)
|
||||
second = second_future.result(timeout=10)
|
||||
|
||||
assert first[0] == second[0]
|
||||
assert first[1] == second[1]
|
||||
assert sorted((first[2], second[2])) == [False, True]
|
||||
with sqlite3.connect(first_ledger.database_path) as connection:
|
||||
assert connection.execute(
|
||||
"SELECT COUNT(*) FROM observatory_run_preparations"
|
||||
).fetchone()[0] == 1
|
||||
|
||||
|
||||
def test_preparation_ledger_startup_uses_only_bounded_admission_queries(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
statements: list[str] = []
|
||||
connect = sqlite3.connect
|
||||
|
||||
def traced_connect(*args: object, **kwargs: object) -> sqlite3.Connection:
|
||||
connection = connect(*args, **kwargs)
|
||||
connection.set_trace_callback(statements.append)
|
||||
return connection
|
||||
|
||||
monkeypatch.setattr(run_preparations_module.sqlite3, "connect", traced_connect)
|
||||
|
||||
ObservatoryRunPreparationLedger(tmp_path)
|
||||
|
||||
normalized = [statement.lower() for statement in statements]
|
||||
assert not any("quick_check" in statement for statement in normalized)
|
||||
quota_queries = [
|
||||
statement
|
||||
for statement in normalized
|
||||
if "select 1 from observatory_run_preparations limit" in statement
|
||||
]
|
||||
assert len(quota_queries) == 1
|
||||
assert str(MAX_RUN_PREPARATION_RECORDS + 1) in quota_queries[0]
|
||||
|
||||
|
||||
def test_optional_ledger_loader_isolates_startup_from_ledger_corruption(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_create_custom_ledger_schema(
|
||||
tmp_path,
|
||||
idempotency_declaration="TEXT NOT NULL",
|
||||
)
|
||||
|
||||
ledger, error = load_observatory_run_preparation_ledger(tmp_path)
|
||||
|
||||
assert ledger is None
|
||||
assert error is not None
|
||||
|
||||
|
||||
def test_optional_ledger_loader_has_a_short_database_lock_budget(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
ledger = ObservatoryRunPreparationLedger(tmp_path)
|
||||
locked = sqlite3.connect(ledger.database_path, timeout=0)
|
||||
locked.execute("PRAGMA journal_mode = DELETE")
|
||||
locked.execute("BEGIN EXCLUSIVE")
|
||||
try:
|
||||
started = monotonic()
|
||||
loaded, error = load_observatory_run_preparation_ledger(tmp_path)
|
||||
elapsed = monotonic() - started
|
||||
finally:
|
||||
locked.rollback()
|
||||
locked.close()
|
||||
|
||||
assert loaded is None
|
||||
assert error is not None
|
||||
assert elapsed < 0.5
|
||||
|
||||
|
||||
def test_preparation_endpoint_records_only_a_blocked_non_run_receipt(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
client, ledger, source_store = _client(tmp_path)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/observatory/run-preparations",
|
||||
json=_request(),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
document = response.json()
|
||||
assert document == ledger.get(document["preparation_id"]).as_dict()
|
||||
assert document["schema_version"] == "missioncore.observatory-run-preparation/v1"
|
||||
assert document["run_id"] is None
|
||||
assert document["state"] == "blocked"
|
||||
assert document["preconditions_passed"] is True
|
||||
assert document["submission_allowed"] is False
|
||||
assert document["dispatch_receipt"] is None
|
||||
assert document["source"] == {
|
||||
"session_id": SOURCE_SESSION_ID,
|
||||
"catalog_sha256": SNAPSHOT_A,
|
||||
}
|
||||
assert document["setup"]["definition"]["definition_sha256"] == (
|
||||
_definition()["definition_sha256"]
|
||||
)
|
||||
assert document["blocker"]["reason_code"] == (
|
||||
"laboratory-runner-adapter-not-installed"
|
||||
)
|
||||
assert document["authority"] == {
|
||||
"commands_enabled": False,
|
||||
"actuation_allowed": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
"production_accepted": False,
|
||||
}
|
||||
assert source_store.snapshot_reads == 1
|
||||
assert client.post("/api/v1/observatory/runs", json={}).status_code == 404
|
||||
|
||||
|
||||
def test_preparation_endpoint_duplicate_returns_the_same_durable_receipt(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
client, _ledger, _source_store = _client(tmp_path)
|
||||
|
||||
first = client.post("/api/v1/observatory/run-preparations", json=_request())
|
||||
second = client.post("/api/v1/observatory/run-preparations", json=_request())
|
||||
restored = client.get(
|
||||
f"/api/v1/observatory/run-preparations/{first.json()['preparation_id']}"
|
||||
)
|
||||
|
||||
assert first.status_code == second.status_code == restored.status_code == 200
|
||||
assert second.json() == first.json()
|
||||
assert restored.json() == first.json()
|
||||
|
||||
|
||||
def test_preparation_endpoint_exact_retry_precedes_mutable_source_and_catalog_checks(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
client, _ledger, source_store = _client(tmp_path)
|
||||
first = client.post(
|
||||
"/api/v1/observatory/run-preparations",
|
||||
json=_request(),
|
||||
)
|
||||
assert first.status_code == 200
|
||||
|
||||
source_store.snapshot_sha256 = SNAPSHOT_B
|
||||
source_store.sessions.clear()
|
||||
replayed = client.post(
|
||||
"/api/v1/observatory/run-preparations",
|
||||
json=_request(),
|
||||
)
|
||||
|
||||
assert replayed.status_code == 200
|
||||
assert replayed.json() == first.json()
|
||||
assert replayed.json()["source"]["catalog_sha256"] == SNAPSHOT_A
|
||||
assert source_store.snapshot_reads == 1
|
||||
|
||||
|
||||
def test_preparation_endpoint_new_key_captures_a_new_source_snapshot(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
client, _ledger, source_store = _client(tmp_path)
|
||||
first = client.post(
|
||||
"/api/v1/observatory/run-preparations",
|
||||
json=_request(),
|
||||
)
|
||||
assert first.status_code == 200
|
||||
|
||||
source_store.snapshot_sha256 = SNAPSHOT_B
|
||||
second = client.post(
|
||||
"/api/v1/observatory/run-preparations",
|
||||
json=_request(idempotency_key="operator-request-002"),
|
||||
)
|
||||
|
||||
assert second.status_code == 200
|
||||
assert second.json()["preparation_id"] != first.json()["preparation_id"]
|
||||
assert second.json()["source"]["catalog_sha256"] == SNAPSHOT_B
|
||||
|
||||
|
||||
def test_preparation_endpoint_same_key_rejects_a_changed_client_payload_early(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
client, _ledger, source_store = _client(tmp_path)
|
||||
assert client.post(
|
||||
"/api/v1/observatory/run-preparations",
|
||||
json=_request(),
|
||||
).status_code == 200
|
||||
source_store.sessions.clear()
|
||||
|
||||
conflict = client.post(
|
||||
"/api/v1/observatory/run-preparations",
|
||||
json=_request(definition_sha256="f" * 64),
|
||||
)
|
||||
|
||||
assert conflict.status_code == 409
|
||||
assert source_store.snapshot_reads == 1
|
||||
|
||||
|
||||
def test_preparation_endpoint_maps_early_ledger_capacity_failure_to_503(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
client, _ledger, source_store = _client(tmp_path)
|
||||
monkeypatch.setattr(
|
||||
run_preparations_module,
|
||||
"MAX_RUN_PREPARATION_STORAGE_BYTES",
|
||||
0,
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/observatory/run-preparations",
|
||||
json=_request(),
|
||||
)
|
||||
|
||||
assert response.status_code == 503
|
||||
assert source_store.snapshot_reads == 0
|
||||
|
||||
|
||||
def test_preparation_endpoint_rejects_stale_incompatible_and_non_executable_inputs(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
client, _ledger, source_store = _client(tmp_path)
|
||||
|
||||
stale = client.post(
|
||||
"/api/v1/observatory/run-preparations",
|
||||
json=_request(definition_sha256="f" * 64),
|
||||
)
|
||||
assert stale.status_code == 409
|
||||
|
||||
source_store.sessions[SOURCE_SESSION_ID] = replace(
|
||||
_source(),
|
||||
replayable=False,
|
||||
)
|
||||
incompatible = client.post(
|
||||
"/api/v1/observatory/run-preparations",
|
||||
json=_request(idempotency_key="operator-request-002"),
|
||||
)
|
||||
assert incompatible.status_code == 409
|
||||
|
||||
current_store = _ReadOnlySourceStore(
|
||||
_source(CURRENT_SOURCE_SESSION_ID, "RAVNOVES004TREE")
|
||||
)
|
||||
current_client, _current_ledger, _ = _client(tmp_path / "current", store=current_store)
|
||||
no_definition = current_client.post(
|
||||
"/api/v1/observatory/run-preparations",
|
||||
json={
|
||||
"schema_version": "missioncore.observatory-run-preparation-request/v1",
|
||||
"idempotency_key": "operator-request-003",
|
||||
"source_session_id": CURRENT_SOURCE_SESSION_ID,
|
||||
"setup_id": "lab-v1-ravnoves004tree-final",
|
||||
"definition_sha256": "f" * 64,
|
||||
},
|
||||
)
|
||||
assert no_definition.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"request_patch",
|
||||
[
|
||||
{"idempotency_key": "../escape"},
|
||||
{"source_session_id": "../source"},
|
||||
{"setup_id": "../setup"},
|
||||
{"definition_sha256": "not-a-digest"},
|
||||
],
|
||||
)
|
||||
def test_preparation_endpoint_rejects_unsafe_request_fields(
|
||||
tmp_path: Path,
|
||||
request_patch: dict[str, object],
|
||||
) -> None:
|
||||
client, _ledger, _source_store = _client(tmp_path)
|
||||
document = {**_request(), **request_patch}
|
||||
|
||||
assert client.post(
|
||||
"/api/v1/observatory/run-preparations",
|
||||
json=document,
|
||||
).status_code == 422
|
||||
|
||||
|
||||
def test_preparation_endpoint_fails_closed_when_ledger_is_unavailable() -> None:
|
||||
app = FastAPI()
|
||||
app.include_router(
|
||||
build_observatory_router(
|
||||
_ReadOnlySourceStore(), # type: ignore[arg-type]
|
||||
setup_registry=_registry(),
|
||||
run_preparation_ledger_error="database corrupt",
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
assert client.post(
|
||||
"/api/v1/observatory/run-preparations",
|
||||
json=_request(),
|
||||
).status_code == 503
|
||||
assert client.get(
|
||||
"/api/v1/observatory/run-preparations/"
|
||||
"observatory-prep-00000000000000000000000000000000"
|
||||
).status_code == 503
|
||||
Reference in New Issue
Block a user