| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213 |
- from __future__ import annotations
- import asyncio
- from types import SimpleNamespace
- import pytest
- from script_build_host.application.mission_service import (
- BuildTransitionGate,
- DirectionReconciler,
- ScriptMissionService,
- StartScriptBuildCommand,
- )
- from script_build_host.domain.errors import InputRelationMismatch, ProtocolViolation
- from script_build_host.domain.records import BuildStatus, Principal
- class _RejectingInputs:
- async def validate_source(self, **_: object) -> None:
- raise InputRelationMismatch()
- class _SourceAuthorizer:
- async def require_source_access(self, *_args: object, **_kwargs: object) -> None:
- return None
- async def require_access(self, *_args: object, **_kwargs: object) -> None:
- return None
- class _LegacyState:
- def __init__(self) -> None:
- self.created = 0
- async def create(self, **_: object) -> int:
- self.created += 1
- return 1
- @pytest.mark.asyncio
- async def test_relation_mismatch_produces_no_build_or_binding_write() -> None:
- legacy = _LegacyState()
- bindings = SimpleNamespace(created=0)
- service = ScriptMissionService(
- runner=SimpleNamespace(),
- coordinator=SimpleNamespace(),
- factory=SimpleNamespace(),
- input_snapshots=_RejectingInputs(), # type: ignore[arg-type]
- bindings=bindings,
- legacy_state=legacy, # type: ignore[arg-type]
- authorizer=_SourceAuthorizer(),
- direction_reconciler=SimpleNamespace(),
- )
- with pytest.raises(InputRelationMismatch):
- await service.start(
- StartScriptBuildCommand(
- execution_id=1,
- topic_build_id=2,
- topic_id=3,
- principal=Principal("tester"),
- )
- )
- assert legacy.created == 0
- assert bindings.created == 0
- @pytest.mark.asyncio
- async def test_source_authorization_runs_before_input_or_build_access() -> None:
- legacy = _LegacyState()
- inputs = SimpleNamespace(validated=False)
- class Denied:
- async def require_source_access(self, *_args: object, **_kwargs: object) -> None:
- raise PermissionError("source forbidden")
- service = ScriptMissionService(
- runner=SimpleNamespace(),
- coordinator=SimpleNamespace(),
- factory=SimpleNamespace(),
- input_snapshots=inputs,
- bindings=SimpleNamespace(),
- legacy_state=legacy, # type: ignore[arg-type]
- authorizer=Denied(), # type: ignore[arg-type]
- direction_reconciler=SimpleNamespace(),
- )
- with pytest.raises(PermissionError, match="source forbidden"):
- await service.start(StartScriptBuildCommand(1, 2, 3, Principal("intruder")))
- assert legacy.created == 0
- assert inputs.validated is False
- class _StopState:
- def __init__(self, status: BuildStatus) -> None:
- self.status = status
- async def get_status(self, _build: int) -> BuildStatus:
- return self.status
- async def set_status(self, _build: int, status: BuildStatus, **_: object) -> None:
- self.status = status
- @pytest.mark.asyncio
- async def test_stop_waits_for_planner_even_when_there_are_no_operations() -> None:
- state = _StopState(BuildStatus.RUNNING)
- pending: asyncio.Future[None] = asyncio.get_running_loop().create_future()
- trace = SimpleNamespace(status="running")
- runner = SimpleNamespace(
- stop=lambda _root: _async_value(True),
- trace_store=SimpleNamespace(get_trace=lambda _root: _async_value(trace)),
- )
- coordinator = SimpleNamespace(
- task_store=SimpleNamespace(load=lambda _root: _async_value(SimpleNamespace(operations={})))
- )
- service = ScriptMissionService(
- runner=runner,
- coordinator=coordinator,
- factory=SimpleNamespace(),
- input_snapshots=SimpleNamespace(),
- bindings=SimpleNamespace(
- get_by_build=lambda _build: _async_value(SimpleNamespace(root_trace_id="root"))
- ),
- legacy_state=state,
- authorizer=SimpleNamespace(require_access=lambda _principal, _build: _async_value(None)),
- direction_reconciler=SimpleNamespace(),
- stop_timeout_seconds=0.01,
- )
- service._runs[7] = pending # type: ignore[assignment]
- result = await service.stop(7, Principal("owner"))
- assert result.status == BuildStatus.STOPPING
- assert state.status == BuildStatus.STOPPING
- pending.cancel()
- @pytest.mark.asyncio
- async def test_stop_is_idempotent_and_publication_is_gated_by_durable_status() -> None:
- state = _StopState(BuildStatus.STOPPED)
- service = ScriptMissionService(
- runner=SimpleNamespace(),
- coordinator=SimpleNamespace(),
- factory=SimpleNamespace(),
- input_snapshots=SimpleNamespace(),
- bindings=SimpleNamespace(),
- legacy_state=state,
- authorizer=SimpleNamespace(require_access=lambda _principal, _build: _async_value(None)),
- direction_reconciler=SimpleNamespace(),
- )
- result = await service.stop(7, Principal("owner"))
- assert result.status == BuildStatus.STOPPED
- state.status = BuildStatus.STOPPING
- publications = SimpleNamespace(prepared=False)
- reconciler = DirectionReconciler(
- coordinator=SimpleNamespace(),
- bindings=SimpleNamespace(),
- artifacts=SimpleNamespace(),
- publications=publications,
- legacy_state=state,
- )
- with pytest.raises(ProtocolViolation, match="forbidden while stopping"):
- await reconciler.reconcile(7, "root")
- assert publications.prepared is False
- @pytest.mark.asyncio
- async def test_stop_intent_and_direction_reconcile_are_serialized() -> None:
- gate = BuildTransitionGate()
- entered = asyncio.Event()
- release = asyncio.Event()
- state = _StopState(BuildStatus.RUNNING)
- class BlockingReconciler:
- transition_gate = gate
- async def reconcile(self, script_build_id: int, _root: str) -> int:
- async with gate.hold(script_build_id):
- entered.set()
- await release.wait()
- assert state.status is BuildStatus.RUNNING
- return 1
- runner = SimpleNamespace(
- stop=lambda _root: _async_value(True),
- trace_store=SimpleNamespace(get_trace=lambda _root: _async_value(None)),
- )
- coordinator = SimpleNamespace(
- task_store=SimpleNamespace(load=lambda _root: _async_value(SimpleNamespace(operations={})))
- )
- service = ScriptMissionService(
- runner=runner,
- coordinator=coordinator,
- factory=SimpleNamespace(),
- input_snapshots=SimpleNamespace(),
- bindings=SimpleNamespace(
- get_by_build=lambda _build: _async_value(SimpleNamespace(root_trace_id="root"))
- ),
- legacy_state=state,
- authorizer=SimpleNamespace(require_access=lambda _principal, _build: _async_value(None)),
- direction_reconciler=BlockingReconciler(), # type: ignore[arg-type]
- transition_gate=gate,
- )
- reconcile = asyncio.create_task(service.direction_reconciler.reconcile(7, "root"))
- await entered.wait()
- stop = asyncio.create_task(service.stop(7, Principal("owner")))
- await asyncio.sleep(0)
- assert state.status is BuildStatus.RUNNING
- release.set()
- assert await reconcile == 1
- assert (await stop).status is BuildStatus.STOPPED
- async def _async_value(value):
- return value
|