From 014dfea97b9c7a40c9a38d7b8e2c141bde4ef319 Mon Sep 17 00:00:00 2001 From: Blake Ellis Date: Tue, 1 Sep 2026 14:30:25 -0400 Subject: [PATCH 01/12] Add failing tests for service Prefect identity and the legacy-state preflight Seat: lead-developer Assignment: single-product-reconciliation Co-Authored-By: Claude Opus 5 --- tests/preview/test_preview_legacy_state.py | 221 +++++++++++++++++++++ tests/service/test_runtime_identity.py | 76 +++++++ 2 files changed, 297 insertions(+) create mode 100644 tests/preview/test_preview_legacy_state.py create mode 100644 tests/service/test_runtime_identity.py diff --git a/tests/preview/test_preview_legacy_state.py b/tests/preview/test_preview_legacy_state.py new file mode 100644 index 00000000..d278ec07 --- /dev/null +++ b/tests/preview/test_preview_legacy_state.py @@ -0,0 +1,221 @@ +"""The preview refuses retired-vocabulary state and resets it destructively.""" + +from __future__ import annotations + +import json +import signal +from typing import TYPE_CHECKING, Any, cast + +import httpx +import pytest +from invoke import Context + +from tasks import preview +from tasks.preview import RESET_COMMAND, PreviewError + +if TYPE_CHECKING: + from pathlib import Path + + from invoke.tasks import Task + +PREFECT_API = "http://localhost:4210/api" +WORK_POOL = "preview-pool" +LEGACY_WORKER = f"{preview.LEGACY_WORKER_NAME_PREFIX}0f0c2f0e-0000-4000-8000-000000000000" +LEGACY_SERVE_COMMAND = f"python -m {preview.LEGACY_PROCESS_COMMANDS[0]}" +LEGACY_WORKER_COMMAND = f"python -m {preview.LEGACY_PROCESS_COMMANDS[1]} --pool {WORK_POOL}" + + +class _Response: + """Minimal stand-in for the Prefect responses the preflight reads.""" + + def __init__(self, status_code: int, payload: Any = None) -> None: + self.status_code = status_code + self._payload = payload + self.text = json.dumps(payload) + + def json(self) -> Any: + return self._payload + + +def _prefect_server( + monkeypatch: pytest.MonkeyPatch, + *, + deployment: bool = False, + pools: tuple[str, ...] = (WORK_POOL,), + workers: dict[str, tuple[str, ...]] | None = None, +) -> None: + """Answer the preflight's three Prefect reads from a declared server state.""" + registrations = workers or {} + + def _get(url: str, **_kwargs: Any) -> _Response: + assert url.endswith(f"/deployments/name/{preview.LEGACY_FLOW_NAME}/run") + return _Response(200, {"id": "d-1"}) if deployment else _Response(404, {}) + + def _post(url: str, **_kwargs: Any) -> _Response: + if url.endswith("/work_pools/filter"): + return _Response(200, [{"name": name} for name in pools]) + pool = url.removeprefix(f"{PREFECT_API}/work_pools/").removesuffix("/workers/filter") + if pool not in pools: + return _Response(404, {}) + return _Response(200, [{"name": name} for name in registrations.get(pool, ())]) + + monkeypatch.setattr(httpx, "get", _get) + monkeypatch.setattr(httpx, "post", _post) + + +def _process_list(monkeypatch: pytest.MonkeyPatch, *entries: tuple[int, str]) -> None: + """Replace the host process probe with a declared process table.""" + monkeypatch.setattr(preview, "_legacy_processes", lambda: tuple(entries)) + + +def _staged_up(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> list[str]: + """Run `preview.up` far enough to reach the preflight, recording what it starts.""" + started: list[str] = [] + monkeypatch.setattr(preview, "STATE_DIR", tmp_path / ".preview") + monkeypatch.setattr( + preview, + "load_preview_env", + lambda: { + "COMPOSE_PROJECT_NAME": "preview-test", + "PREVIEW_INFRAHUB_PORT": "8080", + "PREVIEW_PREFECT_PORT": "4210", + "PREVIEW_SYNC_API_PORT": "8090", + "PREVIEW_WORK_POOL": WORK_POOL, + "PREVIEW_BEARER_TOKENS": '{"tester@local": {"token": "t", "administrator": true}}', + }, + ) + monkeypatch.setattr( + preview, + "_runtime_env", + lambda _values: { + "INFRAHUB_SYNC_CACHE_DIR": str(tmp_path / "sync-cache"), + "INFRAHUB_SYNC_CONFIG_DIRECTORY": str(tmp_path / "examples"), + "INFRAHUB_ADDRESS": "http://localhost:8080", + "INFRAHUB_API_TOKEN": "token", + "PREFECT_API_URL": PREFECT_API, + }, + ) + monkeypatch.setattr(preview, "_compose", lambda *_args, **_kwargs: None) + monkeypatch.setattr(preview, "_wait_for_http", lambda *_args, **_kwargs: None) + monkeypatch.setattr(preview, "_run_smoke", lambda *_args, **_kwargs: None) + monkeypatch.setattr(preview, "_start_process", lambda name, _argv, _env: started.append(name)) + + class _SilentContext(Context): + def run(self, command: str, **kwargs: Any) -> None: # noqa: ANN401, PLR6301 - Invoke surface. + del command, kwargs + + cast("Task", preview.up).body(_SilentContext()) + return started + + +def test_up_refuses_a_legacy_deployment_and_names_the_reset_command( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + _prefect_server(monkeypatch, deployment=True) + _process_list(monkeypatch) + + with pytest.raises(PreviewError) as refusal: + _staged_up(monkeypatch, tmp_path) + + assert f"{preview.LEGACY_FLOW_NAME}/run" in str(refusal.value) + assert RESET_COMMAND in str(refusal.value) + + +def test_up_refuses_a_running_legacy_host_process(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + _prefect_server(monkeypatch) + _process_list(monkeypatch, (4242, LEGACY_SERVE_COMMAND)) + + with pytest.raises(PreviewError) as refusal: + _staged_up(monkeypatch, tmp_path) + + assert "4242" in str(refusal.value) + assert RESET_COMMAND in str(refusal.value) + + +def test_up_starts_nothing_when_it_refuses(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + _prefect_server(monkeypatch, deployment=True) + _process_list(monkeypatch) + + with pytest.raises(PreviewError): + started = _staged_up(monkeypatch, tmp_path) + assert started == [] + + +def test_up_proceeds_from_a_legacy_clean_environment(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + _prefect_server(monkeypatch, workers={WORK_POOL: ("infrahub-sync-service-1",)}) + _process_list(monkeypatch) + + started = _staged_up(monkeypatch, tmp_path) + + assert started == ["prefect-worker", "sync-api"] + + +def test_the_preflight_reports_a_legacy_work_pool(monkeypatch: pytest.MonkeyPatch) -> None: + _prefect_server(monkeypatch, pools=(WORK_POOL, preview.LEGACY_FLOW_NAME)) + + findings = preview._legacy_prefect_state(PREFECT_API, WORK_POOL) + + assert findings == [f"work pool {preview.LEGACY_FLOW_NAME}"] + + +def test_the_preflight_reports_a_legacy_worker_registration(monkeypatch: pytest.MonkeyPatch) -> None: + _prefect_server(monkeypatch, workers={WORK_POOL: ("infrahub-sync-service-1", LEGACY_WORKER)}) + + findings = preview._legacy_prefect_state(PREFECT_API, WORK_POOL) + + assert findings == [f"worker {LEGACY_WORKER} in work pool {WORK_POOL}"] + + +def test_the_preflight_reports_nothing_for_a_clean_server(monkeypatch: pytest.MonkeyPatch) -> None: + _prefect_server(monkeypatch, workers={WORK_POOL: ("infrahub-sync-service-1",)}) + + assert preview._legacy_prefect_state(PREFECT_API, WORK_POOL) == [] + + +def test_the_destructive_reset_stops_a_legacy_command_line_process(monkeypatch: pytest.MonkeyPatch) -> None: + signalled: list[tuple[int, int]] = [] + _process_list(monkeypatch, (4242, LEGACY_SERVE_COMMAND), (4243, LEGACY_WORKER_COMMAND)) + monkeypatch.setattr(preview.os, "getpgid", lambda pid: pid) + monkeypatch.setattr(preview.os, "killpg", lambda pid, number: signalled.append((pid, number))) + monkeypatch.setattr(preview, "_stop_process", lambda _name: None) + monkeypatch.setattr(preview, "_compose", lambda *_args, **_kwargs: None) + monkeypatch.setattr(preview, "load_preview_env", dict) + + cast("Task", preview.down).body(Context(), volumes=True) + + assert sorted(signalled) == [(4242, signal.SIGTERM), (4243, signal.SIGTERM)] + + +def test_the_destructive_reset_refuses_ambiguous_legacy_processes_without_killing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + signalled: list[tuple[int, int]] = [] + _process_list(monkeypatch, (4242, LEGACY_SERVE_COMMAND), (4244, LEGACY_SERVE_COMMAND)) + monkeypatch.setattr(preview.os, "getpgid", lambda pid: pid) + monkeypatch.setattr(preview.os, "killpg", lambda pid, number: signalled.append((pid, number))) + monkeypatch.setattr(preview, "_stop_process", lambda _name: None) + monkeypatch.setattr(preview, "_compose", lambda *_args, **_kwargs: None) + monkeypatch.setattr(preview, "load_preview_env", dict) + + with pytest.raises(PreviewError) as refusal: + cast("Task", preview.down).body(Context(), volumes=True) + + assert "4242" in str(refusal.value) + assert "4244" in str(refusal.value) + assert signalled == [] + + +def test_a_plain_stop_leaves_legacy_command_lines_to_the_destructive_reset( + monkeypatch: pytest.MonkeyPatch, +) -> None: + signalled: list[tuple[int, int]] = [] + _process_list(monkeypatch, (4242, LEGACY_SERVE_COMMAND)) + monkeypatch.setattr(preview.os, "getpgid", lambda pid: pid) + monkeypatch.setattr(preview.os, "killpg", lambda pid, number: signalled.append((pid, number))) + monkeypatch.setattr(preview, "_stop_process", lambda _name: None) + monkeypatch.setattr(preview, "_compose", lambda *_args, **_kwargs: None) + monkeypatch.setattr(preview, "load_preview_env", dict) + + cast("Task", preview.down).body(Context(), volumes=False) + + assert signalled == [] diff --git a/tests/service/test_runtime_identity.py b/tests/service/test_runtime_identity.py new file mode 100644 index 00000000..c2f1bae7 --- /dev/null +++ b/tests/service/test_runtime_identity.py @@ -0,0 +1,76 @@ +"""Prefect registration identity for the service worker and its one deployment.""" + +from __future__ import annotations + +from pathlib import Path +from uuid import UUID + +import pytest + +pytest.importorskip("prefect") + +from prefect.workers.process import ProcessWorker # noqa: E402 + +from infrahub_sync.service.deploy import CATALOGUE # noqa: E402 +from infrahub_sync.service.orchestration import ( # noqa: E402 + SERVICE_DEPLOYMENT_NAME, + SERVICE_DEFINITION, + SERVICE_FLOW_NAME, +) +from infrahub_sync.service.worker import ServiceProcessWorker, service_worker_name # noqa: E402 + +_RUNTIME_IDENTITY_SOURCES = ( + Path(__file__).resolve().parents[2] / "infrahub_sync" / "service", + Path(__file__).resolve().parents[2] / "tasks" / "preview.py", +) + + +def test_the_deployment_registers_the_service_flow_and_deployment_names() -> None: + assert SERVICE_FLOW_NAME == "infrahub-sync-service" + assert SERVICE_DEPLOYMENT_NAME == "run" + assert SERVICE_DEFINITION.key == "infrahub-sync-service/run" + + +def test_the_deployment_carries_the_service_tag_set() -> None: + assert SERVICE_DEFINITION.tags == ("infrahub-sync", "service") + + +def test_the_deployment_entrypoint_names_the_service_flow_function() -> None: + assert SERVICE_DEFINITION.module == "infrahub_sync.service.flow" + assert SERVICE_DEFINITION.function == "service_sync_run" + assert SERVICE_DEFINITION.entrypoint is not None + path_part, _, function_part = SERVICE_DEFINITION.entrypoint.rpartition(":") + assert function_part == "service_sync_run" + assert Path(path_part).parent.name == "service" + + +def test_exactly_one_deployment_is_registered() -> None: + assert CATALOGUE.keys() == (SERVICE_DEFINITION.key,) + + +def test_the_registered_worker_name_carries_the_service_prefix() -> None: + name = service_worker_name() + + prefix, _, suffix = name.rpartition("-") + assert prefix == "infrahub-sync-service" + assert str(UUID(suffix)) == suffix + + +def test_the_worker_dispatch_key_is_service_named_and_distinct_from_prefect() -> None: + assert ServiceProcessWorker.__dispatch_key__() == "infrahub-sync-service-process" + assert ServiceProcessWorker.__dispatch_key__() != ProcessWorker.__dispatch_key__() + + +def test_no_legacy_prefect_identity_string_survives_beside_the_service_one() -> None: + """The identity is renamed, not duplicated: nothing live still says the old name.""" + offenders: list[str] = [] + for source in _RUNTIME_IDENTITY_SOURCES: + paths = sorted(source.rglob("*.py")) if source.is_dir() else [source] + offenders.extend( + f"{path}:{number}" + for path in paths + for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1) + if "infrahub-sync-managed" in line + ) + + assert offenders == [] From 4ea0520985a5a27369208406b3311803e9779884 Mon Sep 17 00:00:00 2001 From: Blake Ellis Date: Tue, 1 Sep 2026 14:40:45 -0400 Subject: [PATCH 02/12] Rename the managed package, symbols, and Prefect identity to service Seat: lead-developer Assignment: single-product-reconciliation Co-Authored-By: Claude Opus 5 --- .github/workflows/workflow-linter.yml | 2 +- .../tutorials/netbox-demo-to-infrahub.mdx | 6 +- infrahub_sync/managed/_settings.py | 3 - infrahub_sync/product_store/models.py | 12 +- infrahub_sync/product_store/store.py | 12 +- .../{managed => service}/__init__.py | 2 +- infrahub_sync/service/_settings.py | 3 + infrahub_sync/{managed => service}/app.py | 28 +- infrahub_sync/{managed => service}/auth.py | 6 +- .../{managed => service}/compatibility.py | 4 +- .../{managed => service}/config_routes.py | 16 +- infrahub_sync/{managed => service}/deploy.py | 12 +- infrahub_sync/{managed => service}/flow.py | 36 +-- .../{managed => service}/liveness.py | 14 +- infrahub_sync/{managed => service}/models.py | 0 .../{managed => service}/orchestration.py | 32 +-- infrahub_sync/{managed => service}/serve.py | 14 +- infrahub_sync/{managed => service}/service.py | 36 +-- infrahub_sync/{managed => service}/storage.py | 16 +- infrahub_sync/{managed => service}/worker.py | 46 +-- tasks/linter.py | 6 +- tasks/preview.py | 165 +++++++++-- tasks/tests.py | 6 +- tests/cli/test_parity_and_closure.py | 4 +- tests/client/test_models.py | 4 +- tests/conformance/oracle.py | 2 +- tests/conformance/test_managed_equivalence.py | 133 --------- tests/conformance/test_oracle.py | 18 +- ...py => test_service_prefect_idempotency.py} | 12 +- ...py => test_service_storage_integration.py} | 12 +- tests/managed/__init__.py | 1 - tests/preview/conftest.py | 4 +- tests/preview/test_prefect_surface.py | 18 +- tests/preview/test_preview_configuration.py | 2 +- tests/preview/test_preview_legacy_state.py | 27 +- tests/preview/test_preview_worker_identity.py | 7 +- ...est_managed_api.py => test_service_api.py} | 4 +- tests/preview/test_smoke_request_shapes.py | 2 +- tests/product_store/test_contract.py | 8 +- tests/runtime_schema/test_worker_path.py | 24 +- tests/service/__init__.py | 1 + .../test_compatibility.py | 6 +- .../test_config_routes.py | 54 ++-- .../test_flow_and_prefect.py | 268 +++++++++--------- tests/{managed => service}/test_http_api.py | 158 +++++------ .../test_legacy_run_binding.py | 68 ++--- .../test_liveness_policy.py | 56 ++-- .../test_registered_plan_apply.py | 32 +-- .../test_registered_schema_guard.py | 28 +- tests/service/test_runtime_identity.py | 36 ++- .../test_service_worker.py} | 94 +++--- tests/{managed => service}/test_storage.py | 56 ++-- .../test_storage_import_boundary.py | 20 +- .../{managed => service}/test_worker_claim.py | 50 ++-- tests/test_linter_tasks.py | 8 +- tests/test_no_prefect_import.py | 12 +- ...e_docs.py => test_service_storage_docs.py} | 6 +- 57 files changed, 847 insertions(+), 865 deletions(-) delete mode 100644 infrahub_sync/managed/_settings.py rename infrahub_sync/{managed => service}/__init__.py (70%) create mode 100644 infrahub_sync/service/_settings.py rename infrahub_sync/{managed => service}/app.py (91%) rename infrahub_sync/{managed => service}/auth.py (95%) rename infrahub_sync/{managed => service}/compatibility.py (87%) rename infrahub_sync/{managed => service}/config_routes.py (97%) rename infrahub_sync/{managed => service}/deploy.py (90%) rename infrahub_sync/{managed => service}/flow.py (95%) rename infrahub_sync/{managed => service}/liveness.py (96%) rename infrahub_sync/{managed => service}/models.py (100%) rename infrahub_sync/{managed => service}/orchestration.py (93%) rename infrahub_sync/{managed => service}/serve.py (86%) rename infrahub_sync/{managed => service}/service.py (97%) rename infrahub_sync/{managed => service}/storage.py (95%) rename infrahub_sync/{managed => service}/worker.py (89%) delete mode 100644 tests/conformance/test_managed_equivalence.py rename tests/integration/{test_managed_prefect_idempotency.py => test_service_prefect_idempotency.py} (87%) rename tests/integration/{test_managed_storage_integration.py => test_service_storage_integration.py} (87%) delete mode 100644 tests/managed/__init__.py rename tests/preview/{test_managed_api.py => test_service_api.py} (99%) create mode 100644 tests/service/__init__.py rename tests/{managed => service}/test_compatibility.py (91%) rename tests/{managed => service}/test_config_routes.py (95%) rename tests/{managed => service}/test_flow_and_prefect.py (81%) rename tests/{managed => service}/test_http_api.py (92%) rename tests/{managed => service}/test_legacy_run_binding.py (81%) rename tests/{managed => service}/test_liveness_policy.py (94%) rename tests/{managed => service}/test_registered_plan_apply.py (91%) rename tests/{managed => service}/test_registered_schema_guard.py (97%) rename tests/{managed/test_managed_worker.py => service/test_service_worker.py} (83%) rename tests/{managed => service}/test_storage.py (92%) rename tests/{managed => service}/test_storage_import_boundary.py (69%) rename tests/{managed => service}/test_worker_claim.py (85%) rename tests/{test_managed_storage_docs.py => test_service_storage_docs.py} (87%) diff --git a/.github/workflows/workflow-linter.yml b/.github/workflows/workflow-linter.yml index d07e8ec1..bedc5821 100644 --- a/.github/workflows/workflow-linter.yml +++ b/.github/workflows/workflow-linter.yml @@ -95,7 +95,7 @@ jobs: - name: "Linting: ty check (direct Prefect Python 3.10)" if: matrix.python-version == '3.10' - run: "uv run ty check --exclude infrahub_sync/managed --exclude tests/managed ." + run: "uv run ty check --exclude infrahub_sync/service --exclude tests/service ." - name: "Linting: ty check (managed Python 3.11+)" if: matrix.python-version != '3.10' diff --git a/docs/docs/tutorials/netbox-demo-to-infrahub.mdx b/docs/docs/tutorials/netbox-demo-to-infrahub.mdx index 64504c78..34e6ff02 100644 --- a/docs/docs/tutorials/netbox-demo-to-infrahub.mdx +++ b/docs/docs/tutorials/netbox-demo-to-infrahub.mdx @@ -233,7 +233,7 @@ On macOS, create that loopback alias first with `sudo ifconfig lo0 alias 127.0.0.2 up`. If a process is bound to port 8000 on all interfaces, the second address cannot reuse that port; stop the conflicting process or start the Sync API on another port with -`uv run uvicorn --factory infrahub_sync.managed.serve:build_app --host 127.0.0.2 --port 8001`, +`uv run uvicorn --factory infrahub_sync.service.serve:build_app --host 127.0.0.2 --port 8001`, then use that port in `INFRAHUB_SYNC_API_URL`. 1. Save this local service definition as `sync-services.yml`: @@ -309,7 +309,7 @@ and Infrahub adapter credentials. ```bash uv run prefect work-pool create --type process sync-process-pool -uv run python -m infrahub_sync.managed.deploy +uv run python -m infrahub_sync.service.deploy uv run prefect worker start --pool sync-process-pool ``` @@ -317,7 +317,7 @@ uv run prefect worker start --pool sync-process-pool ```bash export INFRAHUB_SYNC_MANAGED_HOST="127.0.0.2" -uv run python -m infrahub_sync.managed.serve +uv run python -m infrahub_sync.service.serve ``` 6. In the terminal where you will run the CLI, configure the API credentials and verify diff --git a/infrahub_sync/managed/_settings.py b/infrahub_sync/managed/_settings.py deleted file mode 100644 index cc1df2de..00000000 --- a/infrahub_sync/managed/_settings.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Environment names shared by managed API and worker composition roots.""" - -PRODUCT_CACHE_ENV = "INFRAHUB_SYNC_MANAGED_CACHE_LOCATION" diff --git a/infrahub_sync/product_store/models.py b/infrahub_sync/product_store/models.py index ad928ccc..5cb3f52d 100644 --- a/infrahub_sync/product_store/models.py +++ b/infrahub_sync/product_store/models.py @@ -12,7 +12,7 @@ from infrahub_sync.execution import Operation # noqa: TC001 - Pydantic resolves this annotation at runtime. _IDENTIFIER_PATTERN = r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$" -_INVALID_MANAGED_WORKER_ID = "managed worker identity is invalid" +_INVALID_SERVICE_WORKER_ID = "service worker identity is invalid" _LEGAL_EXECUTION_VERDICTS = { ("completed", "succeeded"), ("failed", "failed"), @@ -116,13 +116,13 @@ def _require_canonical_worker_id(cls, value: object) -> object: if value is None: return None if not isinstance(value, str): - raise ValueError(_INVALID_MANAGED_WORKER_ID) # noqa: TRY004 - Pydantic reports ValueError. + raise ValueError(_INVALID_SERVICE_WORKER_ID) # noqa: TRY004 - Pydantic reports ValueError. try: canonical = str(UUID(value)) except ValueError: - raise ValueError(_INVALID_MANAGED_WORKER_ID) from None + raise ValueError(_INVALID_SERVICE_WORKER_ID) from None if canonical != value: - raise ValueError(_INVALID_MANAGED_WORKER_ID) + raise ValueError(_INVALID_SERVICE_WORKER_ID) return value @model_validator(mode="after") @@ -257,7 +257,7 @@ class ExecutionMergeWriteback(BaseModel): class MutationReceipt(BaseModel): - """Durable actor/key reservation for one managed HTTP mutation.""" + """Durable actor/key reservation for one Sync API mutation.""" model_config = ConfigDict(frozen=True, extra="forbid") @@ -349,7 +349,7 @@ def _require_timezone(cls, value: datetime) -> datetime: class AuditEvent(BaseModel): - """Secret-safe durable evidence for one managed API decision.""" + """Secret-safe durable evidence for one Sync API decision.""" model_config = ConfigDict(frozen=True, extra="forbid") diff --git a/infrahub_sync/product_store/store.py b/infrahub_sync/product_store/store.py index c7a696ee..86cb2549 100644 --- a/infrahub_sync/product_store/store.py +++ b/infrahub_sync/product_store/store.py @@ -230,7 +230,7 @@ # supports up to 8 concurrent writers with margin above that observed worst case. _CONFIGURATION_VERSION_ATTEMPTS = 8 _JSON_MAPPING_ADAPTER = TypeAdapter(dict[str, Any]) -_INVALID_MANAGED_WORKER_ID = "managed worker identity is invalid" +_INVALID_SERVICE_WORKER_ID = "service worker identity is invalid" _INSERT_CONFIGURATION = "INSERT INTO configurations (config_id, created_at) VALUES (?, ?)" _SELECT_CONFIGURATION = "SELECT config_id, created_at FROM configurations WHERE config_id = ?" @@ -2298,7 +2298,7 @@ def claim_execution( ) -> bool: """Claim one pending execution; only a canonical Prefect worker UUID is accepted.""" if not _is_canonical_uuid(worker_id): - raise ValueError(_INVALID_MANAGED_WORKER_ID) + raise ValueError(_INVALID_SERVICE_WORKER_ID) effective_claimed_at = claimed_at if claimed_at is not None else datetime.now(timezone.utc) _require_execution_timestamp(effective_claimed_at) admission_deadline_at = effective_claimed_at - timedelta(seconds=admission_ttl_seconds) @@ -2342,7 +2342,7 @@ def commit_claimed_execution( ) -> bool: """Atomically commit one claimed verdict and its business writeback.""" if not _is_canonical_uuid(worker_id): - raise ValueError(_INVALID_MANAGED_WORKER_ID) + raise ValueError(_INVALID_SERVICE_WORKER_ID) if (terminal_state, terminal_outcome) not in {("completed", "succeeded"), ("failed", "failed")}: msg = "claimed execution terminal verdict is invalid" raise ValueError(msg) @@ -2737,9 +2737,9 @@ def _run_from_rows( row: Sequence[Any], references: Sequence[Sequence[Any]], links: Sequence[Sequence[Any]], - managed_audit_links: Sequence[Sequence[Any]], + service_audit_links: Sequence[Sequence[Any]], ) -> ProductRun: - audit_links = tuple(dict.fromkeys((*json.loads(row[4]), *(str(item[0]) for item in managed_audit_links)))) + audit_links = tuple(dict.fromkeys((*json.loads(row[4]), *(str(item[0]) for item in service_audit_links)))) return ProductRun.model_validate( { "run_id": row[0], @@ -2833,7 +2833,7 @@ def _cancellation_terminal_response(run_id: str, receipt_id: str) -> dict[str, A return { "error": { "code": "execution-terminal", - "message": "the managed execution is already terminal", + "message": "the service execution is already terminal", "status": 409, "run_id": run_id, "mutation_id": receipt_id, diff --git a/infrahub_sync/managed/__init__.py b/infrahub_sync/service/__init__.py similarity index 70% rename from infrahub_sync/managed/__init__.py rename to infrahub_sync/service/__init__.py index 754390b7..25eba27e 100644 --- a/infrahub_sync/managed/__init__.py +++ b/infrahub_sync/service/__init__.py @@ -1,4 +1,4 @@ -"""Optional managed HTTP and Prefect integration. +"""Optional Sync HTTP service and Prefect integration. This package is intentionally not imported by :mod:`infrahub_sync`; install the ``managed`` extra before importing its modules. diff --git a/infrahub_sync/service/_settings.py b/infrahub_sync/service/_settings.py new file mode 100644 index 00000000..b50a218e --- /dev/null +++ b/infrahub_sync/service/_settings.py @@ -0,0 +1,3 @@ +"""Environment names shared by Sync API and worker composition roots.""" + +PRODUCT_CACHE_ENV = "INFRAHUB_SYNC_MANAGED_CACHE_LOCATION" diff --git a/infrahub_sync/managed/app.py b/infrahub_sync/service/app.py similarity index 91% rename from infrahub_sync/managed/app.py rename to infrahub_sync/service/app.py index fd6014be..ffedd2c0 100644 --- a/infrahub_sync/managed/app.py +++ b/infrahub_sync/service/app.py @@ -1,4 +1,4 @@ -"""FastAPI routing for the stable managed Sync HTTP contract.""" +"""FastAPI routing for the stable Sync HTTP contract.""" import logging import os @@ -32,7 +32,7 @@ VerifyRunRequest, VersionResource, ) -from .service import ManagedAPIError, ManagedRunService +from .service import RunService, ServiceAPIError logger = logging.getLogger(__name__) @@ -42,12 +42,12 @@ def create_app( - service: ManagedRunService, + service: RunService, resolver: PrincipalResolver, configuration_routes: ConfigurationRoutes | None = None, reconciler: RunLivenessReconciler | None = None, ) -> FastAPI: - """Create the managed application from explicit providers.""" + """Create the service application from explicit providers.""" @asynccontextmanager async def lifespan(_application: FastAPI): @@ -61,7 +61,7 @@ async def reconcile_loop() -> None: except CancelledError: # pylint: disable=try-except-raise raise except Exception as exc: # noqa: BLE001 # pylint: disable=broad-exception-caught - logger.error("managed liveness iteration failed: %s", type(exc).__name__) # noqa: TRY400 + logger.error("service liveness iteration failed: %s", type(exc).__name__) # noqa: TRY400 await sleep(reconciler.cadence_seconds) task = create_task(reconcile_loop()) @@ -73,7 +73,7 @@ async def reconcile_loop() -> None: with suppress(CancelledError): await task - application = FastAPI(title="Infrahub Sync managed API", version=installed_server_version(), lifespan=lifespan) + application = FastAPI(title="Infrahub Sync Sync API", version=installed_server_version(), lifespan=lifespan) bearer_auth = HTTPBearer(auto_error=False, scheme_name="BearerAuth") def authenticate( @@ -82,24 +82,24 @@ def authenticate( ) -> Principal: if credentials is None: service.record_authentication_refusal(request.url.path, "missing-or-invalid-authorization") - raise ManagedAPIError(401, "unauthenticated", "a valid bearer token is required") + raise ServiceAPIError(401, "unauthenticated", "a valid bearer token is required") token = credentials.credentials.lstrip(" ") if not token: service.record_authentication_refusal(request.url.path, "missing-or-invalid-authorization") - raise ManagedAPIError(401, "unauthenticated", "a valid bearer token is required") + raise ServiceAPIError(401, "unauthenticated", "a valid bearer token is required") principal = resolver.resolve(token) if principal is None: service.record_authentication_refusal(request.url.path, "invalid-bearer-token") - raise ManagedAPIError(401, "unauthenticated", "a valid bearer token is required") + raise ServiceAPIError(401, "unauthenticated", "a valid bearer token is required") return principal def idempotency_key(value: Annotated[str | None, Header(alias="Idempotency-Key")] = None) -> str: if value is None or not value.strip(): - raise ManagedAPIError(422, "idempotency-key-required", "a non-empty Idempotency-Key header is required") + raise ServiceAPIError(422, "idempotency-key-required", "a non-empty Idempotency-Key header is required") return value - @application.exception_handler(ManagedAPIError) - async def managed_error(_request: Request, exc: ManagedAPIError) -> JSONResponse: # noqa: RUF029 + @application.exception_handler(ServiceAPIError) + async def managed_error(_request: Request, exc: ServiceAPIError) -> JSONResponse: # noqa: RUF029 envelope = ErrorEnvelope( error=ErrorDetail( code=exc.code, @@ -153,12 +153,12 @@ async def contain_unhandled_error( return await call_next(request) except Exception as exc: # pylint: disable=broad-exception-caught # noqa: BLE001 logger.error( # noqa: TRY400 - raw traceback text must not cross this log boundary. - "managed API request failed: %s", type(exc).__name__ + "Sync API request failed: %s", type(exc).__name__ ) envelope = ErrorEnvelope( error=ErrorDetail( code="service-unavailable", - message="the managed Sync service is temporarily unavailable", + message="the Sync service is temporarily unavailable", status=503, ) ) diff --git a/infrahub_sync/managed/auth.py b/infrahub_sync/service/auth.py similarity index 95% rename from infrahub_sync/managed/auth.py rename to infrahub_sync/service/auth.py index 63b45737..5bc3f03c 100644 --- a/infrahub_sync/managed/auth.py +++ b/infrahub_sync/service/auth.py @@ -1,4 +1,4 @@ -"""Application-owned principal resolution for the managed API.""" +"""Application-owned principal resolution for the Sync API.""" from __future__ import annotations @@ -13,7 +13,7 @@ class Principal(BaseModel): - """Authenticated managed-API actor.""" + """Authenticated Sync API actor.""" model_config = ConfigDict(frozen=True, extra="forbid") @@ -48,7 +48,7 @@ def from_environment(cls) -> EnvironmentPrincipalResolver: """Load ``{actor: {token, administrator}}`` without retaining raw JSON.""" raw = os.environ.get(PRINCIPALS_ENV) if not raw: - msg = f"{PRINCIPALS_ENV} must contain a JSON object of managed API principals" + msg = f"{PRINCIPALS_ENV} must contain a JSON object of Sync API principals" raise ValueError(msg) try: payload = json.loads(raw) diff --git a/infrahub_sync/managed/compatibility.py b/infrahub_sync/service/compatibility.py similarity index 87% rename from infrahub_sync/managed/compatibility.py rename to infrahub_sync/service/compatibility.py index 406bb941..5fc69b72 100644 --- a/infrahub_sync/managed/compatibility.py +++ b/infrahub_sync/service/compatibility.py @@ -1,4 +1,4 @@ -"""Managed API compatibility declarations.""" +"""Sync API compatibility declarations.""" from __future__ import annotations @@ -8,7 +8,7 @@ API_VERSIONS = ("v3-unstable",) API_STABILITY = "unstable" -_METADATA_ERROR = "managed package metadata is unavailable" +_METADATA_ERROR = "service package metadata is unavailable" def installed_server_version() -> str: diff --git a/infrahub_sync/managed/config_routes.py b/infrahub_sync/service/config_routes.py similarity index 97% rename from infrahub_sync/managed/config_routes.py rename to infrahub_sync/service/config_routes.py index 0515e9b1..3f884f65 100644 --- a/infrahub_sync/managed/config_routes.py +++ b/infrahub_sync/service/config_routes.py @@ -30,7 +30,7 @@ from .auth import Principal from .models import ConfigMutationRequest -from .service import ManagedAPIError +from .service import ServiceAPIError _CONFIG_ID_PATTERN = r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$" _MAX_REGISTRY_VERSION = 2**63 - 1 @@ -54,7 +54,7 @@ def _provider_error_boundary(operation: Any) -> Any: def guarded(*args: Any, **kwargs: Any) -> Any: try: return operation(*args, **kwargs) - except (ConfigurationAPIError, ManagedAPIError): + except (ConfigurationAPIError, ServiceAPIError): raise except ProductStoreProviderError: raise ConfigurationAPIError(503, "storage") from None @@ -67,10 +67,10 @@ def guarded(*args: Any, **kwargs: Any) -> Any: def _strict_integer(value: str, *, minimum: int, maximum: int) -> int: """Parse one bounded API integer without accepting FastAPI's coercions.""" if not value.isascii() or not value.isdecimal(): - raise ManagedAPIError(422, "request-invalid", "the request does not match the API schema") + raise ServiceAPIError(422, "request-invalid", "the request does not match the API schema") number = int(value) if number < minimum or number > maximum: - raise ManagedAPIError(422, "request-invalid", "the request does not match the API schema") + raise ServiceAPIError(422, "request-invalid", "the request does not match the API schema") return number @@ -215,7 +215,7 @@ def mutate( or stored.request_fingerprint != receipt.request_fingerprint ): self._audit(actor, operation, reason, "refused-idempotency") - raise ManagedAPIError( + raise ServiceAPIError( 409, "idempotency-conflict", "Idempotency-Key was already used by this actor for different content" ) if stored.state == "accepted": @@ -225,7 +225,7 @@ def mutate( return stored.response_status, stored.response_body if not projection.claim_mutation(stored.receipt_id, secrets=self._secrets): self._audit(actor, operation, reason, "refused-idempotency-in-progress") - raise ManagedAPIError(409, "idempotency-in-progress", "the matching request is still being processed") + raise ServiceAPIError(409, "idempotency-in-progress", "the matching request is still being processed") try: if operation == "register-config": result = self.register(package) @@ -294,7 +294,7 @@ def register( ) -> Any: if not principal.administrator: routes.audit_refusal(principal.actor, "register-config", body.reason) - raise ManagedAPIError(403, "forbidden", "administrator access is required") + raise ServiceAPIError(403, "forbidden", "administrator access is required") status, content = routes.mutate( actor=principal.actor, idempotency_key=key, @@ -315,7 +315,7 @@ def create_version( ) -> Any: if not principal.administrator: routes.audit_refusal(principal.actor, "create-config-version", body.reason) - raise ManagedAPIError(403, "forbidden", "administrator access is required") + raise ServiceAPIError(403, "forbidden", "administrator access is required") status, content = routes.mutate( actor=principal.actor, idempotency_key=key, diff --git a/infrahub_sync/managed/deploy.py b/infrahub_sync/service/deploy.py similarity index 90% rename from infrahub_sync/managed/deploy.py rename to infrahub_sync/service/deploy.py index c47809bf..2d1320cf 100644 --- a/infrahub_sync/managed/deploy.py +++ b/infrahub_sync/service/deploy.py @@ -1,4 +1,4 @@ -"""Converge the separate managed deployment onto an existing Prefect work pool.""" +"""Converge the separate service deployment onto an existing Prefect work pool.""" from __future__ import annotations @@ -11,11 +11,11 @@ from prefect.client.orchestration import get_client from prefect.client.schemas.actions import DeploymentUpdate -from .orchestration import MANAGED_DEFINITION +from .orchestration import SERVICE_DEFINITION WORK_POOL_ENV = "INFRAHUB_SYNC_MANAGED_WORK_POOL" FLOW_WORKING_DIRECTORY_ENV = "INFRAHUB_SYNC_MANAGED_FLOW_WORKING_DIRECTORY" -CATALOGUE = WorkflowCatalogue(MANAGED_DEFINITION) +CATALOGUE = WorkflowCatalogue(SERVICE_DEFINITION) def flow_pull_steps(working_directory: str) -> list[dict[str, dict[str, str]]]: @@ -36,7 +36,7 @@ def required_flow_working_directory() -> str: raw = os.environ.get(FLOW_WORKING_DIRECTORY_ENV) if not raw: msg = ( - f"{FLOW_WORKING_DIRECTORY_ENV} must name the absolute directory managed flow " + f"{FLOW_WORKING_DIRECTORY_ENV} must name the absolute directory service flow " "runs execute from (relative paths in Sync configurations resolve against it)" ) raise ValueError(msg) @@ -56,7 +56,7 @@ async def _ensure_flow_working_directory(working_directory: str) -> None: """ desired = flow_pull_steps(working_directory) async with get_client() as client: - deployment = await client.read_deployment_by_name(MANAGED_DEFINITION.key) + deployment = await client.read_deployment_by_name(SERVICE_DEFINITION.key) if deployment.pull_steps != desired: await client.update_deployment(deployment.id, deployment=DeploymentUpdate(pull_steps=desired)) @@ -76,7 +76,7 @@ async def _deploy() -> int: def main() -> int: - """Validate the catalogue and apply its one managed deployment.""" + """Validate the catalogue and apply its one service deployment.""" return asyncio.run(_deploy()) diff --git a/infrahub_sync/managed/flow.py b/infrahub_sync/service/flow.py similarity index 95% rename from infrahub_sync/managed/flow.py rename to infrahub_sync/service/flow.py index b927ce78..aa5a929f 100644 --- a/infrahub_sync/managed/flow.py +++ b/infrahub_sync/service/flow.py @@ -55,9 +55,9 @@ from .liveness import LivenessPolicy from .models import PlanResource -from .orchestration import MANAGED_FLOW_NAME +from .orchestration import SERVICE_FLOW_NAME from .service import PLAN_ARTIFACT_ID -from .storage import managed_product_projection +from .storage import service_product_projection CONFIG_DIR_ENV = "INFRAHUB_SYNC_CONFIG_DIRECTORY" RUN_CACHE_ENV = "INFRAHUB_SYNC_CACHE_DIR" @@ -68,13 +68,13 @@ _REGISTERED_PLAN_BINDING_MISMATCH = "registered saved plan binding does not match run binding" _REGISTERED_PLAN_VERIFICATION_FAILED = "registered saved plan verification failed" _REGISTERED_PLAN_CHECKSUM_MISMATCH = "registered saved plan checksum does not match the approved expected_checksum" -_WORKER_BINDING_PARAMETERS_INVALID = "managed worker configuration binding parameters must be all absent or all present" -_LEGACY_RUN_IDENTITY_UNAVAILABLE = "legacy managed run identity is unavailable" -_LEGACY_RUN_CONFIGURATION_MISMATCH = "legacy managed run configuration version does not match durable run" -_WORKER_EXECUTION_REFUSED = "managed worker execution claim was refused" -_WORKER_EXECUTION_ID_INVALID = "managed worker execution identity is invalid" -_WORKER_EXECUTION_IDENTITY_UNAVAILABLE = "managed worker execution identity is unavailable" -_WORKER_EXECUTION_WRITEBACK_REFUSED = "managed worker execution writeback was refused" +_WORKER_BINDING_PARAMETERS_INVALID = "service worker configuration binding parameters must be all absent or all present" +_LEGACY_RUN_IDENTITY_UNAVAILABLE = "legacy service run identity is unavailable" +_LEGACY_RUN_CONFIGURATION_MISMATCH = "legacy service run configuration version does not match durable run" +_WORKER_EXECUTION_REFUSED = "service worker execution claim was refused" +_WORKER_EXECUTION_ID_INVALID = "service worker execution identity is invalid" +_WORKER_EXECUTION_IDENTITY_UNAVAILABLE = "service worker execution identity is unavailable" +_WORKER_EXECUTION_WRITEBACK_REFUSED = "service worker execution writeback was refused" _WORKER_PAGE_SIZE = 200 @@ -117,7 +117,7 @@ def _remote_log_bridge( source_logger.propagate = previous_propagate -def _runtime(*, projection_factory: Any = managed_product_projection) -> tuple[str, ProductProjection]: +def _runtime(*, projection_factory: Any = service_product_projection) -> tuple[str, ProductProjection]: config_directory = os.environ.get(CONFIG_DIR_ENV) run_cache = os.environ.get(RUN_CACHE_ENV) if not config_directory or not Path(config_directory).is_dir(): @@ -360,7 +360,7 @@ def _worker_execution_context( msg = f"API-created Sync run {run_id!r} is unavailable" raise RuntimeError(msg) if stored.value.configuration_binding != binding: - msg = "managed run binding does not match worker parameters" + msg = "service run binding does not match worker parameters" raise ValueError(msg) if binding is None: sync_name = stored.value.summary.get("sync_name") @@ -409,7 +409,7 @@ def _execute_stage( # pylint: disable=too-many-arguments,too-many-positional-ar config_directory: str, projection: ProductProjection, ) -> tuple[dict[str, Any], ExecutionWriteback]: - """Resolve and execute one managed stage within the sanitized worker boundary.""" + """Resolve and execute one service stage within the sanitized worker boundary.""" parameter_binding = _worker_binding(config_id, registry_version, package_checksum) projection, instance, sync_name = _worker_execution_context( run_id, @@ -421,10 +421,10 @@ def _execute_stage( # pylint: disable=too-many-arguments,too-many-positional-ar build_models=stage != "apply", ) if stage in ("apply", "sync") and not confirm_writes: - msg = f"confirm_writes=true is required for managed stage={stage}" + msg = f"confirm_writes=true is required for service stage={stage}" raise ValueError(msg) if stage == "apply" and expected_checksum is None: - msg = "expected_checksum is required for managed stage=apply" + msg = "expected_checksum is required for service stage=apply" raise ValueError(msg) secrets[:] = collect_secret_values(instance) @@ -530,7 +530,7 @@ def _execute_stage( # pylint: disable=too-many-arguments,too-many-positional-ar summary={"sync_name": sync_name, **{key: applied.summary[key] for key in ACTION_KEYS}}, results=result, ) - run_logger.info(redact(f"managed Sync run {run_id} stage={stage} outcome={result['outcome']}", secrets)) + run_logger.info(redact(f"service run {run_id} stage={stage} outcome={result['outcome']}", secrets)) return result, writeback @@ -600,13 +600,13 @@ def _record_failure( # pylint: disable=too-many-arguments,too-many-positional-a except Exception as persistence_error: # noqa: BLE001 # pylint: disable=broad-exception-caught run_logger.log( logging.WARNING, - "managed Sync failure evidence could not be persisted (%s)", + "service failure evidence could not be persisted (%s)", type(persistence_error).__name__, ) -@flow(name=MANAGED_FLOW_NAME) -def managed_sync_run( # pylint: disable=too-many-positional-arguments +@flow(name=SERVICE_FLOW_NAME) +def service_sync_run( # pylint: disable=too-many-positional-arguments run_id: str, stage: Literal["plan", "verify", "apply", "sync"], config_id: str | None = None, diff --git a/infrahub_sync/managed/liveness.py b/infrahub_sync/service/liveness.py similarity index 96% rename from infrahub_sync/managed/liveness.py rename to infrahub_sync/service/liveness.py index 2eeb8e6b..5ed6ecd4 100644 --- a/infrahub_sync/managed/liveness.py +++ b/infrahub_sync/service/liveness.py @@ -1,4 +1,4 @@ -"""Pure timing policy for managed execution liveness.""" +"""Pure timing policy for service execution liveness.""" from __future__ import annotations @@ -15,13 +15,13 @@ ) from .orchestration import ( - ManagedOrchestration, PoolStatus, + ServiceOrchestration, normalized_pool_status, ) RUN_ADMISSION_TTL_ENV = "INFRAHUB_SYNC_RUN_ADMISSION_TTL_SECONDS" -_POLICY_ERROR = "managed liveness settings are invalid" +_POLICY_ERROR = "service liveness settings are invalid" _TERMINAL_STATES = frozenset({"completed", "failed", "crashed", "cancelled"}) @@ -32,7 +32,7 @@ class CancellationSelectionUnavailableError(RuntimeError): async def select_cancellable_execution( run: ProductRun, receipt_id: str | None, - orchestration: ManagedOrchestration, + orchestration: ServiceOrchestration, ) -> PrefectExecutionLink | None: """Select an eligible link without writing product or receipt state.""" for candidate in reversed(run.prefect_executions): @@ -54,7 +54,7 @@ async def select_cancellable_execution( @dataclass(frozen=True, slots=True) class LivenessPolicy: - """Validated code-owned timing values for one managed service instance.""" + """Validated code-owned timing values for one service instance.""" admission_ttl_seconds: int stall_threshold_seconds: float @@ -118,7 +118,7 @@ class RunLivenessReconciler: claiming worker. ``(cancelled, cancelled)`` is written only by reconciliation rule 1. ``(abandoned, abandoned)`` and ``(interrupted, ambiguous)`` may be written either by reconciliation or by - request-time cancellation recovery in ``ManagedRunService.cancel_run``, at + request-time cancellation recovery in ``RunService.cancel_run``, at the same inclusive deadline as rule 2. Those competing reconciliation and request-time paths use the durable compare-and-set, so only one of them commits a verdict. No writer submits or resubmits work: an execution is @@ -128,7 +128,7 @@ class RunLivenessReconciler: def __init__( self, projection: ProductProjection, - orchestration: ManagedOrchestration, + orchestration: ServiceOrchestration, policy: LivenessPolicy, work_pool_name: str, *, diff --git a/infrahub_sync/managed/models.py b/infrahub_sync/service/models.py similarity index 100% rename from infrahub_sync/managed/models.py rename to infrahub_sync/service/models.py diff --git a/infrahub_sync/managed/orchestration.py b/infrahub_sync/service/orchestration.py similarity index 93% rename from infrahub_sync/managed/orchestration.py rename to infrahub_sync/service/orchestration.py index 37f8e438..4cb5dbdb 100644 --- a/infrahub_sync/managed/orchestration.py +++ b/infrahub_sync/service/orchestration.py @@ -1,4 +1,4 @@ -"""Prefect Extras translation and live-state access for the managed API.""" +"""Prefect Extras translation and live-state access for the Sync API.""" from __future__ import annotations @@ -26,8 +26,8 @@ from prefect.exceptions import ObjectNotFound from prefect.states import Cancelling -MANAGED_FLOW_NAME = "infrahub-sync-managed" -MANAGED_DEPLOYMENT_NAME = "run" +SERVICE_FLOW_NAME = "infrahub-sync-service" +SERVICE_DEPLOYMENT_NAME = "run" _TERMINAL_STATE_TYPES = frozenset({StateType.COMPLETED, StateType.FAILED, StateType.CRASHED, StateType.CANCELLED}) # Freshness uses three intervals; cap it at the largest age two datetimes can express. _MAX_DATETIME_AGE = datetime.max.replace(tzinfo=timezone.utc) - datetime.min.replace(tzinfo=timezone.utc) @@ -39,21 +39,21 @@ # not be run". An absolute path also encodes the documented contract that the # API, the deployment apply, and the workers share one installation's # filesystem view; re-applying from a different installation reconciles it. -_MANAGED_FLOW_ENTRYPOINT = f"{Path(__file__).with_name('flow.py')}:managed_sync_run" - -MANAGED_DEFINITION = WorkflowDefinition( - flow_name=MANAGED_FLOW_NAME, - deployment_name=MANAGED_DEPLOYMENT_NAME, - module="infrahub_sync.managed.flow", - function="managed_sync_run", - entrypoint=_MANAGED_FLOW_ENTRYPOINT, - tags=("infrahub-sync", "managed"), +_SERVICE_FLOW_ENTRYPOINT = f"{Path(__file__).with_name('flow.py')}:service_sync_run" + +SERVICE_DEFINITION = WorkflowDefinition( + flow_name=SERVICE_FLOW_NAME, + deployment_name=SERVICE_DEPLOYMENT_NAME, + module="infrahub_sync.service.flow", + function="service_sync_run", + entrypoint=_SERVICE_FLOW_ENTRYPOINT, + tags=("infrahub-sync", "service"), ) @dataclass(frozen=True, slots=True) class Submission: - """One Prefect-accepted managed execution.""" + """One Prefect-accepted service execution.""" flow_run_id: str state: str @@ -115,7 +115,7 @@ def __post_init__(self) -> None: raise ValueError -class ManagedOrchestration(Protocol): +class ServiceOrchestration(Protocol): """Small orchestration boundary consumed by the HTTP service.""" async def submit(self, parameters: dict[str, object], *, idempotency_key: str) -> Submission: ... @@ -128,7 +128,7 @@ async def cancel(self, flow_run_id: str) -> CancellationResult: ... class _PoolClient(Protocol): - """Pinned Prefect client methods used only by the managed liveness adapter.""" + """Pinned Prefect client methods used only by the service liveness adapter.""" async def read_workers_for_work_pool(self, work_pool_name: str) -> list[Any]: ... @@ -143,7 +143,7 @@ def __init__(self, client: RemoteExecutionClient, executor: IdempotentWorkflowEx self._executor = executor or RemoteWorkflowExecutor(client) async def submit(self, parameters: dict[str, object], *, idempotency_key: str) -> Submission: - handle = await self._executor.submit(MANAGED_DEFINITION, parameters, idempotency_key=idempotency_key) + handle = await self._executor.submit(SERVICE_DEFINITION, parameters, idempotency_key=idempotency_key) return Submission(flow_run_id=handle.id, state=await handle.status()) async def observe(self, flow_run_id: str) -> Observation: diff --git a/infrahub_sync/managed/serve.py b/infrahub_sync/service/serve.py similarity index 86% rename from infrahub_sync/managed/serve.py rename to infrahub_sync/service/serve.py index d1afc85a..0af476ad 100644 --- a/infrahub_sync/managed/serve.py +++ b/infrahub_sync/service/serve.py @@ -1,4 +1,4 @@ -"""Run the optional managed HTTP service with environment-owned providers.""" +"""Run the optional Sync HTTP service with environment-owned providers.""" from __future__ import annotations @@ -13,8 +13,8 @@ from .config_routes import ConfigurationRoutes from .liveness import LivenessPolicy, RunLivenessReconciler from .orchestration import CancellationResult, Observation, PoolStatus, PrefectOrchestration, Submission -from .service import ManagedRunService -from .storage import managed_product_projection +from .service import RunService +from .storage import service_product_projection if TYPE_CHECKING: from fastapi import FastAPI @@ -42,13 +42,13 @@ async def cancel(self, flow_run_id: str) -> CancellationResult: def build_app( *, - projection_factory: Any = managed_product_projection, + projection_factory: Any = service_product_projection, resolver_factory: Any = EnvironmentPrincipalResolver.from_environment, - run_service_factory: Any = ManagedRunService, + run_service_factory: Any = RunService, configuration_routes_factory: Any = ConfigurationRoutes, app_factory: Any = create_app, ) -> FastAPI: - """Construct the managed app from its environment-owned durable storage profile.""" + """Construct the service app from its environment-owned durable storage profile.""" policy = LivenessPolicy.from_environment(worker_query_seconds=os.environ.get("PREFECT_WORKER_QUERY_SECONDS", "10")) projection = projection_factory() resolver = resolver_factory() @@ -70,7 +70,7 @@ def build_app( def main() -> None: - """Serve the managed API; Prefect workers and deployments are separate.""" + """Serve the Sync API; Prefect workers and deployments are separate.""" uvicorn.run(build_app(), host=os.environ.get("INFRAHUB_SYNC_MANAGED_HOST", "127.0.0.1"), port=8000) diff --git a/infrahub_sync/managed/service.py b/infrahub_sync/service/service.py similarity index 97% rename from infrahub_sync/managed/service.py rename to infrahub_sync/service/service.py index 4213a837..f4e5e6ab 100644 --- a/infrahub_sync/managed/service.py +++ b/infrahub_sync/service/service.py @@ -1,4 +1,4 @@ -"""Run-oriented managed API behavior over durable Sync product records.""" +"""Run-oriented Sync API behavior over durable Sync product records.""" from __future__ import annotations @@ -38,7 +38,7 @@ WorkerStatusResource, public_run_resource, ) -from .orchestration import ManagedOrchestration, Observation, PoolStatus, normalized_pool_status +from .orchestration import Observation, PoolStatus, ServiceOrchestration, normalized_pool_status if TYPE_CHECKING: from collections.abc import Callable @@ -82,7 +82,7 @@ def _service_status(snapshot: PoolStatus) -> ServiceStatusResource: ) -class ManagedAPIError(Exception): +class ServiceAPIError(Exception): """Stable HTTP classification raised by the service boundary.""" def __init__( @@ -103,13 +103,13 @@ def __init__( super().__init__(self.message) -class ManagedRunService: +class RunService: """Direct application service; Prefect owns all execution mechanics.""" def __init__( self, projection: ProductProjection, - orchestration: ManagedOrchestration, + orchestration: ServiceOrchestration, *, secrets: tuple[str, ...] = (), cancellation_recovery_seconds: float = 30.0, @@ -124,7 +124,7 @@ def __init__( async def create_run( self, request: CreateRunRequest, principal: Principal, idempotency_key: str ) -> tuple[int, dict[str, Any]]: - """Reserve one product identity and accept its first managed execution.""" + """Reserve one product identity and accept its first service execution.""" self._require_non_secret_parameters( principal.actor, request.operation, @@ -323,7 +323,7 @@ async def apply_run( async def cancel_run( # noqa: PLR0911 # pylint: disable=too-many-branches,too-many-return-statements,too-many-statements self, run_id: str, request: CancelRunRequest, principal: Principal, idempotency_key: str ) -> tuple[int, dict[str, Any]]: - """Request cancellation of only the latest active managed execution.""" + """Request cancellation of only the latest active service execution.""" run = self._owned_run(run_id, principal, "cancel", request.reason) body = request.model_dump(mode="json") receipt = self._lookup_existing_receipt( @@ -345,7 +345,7 @@ async def cancel_run( # noqa: PLR0911 # pylint: disable=too-many-branches,too- reason=request.reason, outcome="refused-no-execution", ) - raise self._error(409, "no-active-execution", "the run has no managed execution to cancel", run_id=run_id) + raise self._error(409, "no-active-execution", "the run has no service execution to cancel", run_id=run_id) try: link = await select_cancellable_execution( run, @@ -354,14 +354,14 @@ async def cancel_run( # noqa: PLR0911 # pylint: disable=too-many-branches,too- ) except CancellationSelectionUnavailableError: self._raise_cancel_unavailable( - receipt, principal, request.reason, "the active managed execution cannot be confirmed", run_id=run_id + receipt, principal, request.reason, "the active service execution cannot be confirmed", run_id=run_id ) except Exception as exc: # pylint: disable=broad-exception-caught # noqa: BLE001 self._raise_cancel_unavailable( receipt, principal, request.reason, - "the active managed execution cannot be confirmed", + "the active service execution cannot be confirmed", run_id=run_id, exc=exc, ) @@ -369,7 +369,7 @@ async def cancel_run( # noqa: PLR0911 # pylint: disable=too-many-branches,too- self._audit( run_id, actor=principal.actor, operation="cancel", reason=request.reason, outcome="refused-terminal" ) - raise self._error(409, "execution-terminal", "the managed execution is already terminal", run_id=run_id) + raise self._error(409, "execution-terminal", "the service execution is already terminal", run_id=run_id) if receipt is None: receipt = self._reserve_existing( run, @@ -404,7 +404,7 @@ async def cancel_run( # noqa: PLR0911 # pylint: disable=too-many-branches,too- raise self._error( 409, "execution-terminal", - "the managed execution is already terminal", + "the service execution is already terminal", run_id=run_id, mutation_id=receipt.receipt_id, ) @@ -448,7 +448,7 @@ async def cancel_run( # noqa: PLR0911 # pylint: disable=too-many-branches,too- response_body=self._error_body( 409, "execution-terminal", - "the managed execution is already terminal", + "the service execution is already terminal", run_id=run_id, mutation_id=receipt.receipt_id, ), @@ -499,7 +499,7 @@ async def cancel_run( # noqa: PLR0911 # pylint: disable=too-many-branches,too- raise self._error( 409, "execution-terminal", - "the managed execution is already terminal", + "the service execution is already terminal", run_id=run_id, mutation_id=receipt.receipt_id, ) @@ -508,7 +508,7 @@ async def cancel_run( # noqa: PLR0911 # pylint: disable=too-many-branches,too- raise self._error( 409, "execution-terminal", - "the managed execution is already terminal", + "the service execution is already terminal", run_id=run_id, mutation_id=receipt.receipt_id, ) @@ -622,7 +622,7 @@ async def _submit( error = self._error( 503, "orchestration-unavailable", - f"Prefect could not confirm managed submission ({type(exc).__name__})", + f"Prefect could not confirm service submission ({type(exc).__name__})", run_id=receipt.run_id, mutation_id=receipt.receipt_id, ) @@ -989,8 +989,8 @@ def _error( *, run_id: str | None = None, mutation_id: str | None = None, - ) -> ManagedAPIError: - return ManagedAPIError( + ) -> ServiceAPIError: + return ServiceAPIError( status, code, message, diff --git a/infrahub_sync/managed/storage.py b/infrahub_sync/service/storage.py similarity index 95% rename from infrahub_sync/managed/storage.py rename to infrahub_sync/service/storage.py index 8cdf606a..9c2f4f84 100644 --- a/infrahub_sync/managed/storage.py +++ b/infrahub_sync/service/storage.py @@ -1,4 +1,4 @@ -"""Managed-only adapters for PostgreSQL records and S3-compatible artifacts.""" +"""Service-only adapters for PostgreSQL records and S3-compatible artifacts.""" from __future__ import annotations @@ -30,11 +30,11 @@ _ENDPOINT_URL_ADAPTER = TypeAdapter(AnyHttpUrl) -class ManagedStorageStartupError(RuntimeError): - """Managed durable storage could not initialize at process construction.""" +class ServiceStorageStartupError(RuntimeError): + """Service durable storage could not initialize at process construction.""" def __init__(self) -> None: - super().__init__("managed durable storage startup failed") + super().__init__("service durable storage startup failed") class S3ProtocolError(RuntimeError): @@ -180,14 +180,14 @@ def _endpoint(values: Mapping[str, str]) -> str | None: return endpoint -def managed_product_projection( +def service_product_projection( *, environ: Mapping[str, str] | None = None, database_connect: Callable[[], Any] | None = None, s3_client_builder: Callable[..., Any] = boto3.client, projection_builder: Callable[..., ProductProjection] = production_product_projection, ) -> ProductProjection: - """Build the one PostgreSQL/S3 projection used by a managed process.""" + """Build the one PostgreSQL/S3 projection used by a service process.""" values: Mapping[str, str] = os.environ if environ is None else environ database_url = _database_url(values) bucket = _required_setting(values, S3_BUCKET_ENV) @@ -198,11 +198,11 @@ def managed_product_projection( try: client = Boto3S3Client(s3_client_builder("s3", endpoint_url=endpoint, region_name=region)) except Exception: # noqa: BLE001 # SDK construction is one fixed startup boundary. - raise ManagedStorageStartupError from None + raise ServiceStorageStartupError from None try: return projection_builder(connect=connect, s3_client=client, bucket=bucket, prefix=prefix) except ProductStoreProviderError: - raise ManagedStorageStartupError from None + raise ServiceStorageStartupError from None def _error_code(error: ClientError) -> str | None: diff --git a/infrahub_sync/managed/worker.py b/infrahub_sync/service/worker.py similarity index 89% rename from infrahub_sync/managed/worker.py rename to infrahub_sync/service/worker.py index 33c0272e..b066627f 100644 --- a/infrahub_sync/managed/worker.py +++ b/infrahub_sync/service/worker.py @@ -1,4 +1,4 @@ -"""Run the managed ProcessWorker with a canonical self-hosted Prefect identity.""" +"""Run the service ProcessWorker with a canonical self-hosted Prefect identity.""" from __future__ import annotations @@ -27,20 +27,20 @@ from prefect.client.schemas.objects import FlowRun, WorkPool from prefect.client.schemas.responses import DeploymentResponse -_IDENTITY_ERROR = "managed worker identity is unavailable" -_WORKER_NAME_PREFIX = "infrahub-sync-managed" +_IDENTITY_ERROR = "service worker identity is unavailable" +_WORKER_NAME_PREFIX = "infrahub-sync-service" _WORKER_PAGE_SIZE = 200 _SUBMISSION_IDENTITY: ContextVar[tuple[bool, int | None]] = ContextVar( - "managed_worker_submission_identity", + "service_worker_submission_identity", default=(False, None), ) -class ManagedWorkerIdentityError(RuntimeError): +class ServiceWorkerIdentityError(RuntimeError): """Refuse polling when the worker's exact server identity is unavailable.""" -def managed_worker_name() -> str: +def service_worker_name() -> str: """Return a process-unique Prefect worker name for the supported entrypoint.""" return f"{_WORKER_NAME_PREFIX}-{uuid4()}" @@ -67,7 +67,7 @@ def _without_worker_id( return PrefectLogAdapter(logger.logger, extra=extra) -class ManagedProcessJobConfiguration(ProcessJobConfiguration): +class ServiceProcessJobConfiguration(ProcessJobConfiguration): """Carry the worker identity generation used to prepare this child.""" _identity_generation: int | None = PrivateAttr(default=None) @@ -116,10 +116,10 @@ def release(self) -> None: self._lock.release() -class ManagedProcessWorker(ProcessWorker): +class ServiceProcessWorker(ProcessWorker): """Resolve this process worker's server record before Prefect can poll runs.""" - job_configuration: type[ManagedProcessJobConfiguration] = ManagedProcessJobConfiguration + job_configuration: type[ServiceProcessJobConfiguration] = ServiceProcessJobConfiguration def __init__( # pylint: disable=too-many-positional-arguments self, @@ -150,8 +150,8 @@ def __init__( # pylint: disable=too-many-positional-arguments @classmethod def __dispatch_key__(cls) -> str | None: - """Keep the explicit managed entrypoint separate from Prefect's process key.""" - return "infrahub-sync-managed-process" + """Keep the explicit service entrypoint separate from Prefect's process key.""" + return "infrahub-sync-service-process" async def sync_with_backend(self) -> None: """Make readiness depend on a fresh heartbeat followed by identity resolution.""" @@ -185,7 +185,7 @@ async def _refresh_worker_identity(self) -> None: records = await self._read_worker_records() matches = [record for record in records if record.name == self.name] if len(matches) != 1: - raise ManagedWorkerIdentityError(_IDENTITY_ERROR) + raise ServiceWorkerIdentityError(_IDENTITY_ERROR) record = matches[0] worker_id = _canonical_uuid(record.id) record_pool_id = _canonical_uuid(record.work_pool_id) @@ -197,9 +197,9 @@ async def _refresh_worker_identity(self) -> None: or record_pool_id != expected_pool_id or record.status != WorkerStatus.ONLINE ): - raise ManagedWorkerIdentityError(_IDENTITY_ERROR) + raise ServiceWorkerIdentityError(_IDENTITY_ERROR) except (httpx.HTTPError, ObjectNotFound, AttributeError, TypeError, ValueError): - raise ManagedWorkerIdentityError(_IDENTITY_ERROR) from None + raise ServiceWorkerIdentityError(_IDENTITY_ERROR) from None self._record_worker_id(worker_id) def _record_worker_id(self, remote_id: UUID) -> None: @@ -218,7 +218,7 @@ def _submission_generation(self) -> int | None: async def get_and_submit_flow_runs(self) -> list[FlowRun]: generation = self._submission_generation() if generation is None: - self._logger.debug("Managed worker identity is unavailable; skipping flow run submission.") + self._logger.debug("Service worker identity is unavailable; skipping flow run submission.") self._last_polled_time = datetime.now(timezone.utc) return [] token = _SUBMISSION_IDENTITY.set((True, generation)) @@ -247,8 +247,8 @@ async def _submit_run_and_capture_errors( _SUBMISSION_IDENTITY.reset(token) def _validate_child_identity(self, configuration: ProcessJobConfiguration) -> None: - if not isinstance(configuration, ManagedProcessJobConfiguration): - raise ManagedWorkerIdentityError(_IDENTITY_ERROR) + if not isinstance(configuration, ServiceProcessJobConfiguration): + raise ServiceWorkerIdentityError(_IDENTITY_ERROR) if ( self._identity_refresh_requests or self._identity_refresh_active @@ -256,7 +256,7 @@ def _validate_child_identity(self, configuration: ProcessJobConfiguration) -> No or configuration._identity_generation != self._identity_generation or configuration.env.get("PREFECT__WORKER_ID") != str(self.backend_id) ): - raise ManagedWorkerIdentityError(_IDENTITY_ERROR) + raise ServiceWorkerIdentityError(_IDENTITY_ERROR) async def run( self, @@ -266,7 +266,7 @@ async def run( ) -> ProcessWorkerResult: """Start a child only while its prepared identity generation is current.""" if self._identity_refresh_requests or self._identity_refresh_active: - raise ManagedWorkerIdentityError(_IDENTITY_ERROR) + raise ServiceWorkerIdentityError(_IDENTITY_ERROR) await self._identity_lock.acquire() effective_status: TaskStatus[int] = task_status if task_status is not None else anyio.TASK_STATUS_IGNORED lease = _IdentityLeaseTaskStatus(effective_status, self._identity_lock) @@ -294,7 +294,7 @@ async def _read_worker_records(self) -> list[Worker]: def _pool_argument(argv: Sequence[str] | None = None) -> str: - parser = argparse.ArgumentParser(description="Start an Infrahub Sync managed process worker") + parser = argparse.ArgumentParser(description="Start an Infrahub Sync service process worker") parser.add_argument("--pool", required=True, help="existing Prefect process work pool") arguments = parser.parse_args(argv) pool = arguments.pool @@ -304,10 +304,10 @@ def _pool_argument(argv: Sequence[str] | None = None) -> str: def main(argv: Sequence[str] | None = None) -> int: - """Start one fail-closed managed process worker.""" - worker = ManagedProcessWorker( + """Start one fail-closed service process worker.""" + worker = ServiceProcessWorker( work_pool_name=_pool_argument(argv), - name=managed_worker_name(), + name=service_worker_name(), create_pool_if_not_found=False, ) asyncio.run(worker.start()) diff --git a/tasks/linter.py b/tasks/linter.py index f9d6ef04..a92bc649 100644 --- a/tasks/linter.py +++ b/tasks/linter.py @@ -29,18 +29,18 @@ def _ty_check_command(python_major: int, python_minor: int) -> str: """Return the type-check command for the active supported runtime profile.""" if (python_major, python_minor) == (3, 10): - return "uv run ty check --exclude infrahub_sync/managed --exclude tests/managed ." + return "uv run ty check --exclude infrahub_sync/service --exclude tests/service ." return "uv run ty check ." def _pylint_command(python_major: int, python_minor: int) -> str: """Return the Pylint command for the active supported runtime profile. - The managed tree imports optional dependencies that only install on Python + The service tree imports optional dependencies that only install on Python 3.11+, so the documented 3.10 profile excludes it, mirroring the ty exclusion. """ if (python_major, python_minor) == (3, 10): - return "pylint --output-format=json2 --ignore-paths='^infrahub_sync/managed/' infrahub_sync/" + return "pylint --output-format=json2 --ignore-paths='^infrahub_sync/service/' infrahub_sync/" return "pylint --output-format=json2 infrahub_sync/" diff --git a/tasks/preview.py b/tasks/preview.py index 3419b385..d640353a 100644 --- a/tasks/preview.py +++ b/tasks/preview.py @@ -1,8 +1,8 @@ """Preview environment: one command from a fresh clone to a testable v3 stack. `invoke preview.up` brings up a disposable Infrahub instance and a dedicated -Prefect server (Docker), loads the example schema, starts the managed Sync HTTP -API and a Prefect worker from this checkout, applies the managed deployment, +Prefect server (Docker), loads the example schema, starts the Sync HTTP +API and a Prefect worker from this checkout, applies the service deployment, and finishes by running the preview smoke suite so a tester never receives an environment that has not just proven its own basics. @@ -22,11 +22,15 @@ import subprocess # noqa: S404 -- fixed argv process management for the local preview stack import time from pathlib import Path +from typing import TYPE_CHECKING from invoke import Context, task from .utils import ESCAPED_REPO_PATH +if TYPE_CHECKING: + import httpx + NAMESPACE = "INFRAHUB-SYNC-PREVIEW" REPO_ROOT = Path(__file__).parent.parent.resolve() DEV_DIR = REPO_ROOT / "development" @@ -51,11 +55,20 @@ EXPECT_MAIN_EMPTY_ENV = "INFRAHUB_SYNC_PREVIEW_EXPECT_MAIN_EMPTY" # Process name -> substring its command line must contain before a recorded pid # is treated as ours (guards against pid recycling by unrelated processes). -MANAGED_PROCESSES = { - "sync-api": "infrahub_sync.managed.serve", - "prefect-worker": "infrahub_sync.managed.worker", +SERVICE_PROCESSES = { + "sync-api": "infrahub_sync.service.serve", + "prefect-worker": "infrahub_sync.service.worker", } WAIT_TIMEOUT_SECONDS = 420 +# The pre-stability names this preview used to register and run under. `preview.up` +# reuses volumes and tolerates existing Prefect state, so a checkout that ran the old +# names can still hold a live deployment, work pool, worker, or host process under them. +# Nothing reads these to keep working: they exist only so the preview can refuse and +# name the reset that removes them. +LEGACY_FLOW_NAME = "infrahub-sync-managed" +LEGACY_WORKER_NAME_PREFIX = "infrahub-sync-managed-" +LEGACY_PROCESS_COMMANDS = ("infrahub_sync.managed.serve", "infrahub_sync.managed.worker") +RESET_COMMAND = "uv run invoke preview.down --volumes" class PreviewError(RuntimeError): @@ -107,7 +120,7 @@ def preview_urls(values: dict[str, str]) -> dict[str, str]: def _runtime_env(values: dict[str, str]) -> dict[str, str]: - """Environment for Sync processes: worker, managed API, CLI, and smoke.""" + """Environment for Sync processes: worker, Sync API, CLI, and smoke.""" urls = preview_urls(values) env = dict(os.environ) env.update( @@ -178,6 +191,8 @@ def ensure_smoke_branch(env: dict[str, str]) -> None: _SERVER_ERROR_FLOOR = 500 +_OK = 200 +_NOT_FOUND = 404 def _wait_for_http(url: str, description: str, timeout: int = WAIT_TIMEOUT_SECONDS) -> None: @@ -203,6 +218,117 @@ def _wait_for_http(url: str, description: str, timeout: int = WAIT_TIMEOUT_SECON raise PreviewError(msg) +def _prefect_call(method: str, url: str) -> httpx.Response: + """Read one Prefect resource; an unreachable server cannot prove the preview is clean.""" + import httpx # noqa: PLC0415 -- lazy so importing the tasks package never requires the service extras + + try: + return httpx.post(url, json={}, timeout=15) if method == "POST" else httpx.get(url, timeout=15) + except httpx.HTTPError as exc: + msg = f"Prefect could not be read for retired preview state at {url}: {exc}" + raise PreviewError(msg) from None + + +def _require_ok(response: httpx.Response, url: str) -> None: + if response.status_code != _OK: + msg = f"Prefect answered {response.status_code} for retired preview state at {url}: {response.text}" + raise PreviewError(msg) + + +def _prefect_holds(url: str) -> bool: + """Report whether Prefect still holds the named resource.""" + response = _prefect_call("GET", url) + if response.status_code == _NOT_FOUND: + return False + _require_ok(response, url) + return True + + +def _prefect_records(url: str, *, allow_missing: bool = False) -> list[dict[str, str]]: + """Return one Prefect filter result, or none when its container is absent.""" + response = _prefect_call("POST", url) + if allow_missing and response.status_code == _NOT_FOUND: + return [] + _require_ok(response, url) + return response.json() + + +def _legacy_prefect_state(prefect_api_url: str, work_pool_name: str) -> list[str]: + """Return every retired-name Prefect registration this server still holds.""" + base = prefect_api_url.rstrip("/") + findings: list[str] = [] + if _prefect_holds(f"{base}/deployments/name/{LEGACY_FLOW_NAME}/run"): + findings.append(f"deployment {LEGACY_FLOW_NAME}/run") + pools = _prefect_records(f"{base}/work_pools/filter") + legacy_pools = [pool["name"] for pool in pools if pool["name"].startswith(LEGACY_FLOW_NAME)] + findings.extend(f"work pool {name}" for name in legacy_pools) + for pool in (work_pool_name, *legacy_pools): + workers = _prefect_records(f"{base}/work_pools/{pool}/workers/filter", allow_missing=True) + findings.extend( + f"worker {worker['name']} in work pool {pool}" + for worker in workers + if worker["name"].startswith(LEGACY_WORKER_NAME_PREFIX) + ) + return findings + + +def _legacy_processes() -> tuple[tuple[int, str], ...]: + """Return running host processes whose command line names a retired module path.""" + probe = subprocess.run( + ["/bin/ps", "-A", "-o", "pid=,command="], + capture_output=True, + text=True, + check=False, + ) + if probe.returncode != 0: + msg = "the running process list could not be read, so retired preview processes cannot be ruled out" + raise PreviewError(msg) + matches: list[tuple[int, str]] = [] + for line in probe.stdout.splitlines(): + pid_text, _, command_line = line.strip().partition(" ") + if any(legacy in command_line for legacy in LEGACY_PROCESS_COMMANDS): + matches.append((int(pid_text), command_line)) + return tuple(matches) + + +def assert_no_legacy_state(prefect_api_url: str, work_pool_name: str) -> None: + """Refuse to start the preview while any retired-name state or process survives.""" + findings = [ + *_legacy_prefect_state(prefect_api_url, work_pool_name), + *(f"process {pid} ({command_line})" for pid, command_line in _legacy_processes()), + ] + if not findings: + return + detail = "; ".join(findings) + msg = ( + f"the preview still holds state from before the service rename ({detail}). " + f"Reset it with `{RESET_COMMAND}`, then run `invoke preview.up` again." + ) + raise PreviewError(msg) + + +def _stop_legacy_processes() -> None: + """Stop retired-name host processes no recorded pid names any more.""" + by_command: dict[str, list[int]] = {} + for pid, command_line in _legacy_processes(): + for legacy in LEGACY_PROCESS_COMMANDS: + if legacy in command_line: + by_command.setdefault(legacy, []).append(pid) + ambiguous = {command: pids for command, pids in by_command.items() if len(pids) > 1} + if ambiguous: + detail = "; ".join(f"{command}: pids {sorted(pids)}" for command, pids in sorted(ambiguous.items())) + msg = ( + f"more than one running process matches a retired preview module path ({detail}), " + "so the reset cannot tell which one is the preview's; stop them by hand and reset again" + ) + raise PreviewError(msg) + for command, pids in sorted(by_command.items()): + pid = pids[0] + print(f" - [{NAMESPACE}] Stopping retired process {command} (pid {pid})") + with contextlib.suppress(ProcessLookupError, PermissionError): + os.killpg(os.getpgid(pid), signal.SIGTERM) + + def _pid_file(name: str) -> Path: return STATE_DIR / f"{name}.pid" @@ -227,7 +353,7 @@ def _process_running(name: str) -> int | None: check=False, ) command_line = probe.stdout.strip() - if probe.returncode != 0 or MANAGED_PROCESSES[name] not in command_line: + if probe.returncode != 0 or SERVICE_PROCESSES[name] not in command_line: return None return pid @@ -282,6 +408,7 @@ def up(context: Context) -> None: _compose(context, f"up --detach --wait --wait-timeout {WAIT_TIMEOUT_SECONDS} --quiet-pull", values) _wait_for_http(f"{urls['infrahub']}/api/config", "Infrahub") _wait_for_http(f"{urls['prefect']}/api/health", "Prefect") + assert_no_legacy_state(env["PREFECT_API_URL"], values["PREVIEW_WORK_POOL"]) print(f" - [{NAMESPACE}] Loading the example schema") context.run( @@ -303,16 +430,16 @@ def up(context: Context) -> None: "run", "python", "-m", - "infrahub_sync.managed.worker", + "infrahub_sync.service.worker", "--pool", values["PREVIEW_WORK_POOL"], ], env, ) - print(f" - [{NAMESPACE}] Applying the managed deployment") + print(f" - [{NAMESPACE}] Applying the service deployment") context.run( - "uv run python -m infrahub_sync.managed.deploy", + "uv run python -m infrahub_sync.service.deploy", env=env, ) @@ -323,7 +450,7 @@ def up(context: Context) -> None: "run", "uvicorn", "--factory", - "infrahub_sync.managed.serve:build_app", + "infrahub_sync.service.serve:build_app", "--host", "127.0.0.1", "--port", @@ -331,7 +458,7 @@ def up(context: Context) -> None: ], env, ) - _wait_for_http(f"{urls['sync_api']}/openapi.json", "managed Sync API", timeout=90) + _wait_for_http(f"{urls['sync_api']}/openapi.json", "Sync API", timeout=90) _run_smoke(context, expect_main_empty=True) @@ -339,7 +466,7 @@ def up(context: Context) -> None: print(f" - [{NAMESPACE}] Preview environment ready") print(f" Infrahub UI: {urls['infrahub']} (admin / infrahub)") print(f" Prefect UI: {urls['prefect']}") - print(f" Managed Sync API: {urls['sync_api']} (bearer principals: {', '.join(sorted(tokens))})") + print(f" Sync API: {urls['sync_api']} (bearer principals: {', '.join(sorted(tokens))})") print(f" Config directory: {env['INFRAHUB_SYNC_CONFIG_DIRECTORY']}") print(f" Runtime state: {STATE_DIR}") print(" Next: docs/docs/reference/managed-http-api.mdx and `uv run invoke preview.status`") @@ -381,7 +508,7 @@ def status(context: Context) -> None: values = load_preview_env() urls = preview_urls(values) _compose(context, "ps", values) - for name in MANAGED_PROCESSES: + for name in SERVICE_PROCESSES: pid = _process_running(name) state = f"running (pid {pid})" if pid else "stopped" print(f" - [{NAMESPACE}] {name}: {state}") @@ -391,10 +518,10 @@ def status(context: Context) -> None: @task def logs(context: Context, name: str = "sync-api", lines: int = 50) -> None: - """Print the tail of a managed host process log (sync-api or prefect-worker).""" + """Print the tail of a service host process log (sync-api or prefect-worker).""" del context - if name not in MANAGED_PROCESSES: - msg = f"unknown process {name!r}; expected one of {sorted(MANAGED_PROCESSES)}" + if name not in SERVICE_PROCESSES: + msg = f"unknown process {name!r}; expected one of {sorted(SERVICE_PROCESSES)}" raise PreviewError(msg) log_path = _log_file(name) if not log_path.exists(): @@ -409,8 +536,10 @@ def logs(context: Context, name: str = "sync-api", lines: int = 50) -> None: def down(context: Context, volumes: bool = False) -> None: # noqa: FBT001, FBT002 -- Invoke boolean flag idiom """Stop the preview: host processes, then containers (add --volumes to reset data).""" values = load_preview_env() - for name in MANAGED_PROCESSES: + for name in SERVICE_PROCESSES: _stop_process(name) + if volumes: + _stop_legacy_processes() arguments = "down --volumes" if volumes else "down" _compose(context, arguments, values) print(f" - [{NAMESPACE}] Preview stopped{' and data volumes removed' if volumes else ''}") diff --git a/tasks/tests.py b/tasks/tests.py index d8dabd3d..bbc53980 100644 --- a/tasks/tests.py +++ b/tasks/tests.py @@ -17,11 +17,7 @@ def tests_unit(context: Context) -> None: """Run unit tests — everything under tests/ except integration-marked tests.""" command = 'pytest -m "not integration and not preview"' if sys.version_info < (3, 11): - command += ( - " --ignore=tests/managed" - " --ignore=tests/conformance/test_managed_equivalence.py" - " --ignore=tests/runtime_schema/test_worker_path.py" - ) + command += " --ignore=tests/service --ignore=tests/runtime_schema/test_worker_path.py" with context.cd(MAIN_DIRECTORY): context.run(command, pty=True) diff --git a/tests/cli/test_parity_and_closure.py b/tests/cli/test_parity_and_closure.py index cd38f404..0e5785c2 100644 --- a/tests/cli/test_parity_and_closure.py +++ b/tests/cli/test_parity_and_closure.py @@ -256,8 +256,8 @@ def test_netbox_tutorial_starts_and_authenticates_the_service_boundary() -> None "infrahub-sync[managed]", "prefect server start", "prefect worker start", - "infrahub_sync.managed.deploy", - "infrahub_sync.managed.serve", + "infrahub_sync.service.deploy", + "infrahub_sync.service.serve", "INFRAHUB_SYNC_MANAGED_BEARER_TOKENS", "INFRAHUB_SYNC_API_URL", "INFRAHUB_SYNC_API_TOKEN", diff --git a/tests/client/test_models.py b/tests/client/test_models.py index 40a852a5..7d031f05 100644 --- a/tests/client/test_models.py +++ b/tests/client/test_models.py @@ -11,8 +11,8 @@ from pydantic import ValidationError from infrahub_sync.client.models import OrchestrationSummary, PublicRunResource -from infrahub_sync.managed.models import public_run_resource from infrahub_sync.product_store.models import PrefectExecutionLink, ProductRun +from infrahub_sync.service.models import public_run_resource def _package_imports(root: Path, package: str) -> set[str]: @@ -38,7 +38,7 @@ def test_client_package_imports_no_product_or_service_module() -> None: imports = _package_imports(Path("infrahub_sync/client"), "infrahub_sync.client") assert not {name for name in imports if name.startswith("infrahub_sync.product_store")} - assert not {name for name in imports if name.startswith("infrahub_sync.managed")} + assert not {name for name in imports if name.startswith("infrahub_sync.service")} assert not {name for name in imports if name.startswith("infrahub_sync.adapters")} assert not {name for name in imports if name.startswith("infrahub_sync.execution")} assert not { diff --git a/tests/conformance/oracle.py b/tests/conformance/oracle.py index 3ee92633..ce290887 100644 --- a/tests/conformance/oracle.py +++ b/tests/conformance/oracle.py @@ -6,7 +6,7 @@ from dataclasses import asdict, dataclass from typing import Any, Literal, cast -Surface = Literal["cli", "python", "managed"] +Surface = Literal["cli", "python", "service"] RUN_ID_SCHEMA_PATHS = ( ("product_record", "run_id"), diff --git a/tests/conformance/test_managed_equivalence.py b/tests/conformance/test_managed_equivalence.py deleted file mode 100644 index 13d0ee6c..00000000 --- a/tests/conformance/test_managed_equivalence.py +++ /dev/null @@ -1,133 +0,0 @@ -"""Optional managed-to-standalone DB-003 record/artifact equivalence proof.""" - -from __future__ import annotations - -import logging -from datetime import datetime, timezone -from pathlib import Path -from typing import cast - -import pytest - -pytest.importorskip("prefect") -pytest.importorskip("opsmill_prefect_extras") - -from infrahub_sync import SyncAdapter, SyncInstance -from infrahub_sync.managed import flow as managed_flow -from infrahub_sync.plan.config_version import resolve_config_version -from infrahub_sync.plan.models import PlanManifest -from infrahub_sync.plan.review import SavedPlan -from infrahub_sync.product_store import PrefectExecutionLink, ProductProjection, ProductRun, local_product_projection -from infrahub_sync.product_store.standalone import execute_standalone -from tests.configuration.validation_packages import package, package_data - -FLOW_RUN_ID = "ed4778cb-f2cf-4b1f-a87b-68be37659e93" -WORKER_ID = "8c1da53d-0e6b-4d3d-a0f1-97b6a9ccebf0" - - -def _register_inventory(projection: ProductProjection) -> tuple[str, int, str]: - declared = package_data() - declared["configuration"]["name"] = "inventory" - registered = projection.create_configuration(package(declared)) - return registered.config_id, registered.registry_version, registered.package_checksum - - -def test_managed_and_standalone_plan_product_projection_seams_match( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - run_id = "run-cross-interface-plan" - instance = SyncInstance( - name="inventory", - directory=str(tmp_path), - source=SyncAdapter(name="source", settings={}), - destination=SyncAdapter(name="destination", settings={}), - ) - configuration_reference = resolve_config_version(instance) - saved = SavedPlan( - manifest=PlanManifest( - format_version=2, - run_id=run_id, - created_at="2026-08-10T12:00:00+00:00", - config_version=configuration_reference, - source_snapshot=[], - operations_count=0, - delete_operations_computed=True, - plan_checksum="a" * 64, - ), - operations=[], - checksum_ok=True, - verification_notes=[], - ) - standalone_cache = (tmp_path / "standalone").resolve() - managed_cache = (tmp_path / "managed").resolve() - managed_projection = local_product_projection(managed_cache) - binding = _register_inventory(managed_projection) - managed_projection.create_run( - ProductRun( - run_id=run_id, - operation="plan", - configuration_reference=f"{binding[0]}@{binding[1]}", - config_id=binding[0], - registry_version=binding[1], - package_checksum=binding[2], - started_at=datetime.now(timezone.utc), - phase="accepted", - summary={"sync_name": "inventory"}, - ) - ) - managed_projection.add_prefect_execution( - run_id, - PrefectExecutionLink( - flow_run_id=FLOW_RUN_ID, purpose="plan", attempt=1, submitted_at=datetime.now(timezone.utc) - ), - ) - - monkeypatch.setattr("infrahub_sync.product_store.standalone.execute_run", lambda *_args, **_kwargs: saved) - execute_standalone( - instance, - operation="plan", - run_id=run_id, - product_cache_location=standalone_cache, - _return_saved_plan=True, - ) - - monkeypatch.setattr(managed_flow, "_runtime", lambda: (str(tmp_path), managed_projection)) - monkeypatch.setattr(managed_flow, "_run_logger", lambda: (logging.getLogger("test-conformance"), False)) - monkeypatch.setenv("PREFECT__WORKER_ID", WORKER_ID) - monkeypatch.setattr(managed_flow, "_prefect_flow_run_id", lambda: FLOW_RUN_ID) - monkeypatch.setattr(managed_flow, "_require_current_worker_identity", lambda *_args: None) - monkeypatch.setattr(managed_flow, "resolve_runtime_instance", lambda *_args, **_kwargs: instance) - monkeypatch.setattr(managed_flow, "build_runtime_model_plan", lambda **_kwargs: object()) - monkeypatch.setattr(managed_flow, "collect_secret_values", lambda _instance=None: ()) - monkeypatch.setattr(managed_flow, "_plan", lambda *_args, **_kwargs: saved) - managed_flow.managed_sync_run.fn(run_id, "plan", *binding) - - standalone_projection = local_product_projection(standalone_cache) - standalone_record = standalone_projection.lookup_run(run_id).value - managed_record = managed_projection.lookup_run(run_id).value - standalone_artifact = standalone_projection.lookup_artifact(run_id, "plan-review").value - managed_artifact = managed_projection.lookup_artifact(run_id, "plan-review").value - assert standalone_record is not None - assert managed_record is not None - assert standalone_artifact is not None - assert managed_artifact is not None - - def stable_product_record(record: ProductRun) -> dict[str, object]: - data = record.model_dump(mode="json") - data["started_at"] = "" - data["finished_at"] = "" - references = cast("list[dict[str, object]]", data["artifact_refs"]) - for reference in references: - reference["created_at"] = "" - return data - - standalone_data = stable_product_record(standalone_record) - managed_data = stable_product_record(managed_record) - for key in ("config_id", "registry_version", "package_checksum"): - assert managed_data[key] is not None - managed_data[key] = None - managed_data["configuration_reference"] = configuration_reference - managed_data["prefect_executions"] = [] - assert standalone_data == managed_data - assert standalone_artifact == managed_artifact diff --git a/tests/conformance/test_oracle.py b/tests/conformance/test_oracle.py index 43344f75..5e7e536a 100644 --- a/tests/conformance/test_oracle.py +++ b/tests/conformance/test_oracle.py @@ -56,17 +56,17 @@ def _envelope(surface: Surface) -> CanonicalEnvelope: def test_oracle_normalizes_only_generated_ids_and_timestamps() -> None: - assert_equivalent([_envelope("cli"), _envelope("python"), _envelope("managed")]) + assert_equivalent([_envelope("cli"), _envelope("python"), _envelope("service")]) def test_oracle_refuses_to_hide_a_named_product_field_disagreement() -> None: cli = _envelope("cli") - managed_data = dict(deepcopy(_envelope("managed").product_record)) - managed_data["configuration_reference"] = "different-configuration" - managed = replace(_envelope("managed"), product_record=managed_data) + service_data = dict(deepcopy(_envelope("service").product_record)) + service_data["configuration_reference"] = "different-configuration" + service = replace(_envelope("service"), product_record=service_data) with pytest.raises(AssertionError, match="canonical interface disagreement"): - assert_equivalent([cli, managed]) + assert_equivalent([cli, service]) @pytest.mark.parametrize( @@ -75,17 +75,17 @@ def test_oracle_refuses_to_hide_a_named_product_field_disagreement() -> None: ) def test_oracle_does_not_normalize_semantic_payload_keys(container: str, nested_field: str) -> None: cli = _envelope("cli") - managed = _envelope("managed") + service = _envelope("service") cli_data = dict(deepcopy(getattr(cli, container))) - managed_data = dict(deepcopy(getattr(managed, container))) + service_data = dict(deepcopy(getattr(service, container))) cli_data["payload"] = {nested_field: "semantic-a"} - managed_data["payload"] = {nested_field: "semantic-b"} + service_data["payload"] = {nested_field: "semantic-b"} with pytest.raises(AssertionError, match="canonical interface disagreement"): assert_equivalent( [ replace(cli, **{container: cli_data}), - replace(managed, **{container: managed_data}), + replace(service, **{container: service_data}), ] ) diff --git a/tests/integration/test_managed_prefect_idempotency.py b/tests/integration/test_service_prefect_idempotency.py similarity index 87% rename from tests/integration/test_managed_prefect_idempotency.py rename to tests/integration/test_service_prefect_idempotency.py index 45b22d2a..efbc98f8 100644 --- a/tests/integration/test_managed_prefect_idempotency.py +++ b/tests/integration/test_service_prefect_idempotency.py @@ -1,4 +1,4 @@ -"""Authorized temporary-server proof for managed Prefect idempotency.""" +"""Authorized temporary-server proof for service Prefect idempotency.""" from __future__ import annotations @@ -14,9 +14,9 @@ from prefect.client.orchestration import get_client from prefect.testing.utilities import prefect_test_harness -from infrahub_sync.managed.orchestration import ( - MANAGED_DEPLOYMENT_NAME, - MANAGED_FLOW_NAME, +from infrahub_sync.service.orchestration import ( + SERVICE_DEPLOYMENT_NAME, + SERVICE_FLOW_NAME, PrefectOrchestration, ) @@ -46,8 +46,8 @@ async def test_same_opaque_key_creates_one_prefect_flow_run(isolated_prefect_ser "confirm_writes": False, } async with get_client() as client: - flow_id = await client.create_flow_from_name(MANAGED_FLOW_NAME) - deployment_id = await client.create_deployment(flow_id, name=MANAGED_DEPLOYMENT_NAME) + flow_id = await client.create_flow_from_name(SERVICE_FLOW_NAME) + deployment_id = await client.create_deployment(flow_id, name=SERVICE_DEPLOYMENT_NAME) orchestration = PrefectOrchestration(client) first = await orchestration.submit(parameters, idempotency_key="opaque-server-proof") diff --git a/tests/integration/test_managed_storage_integration.py b/tests/integration/test_service_storage_integration.py similarity index 87% rename from tests/integration/test_managed_storage_integration.py rename to tests/integration/test_service_storage_integration.py index b1f67205..e1f748bc 100644 --- a/tests/integration/test_managed_storage_integration.py +++ b/tests/integration/test_service_storage_integration.py @@ -1,4 +1,4 @@ -"""Opt-in proof that independently composed managed processes share durable storage.""" +"""Opt-in proof that independently composed service processes share durable storage.""" from __future__ import annotations @@ -10,8 +10,8 @@ pytest.importorskip("boto3") pytest.importorskip("psycopg") -from infrahub_sync.managed.storage import managed_product_projection from infrahub_sync.product_store import ProductRun +from infrahub_sync.service.storage import service_product_projection from tests.configuration.validation_packages import package pytestmark = pytest.mark.integration @@ -27,7 +27,7 @@ def _settings_or_skip() -> dict[str, str]: """Return dedicated disposable-store settings, or skip before any network client exists.""" values = {name: os.environ.get(name, "") for name in _REQUIRED_ENVIRONMENT} if missing := [name for name, value in values.items() if not value]: - pytest.skip(f"managed storage integration requires explicit settings; missing: {', '.join(missing)}") + pytest.skip(f"service storage integration requires explicit settings; missing: {', '.join(missing)}") return { "INFRAHUB_SYNC_DATABASE_URL": values["INFRAHUB_SYNC_STORAGE_INTEGRATION_DATABASE_URL"], "INFRAHUB_SYNC_S3_BUCKET": values["INFRAHUB_SYNC_STORAGE_INTEGRATION_S3_BUCKET"], @@ -40,10 +40,10 @@ def _settings_or_skip() -> dict[str, str]: def test_independent_managed_projections_share_configurations_runs_and_artifacts() -> None: """API-like and worker-like composition roots observe one PostgreSQL/S3 record set.""" settings = _settings_or_skip() - api_projection = managed_product_projection(environ=settings) - worker_projection = managed_product_projection(environ=settings) + api_projection = service_product_projection(environ=settings) + worker_projection = service_product_projection(environ=settings) version = api_projection.create_configuration(package()) - run_id = f"managed-storage-integration-{version.config_id}" + run_id = f"service-storage-integration-{version.config_id}" expected_run = ProductRun( run_id=run_id, operation="plan", diff --git a/tests/managed/__init__.py b/tests/managed/__init__.py deleted file mode 100644 index 5bd61ddc..00000000 --- a/tests/managed/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Managed API and flow tests.""" diff --git a/tests/preview/conftest.py b/tests/preview/conftest.py index 9da52e24..c94a5c1a 100644 --- a/tests/preview/conftest.py +++ b/tests/preview/conftest.py @@ -1,7 +1,7 @@ """Fixtures for the preview smoke suite. These tests run against the environment `invoke preview.up` starts and confirm -the simple things on every preview surface (Python API, managed HTTP API, +the simple things on every preview surface (Python API, Sync HTTP API, Prefect) so a tester never starts from a broken environment. They are not a replacement for human testing, and they skip — never fail — when the preview environment is not running. @@ -48,7 +48,7 @@ def preview_env(preview_settings: dict[str, Any]) -> dict[str, Any]: probes = { "Infrahub": f"{preview_settings['urls']['infrahub']}/api/config", "Prefect": f"{preview_settings['urls']['prefect']}/api/health", - "managed Sync API": f"{preview_settings['urls']['sync_api']}/openapi.json", + "Sync API": f"{preview_settings['urls']['sync_api']}/openapi.json", } for description, url in probes.items(): try: diff --git a/tests/preview/test_prefect_surface.py b/tests/preview/test_prefect_surface.py index 0141ab60..8490b223 100644 --- a/tests/preview/test_prefect_surface.py +++ b/tests/preview/test_prefect_surface.py @@ -1,4 +1,4 @@ -"""Prefect surface: the managed deployment is applied and executes flow runs.""" +"""Prefect surface: the service deployment is applied and executes flow runs.""" from __future__ import annotations @@ -13,7 +13,7 @@ def _managed_deployment(preview_env: dict[str, Any]) -> dict[str, Any]: response = httpx.get( - f"{preview_env['urls']['prefect']}/api/deployments/name/infrahub-sync-managed/run", + f"{preview_env['urls']['prefect']}/api/deployments/name/infrahub-sync-service/run", timeout=15, ) assert response.status_code == 200, response.text @@ -34,16 +34,16 @@ def test_the_managed_deployment_carries_no_static_worker_identity(preview_env: d def test_managed_flow_runs_execute_and_complete(preview_env: dict[str, Any]) -> None: - """After the managed-API smoke, the managed deployment must hold a completed run. + """After the Sync API smoke, the service deployment must hold a completed run. - Scoped to the managed deployment's own flow runs: an unrelated run — a CLI-driven + Scoped to the service deployment's own flow runs: an unrelated run — a CLI-driven flow, or anything else sharing the preview's Prefect server — must neither satisfy this check nor fail it. - The poll makes no assertions until the newest managed run reaches a terminal state + The poll makes no assertions until the newest service run reaches a terminal state (or the deadline passes): an in-flight run is expected — Prefect records terminal states a moment after the Sync record finishes, and an apply may legitimately still - be running when this test starts. The timeout matches the managed-API run budget. + be running when this test starts. The timeout matches the Sync API run budget. """ deployment_id = _managed_deployment(preview_env)["id"] newest: dict[str, Any] = {} @@ -65,10 +65,10 @@ def test_managed_flow_runs_execute_and_complete(preview_env: dict[str, Any]) -> if newest and newest["state_type"] not in {"PENDING", "RUNNING", "SCHEDULED"}: break time.sleep(3) - assert flow_runs, "the managed deployment recorded no flow runs; the managed path has not executed" + assert flow_runs, "the service deployment recorded no flow runs; the service path has not executed" states = {run["state_type"] for run in flow_runs} - assert "COMPLETED" in states, f"no completed managed flow runs; observed states: {sorted(states)}" + assert "COMPLETED" in states, f"no completed service flow runs; observed states: {sorted(states)}" assert newest["state_type"] == "COMPLETED", ( - f"the most recent managed flow run is {newest['state_type']}; " + f"the most recent service flow run is {newest['state_type']}; " "stale failures from earlier sessions are tolerated, a fresh one is not" ) diff --git a/tests/preview/test_preview_configuration.py b/tests/preview/test_preview_configuration.py index c4849a5f..1ee7643d 100644 --- a/tests/preview/test_preview_configuration.py +++ b/tests/preview/test_preview_configuration.py @@ -41,7 +41,7 @@ def test_preview_routes_prefect_ui_to_the_published_host_port() -> None: def test_preview_declares_the_managed_postgresql_and_minio_storage_shape() -> None: - """Preview supplies storage and liveness settings to both managed processes.""" + """Preview supplies storage and liveness settings to both service processes.""" compose = (DEV_DIR / "docker-compose.preview.yml").read_text(encoding="utf-8") environment = preview._runtime_env( { diff --git a/tests/preview/test_preview_legacy_state.py b/tests/preview/test_preview_legacy_state.py index d278ec07..e616bc5f 100644 --- a/tests/preview/test_preview_legacy_state.py +++ b/tests/preview/test_preview_legacy_state.py @@ -24,16 +24,18 @@ LEGACY_SERVE_COMMAND = f"python -m {preview.LEGACY_PROCESS_COMMANDS[0]}" LEGACY_WORKER_COMMAND = f"python -m {preview.LEGACY_PROCESS_COMMANDS[1]} --pool {WORK_POOL}" +_Payload = dict[str, str] | list[dict[str, str]] + class _Response: """Minimal stand-in for the Prefect responses the preflight reads.""" - def __init__(self, status_code: int, payload: Any = None) -> None: + def __init__(self, status_code: int, payload: _Payload) -> None: self.status_code = status_code self._payload = payload self.text = json.dumps(payload) - def json(self) -> Any: + def json(self) -> _Payload: return self._payload @@ -47,11 +49,11 @@ def _prefect_server( """Answer the preflight's three Prefect reads from a declared server state.""" registrations = workers or {} - def _get(url: str, **_kwargs: Any) -> _Response: + def _get(url: str, **_kwargs: object) -> _Response: assert url.endswith(f"/deployments/name/{preview.LEGACY_FLOW_NAME}/run") return _Response(200, {"id": "d-1"}) if deployment else _Response(404, {}) - def _post(url: str, **_kwargs: Any) -> _Response: + def _post(url: str, **_kwargs: object) -> _Response: if url.endswith("/work_pools/filter"): return _Response(200, [{"name": name} for name in pools]) pool = url.removeprefix(f"{PREFECT_API}/work_pools/").removesuffix("/workers/filter") @@ -68,9 +70,9 @@ def _process_list(monkeypatch: pytest.MonkeyPatch, *entries: tuple[int, str]) -> monkeypatch.setattr(preview, "_legacy_processes", lambda: tuple(entries)) -def _staged_up(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> list[str]: +def _staged_up(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, started: list[str] | None = None) -> list[str]: """Run `preview.up` far enough to reach the preflight, recording what it starts.""" - started: list[str] = [] + started = [] if started is None else started monkeypatch.setattr(preview, "STATE_DIR", tmp_path / ".preview") monkeypatch.setattr( preview, @@ -111,14 +113,16 @@ def run(self, command: str, **kwargs: Any) -> None: # noqa: ANN401, PLR6301 - I def test_up_refuses_a_legacy_deployment_and_names_the_reset_command( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: + started: list[str] = [] _prefect_server(monkeypatch, deployment=True) _process_list(monkeypatch) with pytest.raises(PreviewError) as refusal: - _staged_up(monkeypatch, tmp_path) + _staged_up(monkeypatch, tmp_path, started) assert f"{preview.LEGACY_FLOW_NAME}/run" in str(refusal.value) assert RESET_COMMAND in str(refusal.value) + assert started == [] def test_up_refuses_a_running_legacy_host_process(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: @@ -132,15 +136,6 @@ def test_up_refuses_a_running_legacy_host_process(monkeypatch: pytest.MonkeyPatc assert RESET_COMMAND in str(refusal.value) -def test_up_starts_nothing_when_it_refuses(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - _prefect_server(monkeypatch, deployment=True) - _process_list(monkeypatch) - - with pytest.raises(PreviewError): - started = _staged_up(monkeypatch, tmp_path) - assert started == [] - - def test_up_proceeds_from_a_legacy_clean_environment(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: _prefect_server(monkeypatch, workers={WORK_POOL: ("infrahub-sync-service-1",)}) _process_list(monkeypatch) diff --git a/tests/preview/test_preview_worker_identity.py b/tests/preview/test_preview_worker_identity.py index 4e645219..0a65e634 100644 --- a/tests/preview/test_preview_worker_identity.py +++ b/tests/preview/test_preview_worker_identity.py @@ -1,4 +1,4 @@ -"""Preview starts the supported managed worker without static identity plumbing.""" +"""Preview starts the supported service worker without static identity plumbing.""" from __future__ import annotations @@ -43,6 +43,7 @@ def _staged_up(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> dict[str, Any ) monkeypatch.setattr(preview, "_compose", lambda *_args, **_kwargs: None) monkeypatch.setattr(preview, "_wait_for_http", lambda *_args, **_kwargs: None) + monkeypatch.setattr(preview, "assert_no_legacy_state", lambda *_args, **_kwargs: None) monkeypatch.setattr(preview, "_run_smoke", lambda *_args, **_kwargs: None) def _start(name: str, argv: list[str], env: dict[str, str]) -> None: @@ -53,7 +54,7 @@ def _start(name: str, argv: list[str], env: dict[str, str]) -> None: class _RecordingContext(Context): def run(self, command: str, **kwargs: Any) -> None: # noqa: ANN401, PLR6301 - Invoke surface. - if "managed.deploy" in command: + if "service.deploy" in command: captured["deploy_env"] = kwargs.get("env", {}) cast("Task", preview.up).body(_RecordingContext()) @@ -71,7 +72,7 @@ def test_preview_starts_the_supported_managed_worker_entrypoint( "run", "python", "-m", - "infrahub_sync.managed.worker", + "infrahub_sync.service.worker", "--pool", "preview-pool", ] diff --git a/tests/preview/test_managed_api.py b/tests/preview/test_service_api.py similarity index 99% rename from tests/preview/test_managed_api.py rename to tests/preview/test_service_api.py index 85c65cb9..0e4853aa 100644 --- a/tests/preview/test_managed_api.py +++ b/tests/preview/test_service_api.py @@ -1,4 +1,4 @@ -"""Managed HTTP API surface: auth boundary and the full registered run lifecycle. +"""Sync API surface: auth boundary and the full registered run lifecycle. The shipped API is registered-only: a run names a registered configuration version, not a directory on the worker's disk. So the smoke registers its own package first, through @@ -101,7 +101,7 @@ def create_run_request(config_id: str, registry_version: int) -> dict[str, Any]: "config_id": config_id, "registry_version": registry_version, "branch": SMOKE_BRANCH, - "reason": "preview smoke: create a managed plan", + "reason": "preview smoke: create a service plan", } diff --git a/tests/preview/test_smoke_request_shapes.py b/tests/preview/test_smoke_request_shapes.py index 0853c624..382a9554 100644 --- a/tests/preview/test_smoke_request_shapes.py +++ b/tests/preview/test_smoke_request_shapes.py @@ -15,7 +15,7 @@ from infrahub_sync.client.models import ApplyRunRequest, ConfigMutationRequest, CreateRunRequest from tasks.preview import SHARED_DEVICE_NAME -from tests.preview import test_managed_api as smoke +from tests.preview import test_service_api as smoke INFRAHUB_URL = "http://localhost:8080" CHECKSUM = "a" * 64 diff --git a/tests/product_store/test_contract.py b/tests/product_store/test_contract.py index c7dfb530..bc7f8ca2 100644 --- a/tests/product_store/test_contract.py +++ b/tests/product_store/test_contract.py @@ -1155,7 +1155,7 @@ def test_execution_link_refuses_noncanonical_worker_identity_with_fixed_error() } ) errors = caught.value.errors(include_input=False) - assert errors[0]["msg"] == "Value error, managed worker identity is invalid" + assert errors[0]["msg"] == "Value error, service worker identity is invalid" def test_new_execution_requires_submitted_at_without_persistence(provider: ProductProjection) -> None: @@ -1528,7 +1528,7 @@ def test_execution_claim_refuses_invalid_worker_identity_without_mutation(provid "run-001", PrefectExecutionLink(flow_run_id="flow-001", purpose="plan", attempt=1, submitted_at=now) ) - with pytest.raises(ValueError, match="managed worker identity is invalid"): + with pytest.raises(ValueError, match="service worker identity is invalid"): provider.claim_execution("run-001", "flow-001", worker_id="not-a-uuid", claimed_at=now) loaded = provider.lookup_run("run-001").value @@ -2391,7 +2391,7 @@ def test_prefect_position_conflict_retries_without_misreporting_a_duplicate(tmp_ assert [link.flow_run_id for link in loaded.prefect_executions] == ["flow-001"] -def test_managed_prefect_attempt_ordinals_are_allocated_atomically(provider: ProductProjection) -> None: +def test_service_prefect_attempt_ordinals_are_allocated_atomically(provider: ProductProjection) -> None: provider.create_run(_run()) def append(position: int) -> PrefectExecutionLink: @@ -4283,7 +4283,7 @@ def test_postgresql_run_store_initializes_against_a_real_server() -> None: # pylint: disable-next=import-outside-toplevel,import-error import psycopg # ty: ignore[unresolved-import] - TODO: optional managed dependency - from infrahub_sync.managed.storage import PsycopgConnectionFactory + from infrahub_sync.service.storage import PsycopgConnectionFactory def connect() -> DBAPIConnection: return PsycopgConnectionFactory(psycopg.connect)(dsn) diff --git a/tests/runtime_schema/test_worker_path.py b/tests/runtime_schema/test_worker_path.py index 331293b3..538d3f2b 100644 --- a/tests/runtime_schema/test_worker_path.py +++ b/tests/runtime_schema/test_worker_path.py @@ -43,7 +43,7 @@ pytest.importorskip("prefect") pytest.importorskip("opsmill_prefect_extras") -from infrahub_sync.managed import flow as managed_flow +from infrahub_sync.service import flow as service_flow _SNAPSHOT: dict[str, Any] = { "BuiltinTag": { @@ -161,7 +161,7 @@ def test_registered_composition_attaches_the_plan_to_the_runtime_instance(spy: _ binding = ("cfg-runtime-models", 1, package.checksum()) projection = _StubProjection(package, binding) - _, instance, name = managed_flow._worker_execution_context( + _, instance, name = service_flow._worker_execution_context( "run-runtime-models", binding, config_directory=str(tmp_path), @@ -187,7 +187,7 @@ def test_a_legacy_unregistered_run_builds_no_runtime_models(spy: _SnapshotSpy, t reference = resolve_config_version(resolve_sync_instance("from-netbox", directory=str(tmp_path))) projection = _StubProjection(package, None, sync_name="from-netbox", configuration_reference=reference) - _, instance, _ = managed_flow._worker_execution_context( + _, instance, _ = service_flow._worker_execution_context( "legacy-run", None, config_directory=str(tmp_path), @@ -217,19 +217,19 @@ def _record(instance: SyncInstance, **kwargs: object) -> SavedPlan | RunResult: def _skip(*_args: object, **_kwargs: object) -> None: return None - monkeypatch.setattr(managed_flow, "execute_run", _record) - monkeypatch.setattr(managed_flow, "_publish_plan", _skip) - monkeypatch.setattr(managed_flow, "_verify_registered_apply", _skip) - monkeypatch.setattr(managed_flow, "_require_planned_schema", _skip) + monkeypatch.setattr(service_flow, "execute_run", _record) + monkeypatch.setattr(service_flow, "_publish_plan", _skip) + monkeypatch.setattr(service_flow, "_verify_registered_apply", _skip) + monkeypatch.setattr(service_flow, "_require_planned_schema", _skip) - result, _ = managed_flow._execute_stage( + result, _ = service_flow._execute_stage( "run-composed-sync", "sync", *binding, None, None, confirm_writes=True, - run_logger=managed_flow.logger, + run_logger=service_flow.logger, secrets=[], config_directory=str(tmp_path), projection=cast("ProductProjection", projection), @@ -256,7 +256,7 @@ def test_only_stages_that_construct_adapters_read_the_schema( binding = ("cfg-runtime-models", 1, package.checksum()) projection = _StubProjection(package, binding) - _, instance, _ = managed_flow._worker_execution_context( + _, instance, _ = service_flow._worker_execution_context( f"run-{stage}", binding, config_directory=str(tmp_path), @@ -276,7 +276,7 @@ def test_a_saved_plan_apply_builds_no_source_requirements(spy: _SnapshotSpy, tmp binding = ("cfg-runtime-models", 1, package.checksum()) projection = _StubProjection(package, binding) - _, instance, _ = managed_flow._worker_execution_context( + _, instance, _ = service_flow._worker_execution_context( "run-apply", binding, config_directory=str(tmp_path), @@ -563,7 +563,7 @@ def _forbidden(*args: object, **kwargs: object) -> None: def _saved_plan() -> SavedPlan: - """The smallest real saved plan the managed stage's assertions accept.""" + """The smallest real saved plan the service stage's assertions accept.""" return SavedPlan( manifest=PlanManifest( format_version=1, diff --git a/tests/service/__init__.py b/tests/service/__init__.py new file mode 100644 index 00000000..28acdff1 --- /dev/null +++ b/tests/service/__init__.py @@ -0,0 +1 @@ +"""Sync API and flow tests.""" diff --git a/tests/managed/test_compatibility.py b/tests/service/test_compatibility.py similarity index 91% rename from tests/managed/test_compatibility.py rename to tests/service/test_compatibility.py index 5e649db5..e4ad5a7d 100644 --- a/tests/managed/test_compatibility.py +++ b/tests/service/test_compatibility.py @@ -4,7 +4,7 @@ import pytest -from infrahub_sync.managed import compatibility +from infrahub_sync.service import compatibility @pytest.mark.parametrize( @@ -20,7 +20,7 @@ def fail_version(_distribution_name: str) -> str: monkeypatch.setattr(compatibility, "version", fail_version) - with pytest.raises(RuntimeError, match=r"^managed package metadata is unavailable$") as caught: + with pytest.raises(RuntimeError, match=r"^service package metadata is unavailable$") as caught: compatibility.installed_server_version() assert caught.value.__cause__ is None @@ -45,7 +45,7 @@ def test_installed_server_version_rejects_invalid_metadata_values( ) -> None: monkeypatch.setattr(compatibility, "version", lambda _distribution_name: metadata_value) - with pytest.raises(RuntimeError, match=r"^managed package metadata is unavailable$") as caught: + with pytest.raises(RuntimeError, match=r"^service package metadata is unavailable$") as caught: compatibility.installed_server_version() assert caught.value.__cause__ is None diff --git a/tests/managed/test_config_routes.py b/tests/service/test_config_routes.py similarity index 95% rename from tests/managed/test_config_routes.py rename to tests/service/test_config_routes.py index 9dcca9c6..ad080b8e 100644 --- a/tests/managed/test_config_routes.py +++ b/tests/service/test_config_routes.py @@ -17,15 +17,15 @@ from fastapi.testclient import TestClient from infrahub_sync.configuration.models import ValidationFinding -from infrahub_sync.managed.app import create_app -from infrahub_sync.managed.auth import PRINCIPALS_ENV, EnvironmentPrincipalResolver -from infrahub_sync.managed.config_routes import ConfigurationAPIError, ConfigurationRoutes -from infrahub_sync.managed.orchestration import CancellationResult, Observation, PoolStatus, Submission -from infrahub_sync.managed.serve import build_app -from infrahub_sync.managed.service import ManagedAPIError, ManagedRunService from infrahub_sync.product_store import configs as configs_service from infrahub_sync.product_store import local_product_projection from infrahub_sync.product_store.configs import ValidationReport +from infrahub_sync.service.app import create_app +from infrahub_sync.service.auth import PRINCIPALS_ENV, EnvironmentPrincipalResolver +from infrahub_sync.service.config_routes import ConfigurationAPIError, ConfigurationRoutes +from infrahub_sync.service.orchestration import CancellationResult, Observation, PoolStatus, Submission +from infrahub_sync.service.serve import build_app +from infrahub_sync.service.service import RunService, ServiceAPIError from tests.configuration.validation_packages import package, package_data ADMIN_TOKEN = "admin-token-canary-0003" # noqa: S105 - deliberate non-secret boundary canary. @@ -35,10 +35,10 @@ from typing import NoReturn from infrahub_sync.configuration.models import ConfigurationPackage - from infrahub_sync.managed.auth import PrincipalResolver - from infrahub_sync.managed.liveness import RunLivenessReconciler from infrahub_sync.product_store.models import ConfigurationVersion from infrahub_sync.product_store.store import ProductProjection + from infrahub_sync.service.auth import PrincipalResolver + from infrahub_sync.service.liveness import RunLivenessReconciler class _Orchestration: # pylint: disable=too-few-public-methods @@ -66,7 +66,7 @@ def test_configuration_routes_register_then_read(tmp_path: Path, monkeypatch: py ) resolver = EnvironmentPrincipalResolver.from_environment() projection = local_product_projection(tmp_path) - runs = ManagedRunService(projection, _Orchestration(), secrets=resolver.secret_values) + runs = RunService(projection, _Orchestration(), secrets=resolver.secret_values) routes = ConfigurationRoutes(tmp_path, secrets=resolver.secret_values) client = TestClient(create_app(runs, resolver, routes)) response = client.post( @@ -87,7 +87,7 @@ def test_configuration_routes_use_the_injected_projection_for_services_receipts_ tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """Every configuration path uses the one projection supplied at composition.""" - from infrahub_sync.managed import config_routes + from infrahub_sync.service import config_routes bearer = "admin-token-canary-0003" monkeypatch.setenv(PRINCIPALS_ENV, json.dumps({"admin": {"token": bearer, "administrator": True}})) @@ -118,7 +118,7 @@ def record(*args: object, **kwargs: object) -> object: monkeypatch.setattr(configs_service, "local_product_projection", local_projection_forbidden) client = TestClient( create_app( - ManagedRunService(cast("ProductProjection", projection), _Orchestration(), secrets=resolver.secret_values), + RunService(cast("ProductProjection", projection), _Orchestration(), secrets=resolver.secret_values), resolver, ConfigurationRoutes( product_projection=cast("ProductProjection", projection), secrets=resolver.secret_values @@ -224,7 +224,7 @@ def record_audit(*_args: object, **_kwargs: object) -> NoReturn: client = TestClient( create_app( - ManagedRunService(run_projection, _Orchestration(), secrets=resolver.secret_values), + RunService(run_projection, _Orchestration(), secrets=resolver.secret_values), resolver, ConfigurationRoutes(product_projection=cast("ProductProjection", StorageProjection())), ) @@ -256,7 +256,7 @@ def list_configurations() -> NoReturn: internal_client = TestClient( create_app( - ManagedRunService(run_projection, _Orchestration(), secrets=resolver.secret_values), + RunService(run_projection, _Orchestration(), secrets=resolver.secret_values), resolver, ConfigurationRoutes(product_projection=cast("ProductProjection", InternalProjection())), ) @@ -329,7 +329,7 @@ def fail(*_args: object, **_kwargs: object) -> NoReturn: projection = cast("ProductProjection", FailingProjection()) client = TestClient( create_app( - ManagedRunService(projection, _Orchestration(), secrets=resolver.secret_values), + RunService(projection, _Orchestration(), secrets=resolver.secret_values), resolver, ConfigurationRoutes(product_projection=projection, secrets=resolver.secret_values), ), @@ -383,7 +383,7 @@ def test_configuration_mutation_replays_exact_response_and_rejects_changed_conte projection = local_product_projection(tmp_path) client = TestClient( create_app( - ManagedRunService(projection, _Orchestration(), secrets=resolver.secret_values), + RunService(projection, _Orchestration(), secrets=resolver.secret_values), resolver, ConfigurationRoutes(tmp_path, secrets=resolver.secret_values), ) @@ -414,7 +414,7 @@ def test_duplicate_configuration_version_checksum_returns_existing_version_witho projection = local_product_projection(tmp_path) client = TestClient( create_app( - ManagedRunService(projection, _Orchestration(), secrets=resolver.secret_values), + RunService(projection, _Orchestration(), secrets=resolver.secret_values), resolver, ConfigurationRoutes(tmp_path, secrets=resolver.secret_values), ) @@ -450,7 +450,7 @@ def test_configuration_mutation_audits_accepted_replayed_and_refused_idempotency projection = local_product_projection(tmp_path) client = TestClient( create_app( - ManagedRunService(projection, _Orchestration(), secrets=resolver.secret_values), + RunService(projection, _Orchestration(), secrets=resolver.secret_values), resolver, ConfigurationRoutes(tmp_path, secrets=resolver.secret_values), ) @@ -511,7 +511,7 @@ def register(self, **_kwargs: object) -> dict[str, object]: service = Service() client = TestClient( create_app( - ManagedRunService(projection, _Orchestration(), secrets=resolver.secret_values), + RunService(projection, _Orchestration(), secrets=resolver.secret_values), resolver, ConfigurationRoutes(tmp_path, service=service, secrets=resolver.secret_values), ) @@ -568,7 +568,7 @@ def lookup_configuration(self, _config_id: str) -> NoReturn: # noqa: PLR6301 with pytest.raises(ConfigurationAPIError) as first: routes.mutate(**request) - with pytest.raises(ManagedAPIError) as retry: + with pytest.raises(ServiceAPIError) as retry: routes.mutate(**request) receipt = projection.lookup_mutation("admin", sha256(b"post-commit-readback-failure").hexdigest()).value @@ -630,7 +630,7 @@ def mutate() -> tuple[int, dict[str, object]]: first = Thread(target=mutate) first.start() assert started.wait(timeout=5) - with pytest.raises(ManagedAPIError) as raised: + with pytest.raises(ServiceAPIError) as raised: mutate() release.set() first.join(timeout=5) @@ -709,7 +709,7 @@ def test_configuration_mutation_refuses_unauthenticated_and_non_admin_calls( projection = local_product_projection(tmp_path) client = TestClient( create_app( - ManagedRunService(projection, _Orchestration(), secrets=resolver.secret_values), + RunService(projection, _Orchestration(), secrets=resolver.secret_values), resolver, ConfigurationRoutes(tmp_path, secrets=resolver.secret_values), ) @@ -774,7 +774,7 @@ def register(self, **_kwargs: object) -> object: service = Service() client = TestClient( create_app( - ManagedRunService(local_product_projection(tmp_path), _Orchestration(), secrets=resolver.secret_values), + RunService(local_product_projection(tmp_path), _Orchestration(), secrets=resolver.secret_values), resolver, ConfigurationRoutes(tmp_path, service=service, secrets=resolver.secret_values), ) @@ -838,7 +838,7 @@ def validate(**_kwargs: object) -> ValidationReport: client = TestClient( create_app( - ManagedRunService(local_product_projection(tmp_path), _Orchestration(), secrets=resolver.secret_values), + RunService(local_product_projection(tmp_path), _Orchestration(), secrets=resolver.secret_values), resolver, ConfigurationRoutes(tmp_path, service=Service(), secrets=resolver.secret_values), ) @@ -907,7 +907,7 @@ def list_versions(**_kwargs: object) -> list[dict[str, object]]: client = TestClient( create_app( - ManagedRunService(local_product_projection(tmp_path), _Orchestration(), secrets=resolver.secret_values), + RunService(local_product_projection(tmp_path), _Orchestration(), secrets=resolver.secret_values), resolver, ConfigurationRoutes(tmp_path, service=Service(), secrets=resolver.secret_values), ) @@ -952,7 +952,7 @@ def test_configuration_error_matrix_preserves_only_declared_fields( # noqa: PLR PRINCIPALS_ENV, json.dumps({"admin": {"token": "admin-token-canary-0003", "administrator": True}}) ) resolver = EnvironmentPrincipalResolver.from_environment() - runs = ManagedRunService(local_product_projection(tmp_path), _Orchestration(), secrets=resolver.secret_values) + runs = RunService(local_product_projection(tmp_path), _Orchestration(), secrets=resolver.secret_values) routes = ConfigurationRoutes(tmp_path) def fail(**_kwargs: object) -> None: @@ -969,7 +969,7 @@ def fail(**_kwargs: object) -> None: def test_configuration_routes_do_not_import_storage_or_validation_internals() -> None: """The HTTP adapter depends only on the shared service facade.""" - source = Path(__file__).parents[2] / "infrahub_sync" / "managed" / "config_routes.py" + source = Path(__file__).parents[2] / "infrahub_sync" / "service" / "config_routes.py" imports = [ node.module or "" for node in ast.walk(ast.parse(source.read_text())) if isinstance(node, ast.ImportFrom) ] @@ -1090,7 +1090,7 @@ def resolve(_token: str) -> object: config_service = ConfigService() client = TestClient( create_app( - cast("ManagedRunService", runs), + cast("RunService", runs), cast("PrincipalResolver", Resolver()), ConfigurationRoutes(tmp_path, service=config_service), ) diff --git a/tests/managed/test_flow_and_prefect.py b/tests/service/test_flow_and_prefect.py similarity index 81% rename from tests/managed/test_flow_and_prefect.py rename to tests/service/test_flow_and_prefect.py index bf65f95a..d53d38a1 100644 --- a/tests/managed/test_flow_and_prefect.py +++ b/tests/service/test_flow_and_prefect.py @@ -33,21 +33,21 @@ from prefect.states import Cancelled, Cancelling, Failed, Pending, Running from infrahub_sync.execution import RunResult -from infrahub_sync.managed import flow as managed_flow -from infrahub_sync.managed.deploy import CATALOGUE -from infrahub_sync.managed.flow import managed_sync_run -from infrahub_sync.managed.orchestration import ( - MANAGED_DEFINITION, - CancellationResult, - Observation, - PrefectOrchestration, -) from infrahub_sync.orchestration import flow as direct_flow from infrahub_sync.orchestration.flow import infrahub_sync_run from infrahub_sync.plan.errors import OperationApplyFailedError from infrahub_sync.plan.models import ApplyRecord, PlanManifest from infrahub_sync.plan.review import SavedPlan from infrahub_sync.product_store import PrefectExecutionLink, ProductRun, local_product_projection +from infrahub_sync.service import flow as service_flow +from infrahub_sync.service.deploy import CATALOGUE +from infrahub_sync.service.flow import service_sync_run +from infrahub_sync.service.orchestration import ( + SERVICE_DEFINITION, + CancellationResult, + Observation, + PrefectOrchestration, +) from tests.configuration.validation_packages import package if TYPE_CHECKING: @@ -72,7 +72,7 @@ def claim(projection, run_id: str) -> tuple[str, str]: assert projection.claim_execution(run_id, flow_run_id, worker_id=worker_id) return flow_run_id, worker_id - monkeypatch.setattr(managed_flow, "_claim_current_execution", claim) + monkeypatch.setattr(service_flow, "_claim_current_execution", claim) @pytest.fixture(autouse=True) @@ -82,7 +82,7 @@ def _stub_runtime_model_plan(monkeypatch: pytest.MonkeyPatch) -> None: def build(*_args: object, **_kwargs: object) -> object: return object() - monkeypatch.setattr(managed_flow, "build_runtime_model_plan", build) + monkeypatch.setattr(service_flow, "build_runtime_model_plan", build) def _saved(run_id: str) -> SavedPlan: @@ -140,7 +140,7 @@ def acquire(self) -> None: """Signal from inside the contender's acquisition attempt, then delegate.""" thread_name = current_thread().name self.events.append(f"{thread_name}:acquire-attempted") - if thread_name == "test-managed-flow": + if thread_name == "test-service-flow": self.managed_acquire_attempted.set() self._delegate.acquire() self.events.append(f"{thread_name}:acquired") @@ -211,17 +211,17 @@ def test_worker_rejects_missing_registered_package_before_runtime_construction( ) ) constructed = [] - monkeypatch.setattr(managed_flow, "_runtime", lambda: (str(tmp_path), projection)) - monkeypatch.setattr(managed_flow, "_run_logger", lambda: (logging.getLogger("test-managed"), False)) - monkeypatch.setattr(managed_flow, "resolve_runtime_instance", lambda *_args, **_kwargs: constructed.append(True)) + monkeypatch.setattr(service_flow, "_runtime", lambda: (str(tmp_path), projection)) + monkeypatch.setattr(service_flow, "_run_logger", lambda: (logging.getLogger("test-service"), False)) + monkeypatch.setattr(service_flow, "resolve_runtime_instance", lambda *_args, **_kwargs: constructed.append(True)) with pytest.raises(RuntimeError, match="registered configuration version is unavailable"): - managed_sync_run.fn(run_id, "plan", "config-001", 1, "a" * 64) + service_sync_run.fn(run_id, "plan", "config-001", 1, "a" * 64) assert constructed == [] def test_managed_and_direct_prefect_flow_schemas_are_separate_and_exact() -> None: - assert tuple(inspect.signature(managed_sync_run.fn).parameters) == ( + assert tuple(inspect.signature(service_sync_run.fn).parameters) == ( "run_id", "stage", "config_id", @@ -237,14 +237,14 @@ def test_managed_and_direct_prefect_flow_schemas_are_separate_and_exact() -> Non "confirm_writes", "branch", ) - assert CATALOGUE.keys() == (MANAGED_DEFINITION.key,) + assert CATALOGUE.keys() == (SERVICE_DEFINITION.key,) assert_valid_definitions(CATALOGUE) def test_flow_working_directory_is_required_absolute_and_existing( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: - from infrahub_sync.managed import deploy + from infrahub_sync.service import deploy monkeypatch.delenv(deploy.FLOW_WORKING_DIRECTORY_ENV, raising=False) with pytest.raises(ValueError, match=deploy.FLOW_WORKING_DIRECTORY_ENV): @@ -264,10 +264,10 @@ def test_flow_working_directory_is_required_absolute_and_existing( async def test_managed_deploy_only_converges_the_flow_working_directory( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: - from infrahub_sync.managed import deploy + from infrahub_sync.service import deploy calls: list[tuple[str, str]] = [] - monkeypatch.setenv(deploy.WORK_POOL_ENV, "managed-pool") + monkeypatch.setenv(deploy.WORK_POOL_ENV, "service-pool") monkeypatch.setenv(deploy.FLOW_WORKING_DIRECTORY_ENV, str(tmp_path)) monkeypatch.setattr(deploy, "apply_deployments", AsyncMock(return_value=SimpleNamespace(is_successful=True))) @@ -283,17 +283,17 @@ async def test_managed_deploy_only_converges_the_flow_working_directory( def test_managed_definition_entrypoint_targets_the_flow_file() -> None: """The applied deployment must carry an executable entrypoint. - Without one, a Prefect process worker refuses every managed flow run with + Without one, a Prefect process worker refuses every service flow run with "does not have an entrypoint and can not be run" — the deployment library sends the entrypoint only when the definition supplies it. """ - assert MANAGED_DEFINITION.entrypoint is not None - path_part, _, function_part = MANAGED_DEFINITION.entrypoint.rpartition(":") + assert SERVICE_DEFINITION.entrypoint is not None + path_part, _, function_part = SERVICE_DEFINITION.entrypoint.rpartition(":") flow_file = Path(path_part) assert flow_file.is_absolute(), "entrypoint must encode the shared-installation path contract" assert flow_file.name == "flow.py" assert flow_file.is_file() - assert function_part == "managed_sync_run" + assert function_part == "service_sync_run" def test_missing_context_uses_local_logger_without_constructing_a_bridge(monkeypatch: pytest.MonkeyPatch) -> None: @@ -304,12 +304,12 @@ def bridge_is_forbidden(_logger): msg = "RunLoggerBridge must not be constructed in the local fallback" raise AssertionError(msg) - monkeypatch.setattr(managed_flow, "get_run_logger", missing_context) - monkeypatch.setattr(managed_flow, "RunLoggerBridge", bridge_is_forbidden) + monkeypatch.setattr(service_flow, "get_run_logger", missing_context) + monkeypatch.setattr(service_flow, "RunLoggerBridge", bridge_is_forbidden) - run_logger, prefect_context = managed_flow._run_logger() - with managed_flow._remote_log_bridge(run_logger, prefect_context=prefect_context): - run_logger.info("local managed execution") + run_logger, prefect_context = service_flow._run_logger() + with service_flow._remote_log_bridge(run_logger, prefect_context=prefect_context): + run_logger.info("local service execution") assert isinstance(run_logger, logging.Logger) assert prefect_context is False @@ -321,11 +321,11 @@ def test_direct_and_managed_log_bridges_serialize_ownership_and_restore_state( ) -> None: """Concurrent flow bridges must not share records or clobber logger state.""" direct_canary = "direct-flow-secret-canary" - managed_canary = "managed-flow-secret-canary" + service_canary = "service-flow-secret-canary" direct_logger = _RecordingRunLogger() managed_logger = _RecordingRunLogger() - source_logger = logging.getLogger(managed_flow.SOURCE_LOGGER_NAME) - child_logger = logging.getLogger(f"{managed_flow.SOURCE_LOGGER_NAME}.concurrency-test") + source_logger = logging.getLogger(service_flow.SOURCE_LOGGER_NAME) + child_logger = logging.getLogger(f"{service_flow.SOURCE_LOGGER_NAME}.concurrency-test") sentinel_handler = logging.NullHandler() original_handlers = list(source_logger.handlers) original_level = source_logger.level @@ -349,15 +349,15 @@ def run_direct() -> None: except BaseException as exc: # noqa: BLE001 - retain thread failure for the main test. direct_failures.append(exc) - def run_managed_bridge() -> None: + def run_service_bridge() -> None: try: - with managed_flow._remote_log_bridge( + with service_flow._remote_log_bridge( managed_logger, prefect_context=True, - secrets=(managed_canary,), + secrets=(service_canary,), ): managed_entered.set() - child_logger.warning("managed record used %s", managed_canary) + child_logger.warning("service record used %s", service_canary) assert release_managed.wait(timeout=5) except BaseException as exc: # noqa: BLE001 - retain thread failure for the main test. managed_failures.append(exc) @@ -365,22 +365,22 @@ def run_managed_bridge() -> None: monkeypatch.setattr("infrahub_sync.orchestration.flow.get_run_logger", lambda: direct_logger) monkeypatch.setattr("infrahub_sync.orchestration.flow.collect_secret_values", lambda: (direct_canary,)) monkeypatch.setattr("infrahub_sync.orchestration.flow.run_remote_request", fail_direct_request) - monkeypatch.setenv(managed_flow.CONFIG_DIR_ENV, str(tmp_path)) + monkeypatch.setenv(service_flow.CONFIG_DIR_ENV, str(tmp_path)) original_ownership_lock = direct_flow._REMOTE_LOGGER_OWNERSHIP_LOCK - assert managed_flow._REMOTE_LOGGER_OWNERSHIP_LOCK is original_ownership_lock + assert service_flow._REMOTE_LOGGER_OWNERSHIP_LOCK is original_ownership_lock ownership_probe = _LoggerOwnershipProbe(original_ownership_lock) monkeypatch.setattr(direct_flow, "_REMOTE_LOGGER_OWNERSHIP_LOCK", ownership_probe) - monkeypatch.setattr(managed_flow, "_REMOTE_LOGGER_OWNERSHIP_LOCK", ownership_probe) + monkeypatch.setattr(service_flow, "_REMOTE_LOGGER_OWNERSHIP_LOCK", ownership_probe) source_logger.handlers = [sentinel_handler] source_logger.setLevel(logging.ERROR) source_logger.propagate = True direct_thread = Thread(target=run_direct, name="test-direct-flow") - managed_thread = Thread(target=run_managed_bridge, name="test-managed-flow") + service_thread = Thread(target=run_service_bridge, name="test-service-flow") try: direct_thread.start() assert direct_entered.wait(timeout=5) - managed_thread.start() + service_thread.start() assert ownership_probe.managed_acquire_attempted.wait(timeout=5) child_logger.warning("direct record used %s", direct_canary) @@ -389,32 +389,32 @@ def run_managed_bridge() -> None: assert not direct_thread.is_alive() assert managed_entered.wait(timeout=5) release_managed.set() - managed_thread.join(timeout=5) - assert not managed_thread.is_alive() + service_thread.join(timeout=5) + assert not service_thread.is_alive() rendered = "\n".join((*direct_logger.rendered, *managed_logger.rendered)) expected_acquisition_order = [ "test-direct-flow:acquire-attempted", "test-direct-flow:acquired", - "test-managed-flow:acquire-attempted", + "test-service-flow:acquire-attempted", "test-direct-flow:released", - "test-managed-flow:acquired", - "test-managed-flow:released", + "test-service-flow:acquired", + "test-service-flow:released", ] violations = [ label for label, violated in ( ("bridge ownership was not serialized", ownership_probe.events != expected_acquisition_order), ( - "direct bridge received the managed record", - any("managed record" in line for line in direct_logger.rendered), + "direct bridge received the service record", + any("service record" in line for line in direct_logger.rendered), ), ( - "managed bridge received the direct record", + "service bridge received the direct record", any("direct record" in line for line in managed_logger.rendered), ), ("direct canary reached a run logger", direct_canary in rendered), - ("managed canary reached a run logger", managed_canary in rendered), + ("service canary reached a run logger", service_canary in rendered), ("source handlers were not restored", source_logger.handlers != [sentinel_handler]), ("source level was not restored", source_logger.level != logging.ERROR), ("source propagation was not restored", source_logger.propagate is not True), @@ -429,7 +429,7 @@ def run_managed_bridge() -> None: finally: release_direct.set() release_managed.set() - for thread in (direct_thread, managed_thread): + for thread in (direct_thread, service_thread): if thread.ident is not None: thread.join(timeout=5) source_logger.handlers = original_handlers @@ -444,7 +444,7 @@ def test_managed_flow_redacts_worker_logs_exception_chain_and_failed_state( ) -> None: environment_canary = "worker-environment-token-canary" configuration_canary = "worker-configuration-token-canary" - run_id = "run-managed-secret-failure" + run_id = "run-service-secret-failure" projection = _create_product_run(tmp_path.resolve(), run_id) run_logger = _RecordingRunLogger() instance = SimpleNamespace( @@ -454,8 +454,8 @@ def test_managed_flow_redacts_worker_logs_exception_chain_and_failed_state( store=None, ) monkeypatch.setenv("NETBOX_TOKEN", environment_canary) - monkeypatch.setattr(managed_flow, "_runtime", lambda: (str(tmp_path), projection)) - monkeypatch.setattr(managed_flow, "_run_logger", lambda: (run_logger, True)) + monkeypatch.setattr(service_flow, "_runtime", lambda: (str(tmp_path), projection)) + monkeypatch.setattr(service_flow, "_run_logger", lambda: (run_logger, True)) def resolve( _sync_name: str, @@ -464,14 +464,14 @@ def resolve( resolve_source_credentials: bool = True, ): del directory, resolve_source_credentials - logging.getLogger("infrahub_sync.managed.worker").warning( + logging.getLogger("infrahub_sync.service.worker").warning( "resolution used %s", environment_canary, ) return instance def fail_plan(_instance, *, run_id: str, branch: str | None, composed_sync: bool): # noqa: ARG001 - logging.getLogger("infrahub_sync.managed.worker").error( + logging.getLogger("infrahub_sync.service.worker").error( "execution used %s", configuration_canary, ) @@ -479,11 +479,11 @@ def fail_plan(_instance, *, run_id: str, branch: str | None, composed_sync: bool failure_message = f"adapter rejected {configuration_canary}" raise ValueError(failure_message) from ConnectionError(cause_message) - monkeypatch.setattr(managed_flow, "resolve_runtime_instance", resolve) - monkeypatch.setattr(managed_flow, "_plan", fail_plan) + monkeypatch.setattr(service_flow, "resolve_runtime_instance", resolve) + monkeypatch.setattr(service_flow, "_plan", fail_plan) with pytest.raises(RuntimeError) as exc_info: - managed_sync_run.fn(run_id, "plan", *_binding(projection, run_id)) + service_sync_run.fn(run_id, "plan", *_binding(projection, run_id)) failure = exc_info.value failed_state = Failed(message=str(failure), data=failure) @@ -517,24 +517,24 @@ def test_managed_apply_failure_retains_partial_write_evidence( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: - run_id = "run-managed-apply-failure" + run_id = "run-service-apply-failure" projection = _create_product_run(tmp_path.resolve(), run_id) partial = ApplyRecord(applied_operations=("op-applied",), failed_operation="op-failed") - monkeypatch.setattr(managed_flow, "_runtime", lambda: (str(tmp_path), projection)) - monkeypatch.setattr(managed_flow, "_run_logger", lambda: (logging.getLogger("test-managed"), False)) - monkeypatch.setattr(managed_flow, "resolve_runtime_instance", _instance) - monkeypatch.setattr(managed_flow, "collect_secret_values", lambda _instance=None: ()) - monkeypatch.setattr(managed_flow, "_verify_registered_apply", lambda **_kwargs: None) - monkeypatch.setattr(managed_flow, "_require_planned_schema", lambda **_kwargs: None) + monkeypatch.setattr(service_flow, "_runtime", lambda: (str(tmp_path), projection)) + monkeypatch.setattr(service_flow, "_run_logger", lambda: (logging.getLogger("test-service"), False)) + monkeypatch.setattr(service_flow, "resolve_runtime_instance", _instance) + monkeypatch.setattr(service_flow, "collect_secret_values", lambda _instance=None: ()) + monkeypatch.setattr(service_flow, "_verify_registered_apply", lambda **_kwargs: None) + monkeypatch.setattr(service_flow, "_require_planned_schema", lambda **_kwargs: None) def fail_apply(*_args: object, **_kwargs: object) -> NoReturn: msg = "destination rejected operation" raise OperationApplyFailedError(msg, apply_record=partial) - monkeypatch.setattr(managed_flow, "execute_run", fail_apply) + monkeypatch.setattr(service_flow, "execute_run", fail_apply) with pytest.raises(RuntimeError): - managed_sync_run.fn( + service_sync_run.fn( run_id, "apply", *_binding(projection, run_id), @@ -562,21 +562,21 @@ def test_managed_verify_failure_merges_evidence_and_terminalizes_exact_link( tmp_path: Path, ) -> None: """Read-only verify failure retains the run lifecycle and closes its execution.""" - run_id = "run-managed-verify-failure" + run_id = "run-service-verify-failure" projection = _create_product_run(tmp_path.resolve(), run_id, operation="verify") - monkeypatch.setattr(managed_flow, "_runtime", lambda: (str(tmp_path), projection)) - monkeypatch.setattr(managed_flow, "_run_logger", lambda: (logging.getLogger("test-managed"), False)) - monkeypatch.setattr(managed_flow, "resolve_runtime_instance", _instance) - monkeypatch.setattr(managed_flow, "collect_secret_values", lambda _instance=None: ()) + monkeypatch.setattr(service_flow, "_runtime", lambda: (str(tmp_path), projection)) + monkeypatch.setattr(service_flow, "_run_logger", lambda: (logging.getLogger("test-service"), False)) + monkeypatch.setattr(service_flow, "resolve_runtime_instance", _instance) + monkeypatch.setattr(service_flow, "collect_secret_values", lambda _instance=None: ()) def fail_verify(*_args: object, **_kwargs: object) -> NoReturn: msg = "saved plan verification failed" raise ValueError(msg) - monkeypatch.setattr(managed_flow, "execute_run", fail_verify) + monkeypatch.setattr(service_flow, "execute_run", fail_verify) with pytest.raises(RuntimeError): - managed_sync_run.fn(run_id, "verify", *_binding(projection, run_id)) + service_sync_run.fn(run_id, "verify", *_binding(projection, run_id)) stored = projection.lookup_run(run_id).value assert stored is not None @@ -591,16 +591,16 @@ def test_success_writeback_persistence_failure_is_not_recorded_as_business_failu tmp_path: Path, ) -> None: """A failed success commit stays nonterminal for conservative reconciliation.""" - run_id = "run-managed-success-persistence-failure" + run_id = "run-service-success-persistence-failure" projection = _create_product_run(tmp_path.resolve(), run_id) saved = _saved(run_id) - monkeypatch.setattr(managed_flow, "_runtime", lambda: (str(tmp_path), projection)) - monkeypatch.setattr(managed_flow, "_run_logger", lambda: (logging.getLogger("test-managed"), False)) - monkeypatch.setattr(managed_flow, "resolve_runtime_instance", _instance) - monkeypatch.setattr(managed_flow, "collect_secret_values", lambda _instance=None: ()) - monkeypatch.setattr(managed_flow, "_verify_registered_apply", lambda **_kwargs: None) - monkeypatch.setattr(managed_flow, "_require_planned_schema", lambda **_kwargs: None) - monkeypatch.setattr(managed_flow, "_plan", lambda *_args, **_kwargs: saved) + monkeypatch.setattr(service_flow, "_runtime", lambda: (str(tmp_path), projection)) + monkeypatch.setattr(service_flow, "_run_logger", lambda: (logging.getLogger("test-service"), False)) + monkeypatch.setattr(service_flow, "resolve_runtime_instance", _instance) + monkeypatch.setattr(service_flow, "collect_secret_values", lambda _instance=None: ()) + monkeypatch.setattr(service_flow, "_verify_registered_apply", lambda **_kwargs: None) + monkeypatch.setattr(service_flow, "_require_planned_schema", lambda **_kwargs: None) + monkeypatch.setattr(service_flow, "_plan", lambda *_args, **_kwargs: saved) commits: list[str] = [] def fail_commit(*_args: object, **kwargs: object) -> bool: @@ -613,7 +613,7 @@ def fail_commit(*_args: object, **kwargs: object) -> bool: monkeypatch.setattr(projection, "commit_claimed_execution", fail_commit) with pytest.raises(RuntimeError, match="injected persistence failure"): - managed_sync_run.fn(run_id, "plan", *_binding(projection, run_id)) + service_sync_run.fn(run_id, "plan", *_binding(projection, run_id)) stored = projection.lookup_run(run_id).value assert stored is not None @@ -628,16 +628,16 @@ def test_success_writeback_commit_error_preserves_reread_committed_result( tmp_path: Path, ) -> None: """An ambiguous commit response returns the known result when durable reread proves success.""" - run_id = "run-managed-success-committed-before-error" + run_id = "run-service-success-committed-before-error" projection = _create_product_run(tmp_path.resolve(), run_id) saved = _saved(run_id) - monkeypatch.setattr(managed_flow, "_runtime", lambda: (str(tmp_path), projection)) - monkeypatch.setattr(managed_flow, "_run_logger", lambda: (logging.getLogger("test-managed"), False)) - monkeypatch.setattr(managed_flow, "resolve_runtime_instance", _instance) - monkeypatch.setattr(managed_flow, "collect_secret_values", lambda _instance=None: ()) - monkeypatch.setattr(managed_flow, "_verify_registered_apply", lambda **_kwargs: None) - monkeypatch.setattr(managed_flow, "_require_planned_schema", lambda **_kwargs: None) - monkeypatch.setattr(managed_flow, "_plan", lambda *_args, **_kwargs: saved) + monkeypatch.setattr(service_flow, "_runtime", lambda: (str(tmp_path), projection)) + monkeypatch.setattr(service_flow, "_run_logger", lambda: (logging.getLogger("test-service"), False)) + monkeypatch.setattr(service_flow, "resolve_runtime_instance", _instance) + monkeypatch.setattr(service_flow, "collect_secret_values", lambda _instance=None: ()) + monkeypatch.setattr(service_flow, "_verify_registered_apply", lambda **_kwargs: None) + monkeypatch.setattr(service_flow, "_require_planned_schema", lambda **_kwargs: None) + monkeypatch.setattr(service_flow, "_plan", lambda *_args, **_kwargs: saved) commit = projection.commit_claimed_execution def commit_then_fail(*args: object, **kwargs: object) -> bool: @@ -647,7 +647,7 @@ def commit_then_fail(*args: object, **kwargs: object) -> bool: monkeypatch.setattr(projection, "commit_claimed_execution", commit_then_fail) - result = managed_sync_run.fn(run_id, "plan", *_binding(projection, run_id)) + result = service_sync_run.fn(run_id, "plan", *_binding(projection, run_id)) stored = projection.lookup_run(run_id).value assert stored is not None @@ -661,16 +661,16 @@ def test_managed_confirmed_sync_retains_the_semantic_sync_operation( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: - run_id = "run-managed-semantic-sync" + run_id = "run-service-semantic-sync" projection = _create_product_run(tmp_path.resolve(), run_id, operation="sync") saved = _saved(run_id) - monkeypatch.setattr(managed_flow, "_runtime", lambda: (str(tmp_path), projection)) - monkeypatch.setattr(managed_flow, "_run_logger", lambda: (logging.getLogger("test-managed"), False)) - monkeypatch.setattr(managed_flow, "resolve_runtime_instance", _instance) - monkeypatch.setattr(managed_flow, "collect_secret_values", lambda _instance=None: ()) - monkeypatch.setattr(managed_flow, "bounded_run_lock", lambda *_args, **_kwargs: nullcontext()) - monkeypatch.setattr(managed_flow, "_verify_registered_apply", lambda **_kwargs: None) - monkeypatch.setattr(managed_flow, "_require_planned_schema", lambda **_kwargs: None) + monkeypatch.setattr(service_flow, "_runtime", lambda: (str(tmp_path), projection)) + monkeypatch.setattr(service_flow, "_run_logger", lambda: (logging.getLogger("test-service"), False)) + monkeypatch.setattr(service_flow, "resolve_runtime_instance", _instance) + monkeypatch.setattr(service_flow, "collect_secret_values", lambda _instance=None: ()) + monkeypatch.setattr(service_flow, "bounded_run_lock", lambda *_args, **_kwargs: nullcontext()) + monkeypatch.setattr(service_flow, "_verify_registered_apply", lambda **_kwargs: None) + monkeypatch.setattr(service_flow, "_require_planned_schema", lambda **_kwargs: None) def core(_instance: object, *, operation: str, **_kwargs: object) -> SavedPlan | RunResult: if operation in {"plan", "verify"}: @@ -685,9 +685,9 @@ def core(_instance: object, *, operation: str, **_kwargs: object) -> SavedPlan | artifact_path=str(tmp_path / run_id), ) - monkeypatch.setattr(managed_flow, "execute_run", core) + monkeypatch.setattr(service_flow, "execute_run", core) - result = managed_sync_run.fn( + result = service_sync_run.fn( run_id, "sync", *_binding(projection, run_id), @@ -705,25 +705,25 @@ def core(_instance: object, *, operation: str, **_kwargs: object) -> SavedPlan | def test_managed_plan_worker_updates_the_api_created_run_and_publishes_review( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: - run_id = "run-managed-plan" + run_id = "run-service-plan" projection = _create_product_run(tmp_path.resolve(), run_id) saved = _saved(run_id) seen: list[str] = [] - monkeypatch.setattr(managed_flow, "_runtime", lambda: (str(tmp_path), projection)) - monkeypatch.setattr(managed_flow, "_run_logger", lambda: (logging.getLogger("test-managed"), False)) - monkeypatch.setattr(managed_flow, "resolve_runtime_instance", _instance) - monkeypatch.setattr(managed_flow, "collect_secret_values", lambda _instance=None: ()) - monkeypatch.setattr(managed_flow, "_verify_registered_apply", lambda **_kwargs: None) - monkeypatch.setattr(managed_flow, "_require_planned_schema", lambda **_kwargs: None) + monkeypatch.setattr(service_flow, "_runtime", lambda: (str(tmp_path), projection)) + monkeypatch.setattr(service_flow, "_run_logger", lambda: (logging.getLogger("test-service"), False)) + monkeypatch.setattr(service_flow, "resolve_runtime_instance", _instance) + monkeypatch.setattr(service_flow, "collect_secret_values", lambda _instance=None: ()) + monkeypatch.setattr(service_flow, "_verify_registered_apply", lambda **_kwargs: None) + monkeypatch.setattr(service_flow, "_require_planned_schema", lambda **_kwargs: None) def plan(_instance, *, run_id: str, branch: str | None, composed_sync: bool): # noqa: ARG001 seen.append(run_id) assert composed_sync is False return saved - monkeypatch.setattr(managed_flow, "_plan", plan) + monkeypatch.setattr(service_flow, "_plan", plan) - result = managed_sync_run.fn( + result = service_sync_run.fn( run_id, "plan", *_binding(projection, run_id), @@ -741,17 +741,17 @@ def plan(_instance, *, run_id: str, branch: str | None, composed_sync: bool): # @pytest.mark.usefixtures("_claimed_worker_execution") def test_managed_verify_is_read_only_for_product_lifecycle(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - run_id = "run-managed-verify" + run_id = "run-service-verify" projection = _create_product_run(tmp_path.resolve(), run_id) before = projection.lookup_run(run_id).value saved = _saved(run_id) - monkeypatch.setattr(managed_flow, "_runtime", lambda: (str(tmp_path), projection)) - monkeypatch.setattr(managed_flow, "_run_logger", lambda: (logging.getLogger("test-managed"), False)) - monkeypatch.setattr(managed_flow, "resolve_runtime_instance", _instance) - monkeypatch.setattr(managed_flow, "collect_secret_values", lambda _instance=None: ()) - monkeypatch.setattr(managed_flow, "execute_run", lambda *_args, **_kwargs: saved) + monkeypatch.setattr(service_flow, "_runtime", lambda: (str(tmp_path), projection)) + monkeypatch.setattr(service_flow, "_run_logger", lambda: (logging.getLogger("test-service"), False)) + monkeypatch.setattr(service_flow, "resolve_runtime_instance", _instance) + monkeypatch.setattr(service_flow, "collect_secret_values", lambda _instance=None: ()) + monkeypatch.setattr(service_flow, "execute_run", lambda *_args, **_kwargs: saved) - result = managed_sync_run.fn( + result = service_sync_run.fn( run_id, "verify", *_binding(projection, run_id), @@ -779,17 +779,17 @@ def test_managed_verify_is_read_only_for_product_lifecycle(monkeypatch: pytest.M def test_confirmed_managed_sync_calls_plan_verify_apply_in_order_on_one_run( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: - run_id = "run-managed-sync" + run_id = "run-service-sync" projection = _create_product_run(tmp_path.resolve(), run_id, operation="sync") saved = _saved(run_id) calls: list[tuple[str, str]] = [] - monkeypatch.setattr(managed_flow, "_runtime", lambda: (str(tmp_path), projection)) - monkeypatch.setattr(managed_flow, "_run_logger", lambda: (logging.getLogger("test-managed"), False)) - monkeypatch.setattr(managed_flow, "bounded_run_lock", lambda *_args, **_kwargs: nullcontext()) - monkeypatch.setattr(managed_flow, "resolve_runtime_instance", _instance) - monkeypatch.setattr(managed_flow, "collect_secret_values", lambda _instance=None: ()) - monkeypatch.setattr(managed_flow, "_verify_registered_apply", lambda **_kwargs: None) - monkeypatch.setattr(managed_flow, "_require_planned_schema", lambda **_kwargs: None) + monkeypatch.setattr(service_flow, "_runtime", lambda: (str(tmp_path), projection)) + monkeypatch.setattr(service_flow, "_run_logger", lambda: (logging.getLogger("test-service"), False)) + monkeypatch.setattr(service_flow, "bounded_run_lock", lambda *_args, **_kwargs: nullcontext()) + monkeypatch.setattr(service_flow, "resolve_runtime_instance", _instance) + monkeypatch.setattr(service_flow, "collect_secret_values", lambda _instance=None: ()) + monkeypatch.setattr(service_flow, "_verify_registered_apply", lambda **_kwargs: None) + monkeypatch.setattr(service_flow, "_require_planned_schema", lambda **_kwargs: None) def plan(_instance, *, run_id: str, branch: str | None, composed_sync: bool): # noqa: ARG001 calls.append(("plan", run_id)) @@ -813,10 +813,10 @@ def execute(_instance, *, operation: str, run_id: str, **kwargs: object) -> Save artifact_path=str(tmp_path / run_id), ) - monkeypatch.setattr(managed_flow, "_plan", plan) - monkeypatch.setattr(managed_flow, "execute_run", execute) + monkeypatch.setattr(service_flow, "_plan", plan) + monkeypatch.setattr(service_flow, "execute_run", execute) - managed_sync_run.fn( + service_sync_run.fn( run_id, "sync", *_binding(projection, run_id), @@ -842,7 +842,7 @@ def __init__(self) -> None: self.parameters: list[dict[str, Any]] = [] async def read_deployment_by_name(self, name: str): - assert name == MANAGED_DEFINITION.key + assert name == SERVICE_DEFINITION.key return SimpleNamespace(id=self.deployment_id) async def create_flow_run_from_deployment( @@ -1069,8 +1069,8 @@ async def update_deployment( # noqa: PLR6301 - protocol fake. async def test_prefect_extras_deployment_converges_the_managed_catalogue_offline() -> None: client = _DeploymentClient() - report = await apply_deployments(CATALOGUE, work_pool_name="managed-pool", client=client) + report = await apply_deployments(CATALOGUE, work_pool_name="service-pool", client=client) assert report.is_successful assert [result.status for result in report.results] == ["created"] - assert client.created[0]["work_pool_name"] == "managed-pool" + assert client.created[0]["work_pool_name"] == "service-pool" diff --git a/tests/managed/test_http_api.py b/tests/service/test_http_api.py similarity index 92% rename from tests/managed/test_http_api.py rename to tests/service/test_http_api.py index d14c785d..c794716f 100644 --- a/tests/managed/test_http_api.py +++ b/tests/service/test_http_api.py @@ -32,10 +32,19 @@ from prefect.states import Cancelled, Cancelling, Running from infrahub_sync.configuration import ConfigurationPackage -from infrahub_sync.managed import flow as managed_flow -from infrahub_sync.managed.app import create_app -from infrahub_sync.managed.auth import PRINCIPALS_ENV, EnvironmentPrincipalResolver -from infrahub_sync.managed.models import ( +from infrahub_sync.plan.models import PlanManifest +from infrahub_sync.plan.review import SavedPlan +from infrahub_sync.product_store import ( + PrefectExecutionLink, + ProductProjection, + ProductRun, + WriteAdmissionConflictError, + local_product_projection, +) +from infrahub_sync.service import flow as service_flow +from infrahub_sync.service.app import create_app +from infrahub_sync.service.auth import PRINCIPALS_ENV, EnvironmentPrincipalResolver +from infrahub_sync.service.models import ( CreateRunRequest, PlanOperationResource, PlanResource, @@ -43,23 +52,14 @@ PublicRunResource, public_run_resource, ) -from infrahub_sync.managed.orchestration import ( +from infrahub_sync.service.orchestration import ( CancellationResult, Observation, PoolStatus, PrefectOrchestration, Submission, ) -from infrahub_sync.managed.service import PLAN_ARTIFACT_ID, ManagedAPIError, ManagedRunService -from infrahub_sync.plan.models import PlanManifest -from infrahub_sync.plan.review import SavedPlan -from infrahub_sync.product_store import ( - PrefectExecutionLink, - ProductProjection, - ProductRun, - WriteAdmissionConflictError, - local_product_projection, -) +from infrahub_sync.service.service import PLAN_ARTIFACT_ID, RunService, ServiceAPIError if TYPE_CHECKING: from opsmill_prefect_extras.executors import RemoteExecutionClient @@ -159,7 +159,7 @@ async def cancel(self, flow_run_id: str) -> CancellationResult: @pytest.fixture -def managed( +def service_api( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> tuple[TestClient, ProductProjection, _FakeOrchestration]: monkeypatch.setenv( @@ -176,7 +176,7 @@ def managed( projection = local_product_projection(tmp_path.resolve()) orchestration = _FakeOrchestration() version = projection.create_configuration(_registered_package()) - service = ManagedRunService(projection, orchestration, secrets=resolver.secret_values) + service = RunService(projection, orchestration, secrets=resolver.secret_values) client = TestClient(create_app(service, resolver)) client.app.state.run_binding = version return client, projection, orchestration @@ -203,10 +203,10 @@ def _create( def test_admission_reads_registered_binding_before_allocating_run( - managed: tuple[TestClient, ProductProjection, _FakeOrchestration], + service_api: tuple[TestClient, ProductProjection, _FakeOrchestration], ) -> None: """Admission takes package identity and display name from the immutable registry row.""" - _client, projection, orchestration = managed + _client, projection, orchestration = service_api version = projection.create_configuration(_registered_package()) request = CreateRunRequest( operation="plan", @@ -214,7 +214,7 @@ def test_admission_reads_registered_binding_before_allocating_run( registry_version=version.registry_version, reason="plan registered package", ) - service = ManagedRunService(projection, orchestration) + service = RunService(projection, orchestration) principal = EnvironmentPrincipalResolver.from_environment().resolve(OWNER_TOKEN) assert principal is not None @@ -237,7 +237,7 @@ def test_admission_reads_registered_binding_before_allocating_run( missing = CreateRunRequest( operation="plan", config_id="missing-config", registry_version=1, reason="refuse before allocation" ) - with pytest.raises(ManagedAPIError, match="requested configuration version does not exist"): + with pytest.raises(ServiceAPIError, match="requested configuration version does not exist"): asyncio.run(service.create_run(missing, principal, "missing-key")) assert len(orchestration.submissions) == 1 @@ -316,11 +316,11 @@ def test_worker_published_plan_is_retrievable_through_an_independent_api_project verification_notes=[], ) - managed_flow._publish_plan(worker_projection, run_id, saved, ()) + service_flow._publish_plan(worker_projection, run_id, saved, ()) - expected_plan = managed_flow._review_document(run_id, saved) + expected_plan = service_flow._review_document(run_id, saved) expected_bytes = expected_plan.model_dump_json().encode() - service = ManagedRunService(api_projection, _FakeOrchestration(), secrets=resolver.secret_values) + service = RunService(api_projection, _FakeOrchestration(), secrets=resolver.secret_values) client = TestClient(create_app(service, resolver)) headers = {"Authorization": f"Bearer {OWNER_TOKEN}"} plan_response = client.get(f"/runs/{run_id}/plan", headers=headers) @@ -334,9 +334,9 @@ def test_worker_published_plan_is_retrievable_through_an_independent_api_project def test_authentication_idempotency_and_secret_boundaries( - managed: tuple[TestClient, ProductProjection, _FakeOrchestration], tmp_path: Path + service_api: tuple[TestClient, ProductProjection, _FakeOrchestration], tmp_path: Path ) -> None: - client, projection, orchestration = managed + client, projection, orchestration = service_api missing = client.post("/runs", json={}) malformed = client.post("/runs", headers={"Authorization": "Basic not-a-bearer-token"}, json={}) @@ -399,9 +399,9 @@ def test_authentication_idempotency_and_secret_boundaries( def test_lost_submission_response_reuses_one_opaque_prefect_key_and_flow_run( - managed: tuple[TestClient, ProductProjection, _FakeOrchestration], + service_api: tuple[TestClient, ProductProjection, _FakeOrchestration], ) -> None: - client, projection, orchestration = managed + client, projection, orchestration = service_api orchestration.fail_after_accept_once = True uncertain = _create(client, key="timeout-retry-key") @@ -419,10 +419,10 @@ def test_lost_submission_response_reuses_one_opaque_prefect_key_and_flow_run( def test_reserved_apply_retry_replays_after_the_plan_artifact_expires( - managed: tuple[TestClient, ProductProjection, _FakeOrchestration], + service_api: tuple[TestClient, ProductProjection, _FakeOrchestration], tmp_path: Path, ) -> None: - client, projection, orchestration = managed + client, projection, orchestration = service_api created = _create(client) run_id = created.json()["run"]["run_id"] plan = _publish_plan(projection, run_id) @@ -449,9 +449,9 @@ def test_reserved_apply_retry_replays_after_the_plan_artifact_expires( def test_retained_routes_survive_missing_prefect_detail( - managed: tuple[TestClient, ProductProjection, _FakeOrchestration], + service_api: tuple[TestClient, ProductProjection, _FakeOrchestration], ) -> None: - client, projection, orchestration = managed + client, projection, orchestration = service_api created = _create(client) run_id = created.json()["run"]["run_id"] plan = _publish_plan(projection, run_id) @@ -520,9 +520,9 @@ def test_retained_routes_survive_missing_prefect_detail( def test_cancellation_transport_failure_remains_a_typed_mutation_error( - managed: tuple[TestClient, ProductProjection, _FakeOrchestration], + service_api: tuple[TestClient, ProductProjection, _FakeOrchestration], ) -> None: - client, projection, orchestration = managed + client, projection, orchestration = service_api created = _create(client) run_id = created.json()["run"]["run_id"] orchestration.cancel_failure = True @@ -591,11 +591,11 @@ async def set_flow_run_state(self, flow_run_id: UUID, _state: State[object]) -> ), ) def test_cancel_rejected_or_malformed_prefect_results_never_acknowledge_or_replay_202( - managed: tuple[TestClient, ProductProjection, _FakeOrchestration], + service_api: tuple[TestClient, ProductProjection, _FakeOrchestration], monkeypatch: pytest.MonkeyPatch, result: object, ) -> None: - client, projection, orchestration = managed + client, projection, orchestration = service_api created = _create(client) run_id = created.json()["run"]["run_id"] flow_run_id = created.json()["orchestration"][0]["flow_run_id"] @@ -622,11 +622,11 @@ def test_cancel_rejected_or_malformed_prefect_results_never_acknowledge_or_repla @pytest.mark.parametrize("replacement_state", [Cancelling(), Cancelled()], ids=("cancelling", "cancelled")) def test_cancel_reject_replacement_state_never_fabricates_acknowledgement( - managed: tuple[TestClient, ProductProjection, _FakeOrchestration], + service_api: tuple[TestClient, ProductProjection, _FakeOrchestration], monkeypatch: pytest.MonkeyPatch, replacement_state: State[object], ) -> None: - client, projection, orchestration = managed + client, projection, orchestration = service_api created = _create(client) run_id = created.json()["run"]["run_id"] prefect_client = _PrefectCancellationClient( @@ -653,10 +653,10 @@ def test_cancel_reject_replacement_state_never_fabricates_acknowledgement( def test_cancel_refuses_run_without_execution_without_reserving_receipt( - managed: tuple[TestClient, ProductProjection, _FakeOrchestration], + service_api: tuple[TestClient, ProductProjection, _FakeOrchestration], ) -> None: """A pre-admission refusal has no durable mutation effect.""" - client, projection, orchestration = managed + client, projection, orchestration = service_api run_id = "run-without-execution" key = "cancel-no-execution" projection.create_run( @@ -683,11 +683,11 @@ def test_cancel_refuses_run_without_execution_without_reserving_receipt( def test_cancel_observation_failure_before_admission_has_no_receipt_or_secret( - managed: tuple[TestClient, ProductProjection, _FakeOrchestration], + service_api: tuple[TestClient, ProductProjection, _FakeOrchestration], monkeypatch: pytest.MonkeyPatch, ) -> None: """Remote selection failure remains typed and cannot reserve or reflect provider detail.""" - client, projection, orchestration = managed + client, projection, orchestration = service_api created = _create(client) run_id = created.json()["run"]["run_id"] key = "cancel-observation-unavailable" @@ -713,10 +713,10 @@ async def fail_observation(_flow_run_id: str) -> Observation: # noqa: RUF029 - def test_cancel_replays_same_key_and_refuses_distinct_key_without_reserving_receipt( - managed: tuple[TestClient, ProductProjection, _FakeOrchestration], + service_api: tuple[TestClient, ProductProjection, _FakeOrchestration], ) -> None: """One intent owns the link; its key replays while a new key has no effect.""" - client, projection, orchestration = managed + client, projection, orchestration = service_api created = _create(client) run_id = created.json()["run"]["run_id"] first_key = "cancel-intent-owner" @@ -749,11 +749,11 @@ def test_cancel_replays_same_key_and_refuses_distinct_key_without_reserving_rece def test_duplicate_cancel_claim_loss_replays_concurrent_completion( - managed: tuple[TestClient, ProductProjection, _FakeOrchestration], + service_api: tuple[TestClient, ProductProjection, _FakeOrchestration], monkeypatch: pytest.MonkeyPatch, ) -> None: """A duplicate that loses the receipt claim observes the winner's completed result.""" - client, projection, orchestration = managed + client, projection, orchestration = service_api created = _create(client) run_id = created.json()["run"]["run_id"] flow_run_id = created.json()["orchestration"][0]["flow_run_id"] @@ -784,11 +784,11 @@ def lose_to_completed_duplicate(receipt_id: str, *, secrets=()) -> bool: def test_cancel_post_admission_cas_loss_completes_replayable_conflict( - managed: tuple[TestClient, ProductProjection, _FakeOrchestration], + service_api: tuple[TestClient, ProductProjection, _FakeOrchestration], monkeypatch: pytest.MonkeyPatch, ) -> None: """Business terminalization after admission settles the cancellation receipt once.""" - client, projection, orchestration = managed + client, projection, orchestration = service_api created = _create(client) run_id = created.json()["run"]["run_id"] flow_run_id = created.json()["orchestration"][0]["flow_run_id"] @@ -835,11 +835,11 @@ def lose_eligibility_after_admission( # noqa: PLR0913 - mirrors the provider CA def test_cancel_append_during_observation_never_targets_the_stale_execution( - managed: tuple[TestClient, ProductProjection, _FakeOrchestration], + service_api: tuple[TestClient, ProductProjection, _FakeOrchestration], monkeypatch: pytest.MonkeyPatch, ) -> None: """A newer durable execution invalidates selection before any remote cancel.""" - client, projection, orchestration = managed + client, projection, orchestration = service_api created = _create(client) run_id = created.json()["run"]["run_id"] old_flow_run_id = created.json()["orchestration"][0]["flow_run_id"] @@ -884,10 +884,10 @@ async def append_then_observe(flow_run_id: str) -> Observation: def test_run_resource_exposes_liveness_without_private_worker_or_receipt_ids( - managed: tuple[TestClient, ProductProjection, _FakeOrchestration], + service_api: tuple[TestClient, ProductProjection, _FakeOrchestration], ) -> None: """Public runs retain legacy links while liveness omits correlation identities.""" - client, projection, _orchestration = managed + client, projection, _orchestration = service_api created = _create(client) run_id = created.json()["run"]["run_id"] _publish_plan(projection, run_id) @@ -954,10 +954,10 @@ def test_run_resource_exposes_liveness_without_private_worker_or_receipt_ids( def test_public_run_resource_is_a_standalone_projection_of_the_product_run_contract( - managed: tuple[TestClient, ProductProjection, _FakeOrchestration], + service_api: tuple[TestClient, ProductProjection, _FakeOrchestration], ) -> None: """The server copies the store record into a neutral wire model.""" - client, _projection, _orchestration = managed + client, _projection, _orchestration = service_api product_run = ProductRun( run_id="public-contract-run", operation="plan", @@ -987,9 +987,9 @@ def test_public_run_resource_is_a_standalone_projection_of_the_product_run_contr def test_cancellation_exception_remains_typed_secret_safe_and_audited( - managed: tuple[TestClient, ProductProjection, _FakeOrchestration], + service_api: tuple[TestClient, ProductProjection, _FakeOrchestration], ) -> None: - client, projection, orchestration = managed + client, projection, orchestration = service_api created = _create(client) run_id = created.json()["run"]["run_id"] orchestration.cancel_exception = True @@ -1008,10 +1008,10 @@ def test_cancellation_exception_remains_typed_secret_safe_and_audited( def test_cancellation_remote_success_then_crash_resumes_same_intent( - managed: tuple[TestClient, ProductProjection, _FakeOrchestration], + service_api: tuple[TestClient, ProductProjection, _FakeOrchestration], ) -> None: """A lost acknowledgement response retries the exact link and completes one receipt.""" - client, projection, orchestration = managed + client, projection, orchestration = service_api created = _create(client) run_id = created.json()["run"]["run_id"] headers = {**AUTH, "Idempotency-Key": "cancel-lost-ack"} @@ -1050,10 +1050,10 @@ def test_cancellation_remote_success_then_crash_resumes_same_intent( ], ) def test_cancel_scans_past_newer_non_active_links( - managed: tuple[TestClient, ProductProjection, _FakeOrchestration], + service_api: tuple[TestClient, ProductProjection, _FakeOrchestration], newest_observation: Observation, ) -> None: - client, projection, orchestration = managed + client, projection, orchestration = service_api created = _create(client) run_id = created.json()["run"]["run_id"] plan = _publish_plan(projection, run_id) @@ -1083,9 +1083,9 @@ def test_cancel_scans_past_newer_non_active_links( def test_cancel_treats_expired_and_terminal_links_as_non_cancellable( - managed: tuple[TestClient, ProductProjection, _FakeOrchestration], + service_api: tuple[TestClient, ProductProjection, _FakeOrchestration], ) -> None: - client, projection, orchestration = managed + client, projection, orchestration = service_api created = _create(client) run_id = created.json()["run"]["run_id"] plan = _publish_plan(projection, run_id) @@ -1111,9 +1111,9 @@ def test_cancel_treats_expired_and_terminal_links_as_non_cancellable( def test_owner_admin_authorization_apply_verify_and_cancel( # noqa: PLR0914 - one end-to-end matrix. - managed: tuple[TestClient, ProductProjection, _FakeOrchestration], + service_api: tuple[TestClient, ProductProjection, _FakeOrchestration], ) -> None: - client, projection, orchestration = managed + client, projection, orchestration = service_api created = _create(client) run_id = created.json()["run"]["run_id"] plan = _publish_plan(projection, run_id) @@ -1192,9 +1192,9 @@ def test_owner_admin_authorization_apply_verify_and_cancel( # noqa: PLR0914 - o def test_concurrent_and_post_completion_distinct_apply_keys_are_refused( - managed: tuple[TestClient, ProductProjection, _FakeOrchestration], + service_api: tuple[TestClient, ProductProjection, _FakeOrchestration], ) -> None: - client, projection, orchestration = managed + client, projection, orchestration = service_api created = _create(client) run_id = created.json()["run"]["run_id"] plan = _publish_plan(projection, run_id) @@ -1253,9 +1253,9 @@ def apply(position: int): def test_confirmed_sync_reserves_its_write_admission_and_replays( - managed: tuple[TestClient, ProductProjection, _FakeOrchestration], + service_api: tuple[TestClient, ProductProjection, _FakeOrchestration], ) -> None: - client, projection, orchestration = managed + client, projection, orchestration = service_api version = client.app.state.run_binding body = { "operation": "sync", @@ -1293,13 +1293,13 @@ def test_confirmed_sync_reserves_its_write_admission_and_replays( ) @pytest.mark.parametrize("operation", ["verify", "apply", "cancel"]) def test_owner_and_administrator_mutation_matrix( - managed: tuple[TestClient, ProductProjection, _FakeOrchestration], + service_api: tuple[TestClient, ProductProjection, _FakeOrchestration], actor: str, token: str, expected_status: int, operation: str, ) -> None: - client, projection, orchestration = managed + client, projection, orchestration = service_api created = _create(client) run_id = created.json()["run"]["run_id"] plan = _publish_plan(projection, run_id) @@ -1334,11 +1334,11 @@ def test_owner_and_administrator_mutation_matrix( def test_stable_not_found_expired_unavailable_and_degraded_prefect_reads( - managed: tuple[TestClient, ProductProjection, _FakeOrchestration], + service_api: tuple[TestClient, ProductProjection, _FakeOrchestration], tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - client, projection, orchestration = managed + client, projection, orchestration = service_api headers = {"Authorization": f"Bearer {OWNER_TOKEN}"} missing = client.get("/runs/missing", headers=headers) @@ -1387,18 +1387,18 @@ async def unavailable_observation(_flow_run_id: str) -> Observation: # noqa: RU def test_generic_service_failure_is_contained_before_server_logging( - managed: tuple[TestClient, ProductProjection, _FakeOrchestration], + service_api: tuple[TestClient, ProductProjection, _FakeOrchestration], monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, ) -> None: - client, projection, _orchestration = managed + client, projection, _orchestration = service_api canary = "unexpected-storage-token-canary" def fail_lookup(_run_id: str): raise RuntimeError(canary) monkeypatch.setattr(projection, "lookup_run", fail_lookup) - with caplog.at_level(logging.ERROR, logger="infrahub_sync.managed.app"): + with caplog.at_level(logging.ERROR, logger="infrahub_sync.service.app"): response = client.get("/runs/failing-run", headers={"Authorization": f"bearer {OWNER_TOKEN}"}) assert response.status_code == 503 @@ -1409,9 +1409,9 @@ def fail_lookup(_run_id: str): def test_confirmation_schema_errors_and_openapi_contract( - managed: tuple[TestClient, ProductProjection, _FakeOrchestration], + service_api: tuple[TestClient, ProductProjection, _FakeOrchestration], ) -> None: - client, _projection, orchestration = managed + client, _projection, orchestration = service_api version = client.app.state.run_binding headers = {"Authorization": f"Bearer {OWNER_TOKEN}", "Idempotency-Key": "sync-key"} unconfirmed = client.post( @@ -1491,10 +1491,10 @@ def test_confirmation_schema_errors_and_openapi_contract( def test_version_is_unauthenticated_and_declares_the_unstable_api( - managed: tuple[TestClient, ProductProjection, _FakeOrchestration], + service_api: tuple[TestClient, ProductProjection, _FakeOrchestration], ) -> None: """Lifecycle discovery does not require a bearer token.""" - client, _projection, _orchestration = managed + client, _projection, _orchestration = service_api response = client.get("/version") diff --git a/tests/managed/test_legacy_run_binding.py b/tests/service/test_legacy_run_binding.py similarity index 81% rename from tests/managed/test_legacy_run_binding.py rename to tests/service/test_legacy_run_binding.py index 06446166..62cab7a9 100644 --- a/tests/managed/test_legacy_run_binding.py +++ b/tests/service/test_legacy_run_binding.py @@ -16,14 +16,14 @@ pytest.importorskip("opsmill_prefect_extras") from infrahub_sync.execution import RunResult -from infrahub_sync.managed import flow as managed_flow -from infrahub_sync.managed.flow import managed_sync_run from infrahub_sync.plan.canonical import canonical_json_bytes from infrahub_sync.plan.checksum import compute_plan_checksum from infrahub_sync.plan.models import PlanManifest from infrahub_sync.plan.review import SavedPlan from infrahub_sync.plan.writer import MANIFEST_FILE_NAME, OPERATIONS_FILE_NAME, PLAN_DIR_NAME, write_plan_artifact from infrahub_sync.product_store import PrefectExecutionLink, ProductRun, local_product_projection +from infrahub_sync.service import flow as service_flow +from infrahub_sync.service.flow import service_sync_run FLOW_RUN_ID = "ed4778cb-f2cf-4b1f-a87b-68be37659e93" WORKER_ID = "8c1da53d-0e6b-4d3d-a0f1-97b6a9ccebf0" @@ -56,8 +56,8 @@ def _legacy_run(cache: Path, run_id: str, operation: Literal["plan", "verify", " def _worker_execution_context(monkeypatch: pytest.MonkeyPatch) -> None: """Give direct worker calls the durable Prefect identity now required before parsing.""" monkeypatch.setenv("PREFECT__WORKER_ID", WORKER_ID) - monkeypatch.setattr(managed_flow, "_prefect_flow_run_id", lambda: FLOW_RUN_ID) - monkeypatch.setattr(managed_flow, "_require_current_worker_identity", lambda *_args: None) + monkeypatch.setattr(service_flow, "_prefect_flow_run_id", lambda: FLOW_RUN_ID) + monkeypatch.setattr(service_flow, "_require_current_worker_identity", lambda *_args: None) def _legacy_saved(run_id: str) -> SavedPlan: @@ -91,30 +91,30 @@ def test_all_absent_legacy_run_reaches_existing_local_worker_path( verified: list[tuple[str, object]] = [] parsed: list[object] = [] instance = SimpleNamespace(name="legacy-inventory") - monkeypatch.setattr(managed_flow, "_runtime", lambda: (str(tmp_path), projection)) - monkeypatch.setattr(managed_flow, "_run_logger", lambda: (logging.getLogger("test-managed"), False)) + monkeypatch.setattr(service_flow, "_runtime", lambda: (str(tmp_path), projection)) + monkeypatch.setattr(service_flow, "_run_logger", lambda: (logging.getLogger("test-service"), False)) monkeypatch.setattr( - managed_flow, + service_flow, "resolve_sync_instance", lambda name, *, directory: (resolved.append((name, directory)), instance)[1], raising=False, ) - monkeypatch.setattr(managed_flow, "resolve_config_version", lambda _instance: "legacy-config-version") - monkeypatch.setattr(managed_flow, "collect_secret_values", lambda _instance=None: ()) - monkeypatch.setattr(managed_flow, "_plan", lambda *_args, **_kwargs: saved) - monkeypatch.setattr(managed_flow, "_publish_plan", lambda *_args, **_kwargs: None) + monkeypatch.setattr(service_flow, "resolve_config_version", lambda _instance: "legacy-config-version") + monkeypatch.setattr(service_flow, "collect_secret_values", lambda _instance=None: ()) + monkeypatch.setattr(service_flow, "_plan", lambda *_args, **_kwargs: saved) + monkeypatch.setattr(service_flow, "_publish_plan", lambda *_args, **_kwargs: None) if stage == "apply": artifact = object() - monkeypatch.setattr(managed_flow, "resolve_run_directory", lambda *_args: tmp_path) - monkeypatch.setattr(managed_flow, "read_plan_artifact_bytes", lambda _path: artifact) + monkeypatch.setattr(service_flow, "resolve_run_directory", lambda *_args: tmp_path) + monkeypatch.setattr(service_flow, "read_plan_artifact_bytes", lambda _path: artifact) def verify(**kwargs: object) -> list[object]: verified.append((str(kwargs["run_id"]), kwargs["config_version"])) return [] - monkeypatch.setattr(managed_flow, "verify_plan", verify) + monkeypatch.setattr(service_flow, "verify_plan", verify) monkeypatch.setattr( - managed_flow, + service_flow, "parse_plan_artifact", lambda *_args, **_kwargs: (parsed.append(True), SimpleNamespace(manifest=saved.manifest))[1], ) @@ -132,11 +132,11 @@ def execute(*_args: object, operation: str, **_kwargs: object) -> SavedPlan | Ru artifact_path=str(tmp_path / run_id), ) - monkeypatch.setattr(managed_flow, "execute_run", execute) + monkeypatch.setattr(service_flow, "execute_run", execute) if stage == "apply": - managed_sync_run.fn(run_id, stage, expected_checksum="a" * 64, confirm_writes=True) + service_sync_run.fn(run_id, stage, expected_checksum="a" * 64, confirm_writes=True) else: - managed_sync_run.fn(run_id, stage) + service_sync_run.fn(run_id, stage) assert resolved == [("legacy-inventory", str(tmp_path))] assert verified == ([(run_id, "legacy-config-version")] if stage == "apply" else []) @@ -162,11 +162,11 @@ def test_legacy_apply_refuses_checksum_valid_nonlegacy_manifest_before_destinati instance = SimpleNamespace(name="legacy-inventory") run_dir = tmp_path / "runs" / instance.name / run_id monkeypatch.setenv("INFRAHUB_SYNC_CACHE_DIR", str(tmp_path / "runs")) - monkeypatch.setattr(managed_flow, "_runtime", lambda: (str(tmp_path), projection)) - monkeypatch.setattr(managed_flow, "_run_logger", lambda: (logging.getLogger("test-managed"), False)) - monkeypatch.setattr(managed_flow, "resolve_sync_instance", lambda *_args, **_kwargs: instance, raising=False) - monkeypatch.setattr(managed_flow, "resolve_config_version", lambda _instance: "legacy-config-version") - monkeypatch.setattr(managed_flow, "collect_secret_values", lambda _instance=None: ()) + monkeypatch.setattr(service_flow, "_runtime", lambda: (str(tmp_path), projection)) + monkeypatch.setattr(service_flow, "_run_logger", lambda: (logging.getLogger("test-service"), False)) + monkeypatch.setattr(service_flow, "resolve_sync_instance", lambda *_args, **_kwargs: instance, raising=False) + monkeypatch.setattr(service_flow, "resolve_config_version", lambda _instance: "legacy-config-version") + monkeypatch.setattr(service_flow, "collect_secret_values", lambda _instance=None: ()) if isinstance(manifest_binding, tuple): write_plan_artifact( run_dir=run_dir, @@ -198,15 +198,15 @@ def destination_construction_sentinel(*_args: object, **_kwargs: object) -> RunR msg = "destination construction sentinel reached" raise RuntimeError(msg) - monkeypatch.setattr(managed_flow, "execute_run", destination_construction_sentinel) + monkeypatch.setattr(service_flow, "execute_run", destination_construction_sentinel) with pytest.raises(RuntimeError, match=expected_error): - managed_sync_run.fn(run_id, "apply", expected_checksum="a" * 64, confirm_writes=True) + service_sync_run.fn(run_id, "apply", expected_checksum="a" * 64, confirm_writes=True) def test_prefect_binding_carrier_is_optional_only_as_one_closed_group() -> None: """Legacy submissions omit all three carrier keys; validation remains in the worker.""" - parameters = inspect.signature(managed_sync_run.fn).parameters + parameters = inspect.signature(service_sync_run.fn).parameters assert tuple(parameters)[:5] == ("run_id", "stage", "config_id", "registry_version", "package_checksum") assert tuple(parameters[name].default for name in ("config_id", "registry_version", "package_checksum")) == ( None, @@ -219,18 +219,18 @@ def test_prefect_binding_carrier_is_optional_only_as_one_closed_group() -> None: ("durable_binding", "carrier", "error"), [ pytest.param( - None, ("config-001", 1, "a" * 64), "managed run binding does not match worker parameters", id="legacy-bound" + None, ("config-001", 1, "a" * 64), "service run binding does not match worker parameters", id="legacy-bound" ), pytest.param( ("config-001", 1, "a" * 64), (None, None, None), - "managed run binding does not match worker parameters", + "service run binding does not match worker parameters", id="bound-legacy", ), pytest.param( ("config-001", 1, "a" * 64), ("config-001", None, None), - "managed worker configuration binding parameters must be all absent or all present", + "service worker configuration binding parameters must be all absent or all present", id="partial", ), ], @@ -275,13 +275,13 @@ def test_cross_product_and_partial_worker_carriers_refuse_before_runtime( ), ) constructed: list[object] = [] - monkeypatch.setattr(managed_flow, "_runtime", lambda: (str(tmp_path), projection)) - monkeypatch.setattr(managed_flow, "_run_logger", lambda: (logging.getLogger("test-managed"), False)) + monkeypatch.setattr(service_flow, "_runtime", lambda: (str(tmp_path), projection)) + monkeypatch.setattr(service_flow, "_run_logger", lambda: (logging.getLogger("test-service"), False)) monkeypatch.setattr( - managed_flow, "resolve_sync_instance", lambda *_args, **_kwargs: constructed.append(True), raising=False + service_flow, "resolve_sync_instance", lambda *_args, **_kwargs: constructed.append(True), raising=False ) - monkeypatch.setattr(managed_flow, "resolve_runtime_instance", lambda *_args, **_kwargs: constructed.append(True)) + monkeypatch.setattr(service_flow, "resolve_runtime_instance", lambda *_args, **_kwargs: constructed.append(True)) with pytest.raises(RuntimeError, match=error): - managed_sync_run.fn("carrier-refusal", "plan", *carrier) + service_sync_run.fn("carrier-refusal", "plan", *carrier) assert constructed == [] diff --git a/tests/managed/test_liveness_policy.py b/tests/service/test_liveness_policy.py similarity index 94% rename from tests/managed/test_liveness_policy.py rename to tests/service/test_liveness_policy.py index 95ff2219..d1a18111 100644 --- a/tests/managed/test_liveness_policy.py +++ b/tests/service/test_liveness_policy.py @@ -15,9 +15,9 @@ from prefect.states import Cancelled, Completed, Crashed, Failed, Running -from infrahub_sync.managed.liveness import LivenessPolicy, RunLivenessReconciler, select_cancellable_execution -from infrahub_sync.managed.orchestration import CancellationResult, Observation, PoolStatus, PoolWorker, Submission from infrahub_sync.product_store import MutationReceipt, PrefectExecutionLink, ProductRun, local_product_projection +from infrahub_sync.service.liveness import LivenessPolicy, RunLivenessReconciler, select_cancellable_execution +from infrahub_sync.service.orchestration import CancellationResult, Observation, PoolStatus, PoolWorker, Submission if TYPE_CHECKING: from opsmill_prefect_extras.executors import RemoteExecutionClient @@ -25,7 +25,7 @@ def test_admission_ttl_and_prefect_query_define_liveness_formulae(monkeypatch: pytest.MonkeyPatch) -> None: """The two accepted environment values define all derived timing.""" - from infrahub_sync.managed.liveness import LivenessPolicy + from infrahub_sync.service.liveness import LivenessPolicy monkeypatch.setenv("INFRAHUB_SYNC_RUN_ADMISSION_TTL_SECONDS", "300") policy = LivenessPolicy.from_environment(worker_query_seconds="10") @@ -47,7 +47,7 @@ def test_prefect_query_seconds_accepts_its_documented_numeric_domain( @pytest.mark.parametrize("value", ["-1", "0", "3600.1", "NaN", "Infinity", "", "ten"]) def test_prefect_query_seconds_refuses_out_of_domain_values(monkeypatch: pytest.MonkeyPatch, value: str) -> None: monkeypatch.setenv("INFRAHUB_SYNC_RUN_ADMISSION_TTL_SECONDS", "300") - with pytest.raises(ValueError, match=r"^managed liveness settings are invalid$"): + with pytest.raises(ValueError, match=r"^service liveness settings are invalid$"): LivenessPolicy.from_environment(worker_query_seconds=value) @@ -56,7 +56,7 @@ def test_admission_ttl_refuses_oversized_digits_with_fixed_unchained_error( ) -> None: monkeypatch.setenv("INFRAHUB_SYNC_RUN_ADMISSION_TTL_SECONDS", "9" * 5000) - with pytest.raises(ValueError, match=r"^managed liveness settings are invalid$") as caught: + with pytest.raises(ValueError, match=r"^service liveness settings are invalid$") as caught: LivenessPolicy.from_environment(worker_query_seconds="10") assert caught.value.__cause__ is None @@ -344,7 +344,7 @@ def test_custom_named_terminal_prefect_states_interrupt_a_claimed_execution( tmp_path, state: object, canonical_state: str ) -> None: """Terminal StateType remains authoritative when Prefect supplies a custom name.""" - from infrahub_sync.managed.orchestration import PrefectOrchestration + from infrahub_sync.service.orchestration import PrefectOrchestration now = datetime(2026, 8, 29, 12, tzinfo=timezone.utc) owner_id = uuid4() @@ -404,7 +404,7 @@ async def get_scheduled_flow_runs_for_work_pool(self, _pool: str): # noqa: PLR6 def test_custom_named_nonterminal_prefect_observation_preserves_its_name() -> None: """A nonterminal custom name remains useful live orchestration detail.""" - from infrahub_sync.managed.orchestration import PrefectOrchestration + from infrahub_sync.service.orchestration import PrefectOrchestration flow_run_id = uuid4() @@ -506,9 +506,9 @@ async def reconcile_together() -> None: def test_lifespan_continues_after_ordinary_failure_and_cancels_cleanly(tmp_path) -> None: """A transient provider failure does not kill the loop, and shutdown propagates cancellation.""" - from infrahub_sync.managed.app import create_app - from infrahub_sync.managed.auth import Principal - from infrahub_sync.managed.service import ManagedRunService + from infrahub_sync.service.app import create_app + from infrahub_sync.service.auth import Principal + from infrahub_sync.service.service import RunService class _Reconciler: cadence_seconds = 0.01 @@ -541,9 +541,7 @@ def resolve(token: str) -> Principal: projection = local_product_projection(tmp_path) reconciler = _Reconciler() app = create_app( - ManagedRunService( - projection, _Orchestration(PoolStatus(detail_available=False, queue_depth=None, observed_at=None)) - ), + RunService(projection, _Orchestration(PoolStatus(detail_available=False, queue_depth=None, observed_at=None))), _Resolver(), reconciler=cast("RunLivenessReconciler", reconciler), ) # type: ignore[arg-type] @@ -559,8 +557,8 @@ async def exercise() -> None: def test_pool_parsing_reports_a_ready_worker_when_freshness_is_exactly_at_the_bound() -> None: """A heartbeat exactly three intervals old is still fresh.""" - from infrahub_sync.managed.orchestration import PrefectOrchestration - from infrahub_sync.managed.service import _service_status # noqa: PLC2701 - public status behavior test. + from infrahub_sync.service.orchestration import PrefectOrchestration + from infrahub_sync.service.service import _service_status # noqa: PLC2701 - public status behavior test. now = datetime(2026, 8, 29, 12, tzinfo=timezone.utc) @@ -612,7 +610,7 @@ async def read_workers_for_work_pool(self, _pool: str): # noqa: PLR6301 async def get_scheduled_flow_runs_for_work_pool(self, _pool: str): # noqa: PLR6301 return [] - from infrahub_sync.managed.orchestration import PrefectOrchestration + from infrahub_sync.service.orchestration import PrefectOrchestration snapshot = asyncio.run(PrefectOrchestration(cast("RemoteExecutionClient", _PoolClient())).pool_status("pool", now)) @@ -637,7 +635,7 @@ async def read_workers_for_work_pool(self, _pool: str): # noqa: PLR6301 async def get_scheduled_flow_runs_for_work_pool(self, _pool: str): # noqa: PLR6301 return [] - from infrahub_sync.managed.orchestration import PrefectOrchestration + from infrahub_sync.service.orchestration import PrefectOrchestration snapshot = asyncio.run(PrefectOrchestration(cast("RemoteExecutionClient", _PoolClient())).pool_status("pool", now)) assert snapshot.detail_available @@ -654,7 +652,7 @@ async def read_workers_for_work_pool(self, _pool: str): # noqa: PLR6301 async def get_scheduled_flow_runs_for_work_pool(self, _pool: str): # noqa: PLR6301 return [] - from infrahub_sync.managed.orchestration import PrefectOrchestration + from infrahub_sync.service.orchestration import PrefectOrchestration snapshot = asyncio.run(PrefectOrchestration(cast("RemoteExecutionClient", _PoolClient())).pool_status("pool", now)) assert snapshot == PoolStatus(detail_available=False, queue_depth=None, observed_at=None) @@ -672,7 +670,7 @@ async def read_workers_for_work_pool(self, _pool: str): # noqa: PLR6301 async def get_scheduled_flow_runs_for_work_pool(self, _pool: str): # noqa: PLR6301 return scheduled - from infrahub_sync.managed.orchestration import PrefectOrchestration + from infrahub_sync.service.orchestration import PrefectOrchestration snapshot = asyncio.run(PrefectOrchestration(cast("RemoteExecutionClient", _PoolClient())).pool_status("pool", now)) assert snapshot == PoolStatus(detail_available=False, queue_depth=None, observed_at=None) @@ -690,7 +688,7 @@ async def read_workers_for_work_pool(self, _pool: str): # noqa: PLR6301 async def get_scheduled_flow_runs_for_work_pool(self, _pool: str): # noqa: PLR6301 return [] - from infrahub_sync.managed.orchestration import PrefectOrchestration + from infrahub_sync.service.orchestration import PrefectOrchestration snapshot = asyncio.run(PrefectOrchestration(cast("RemoteExecutionClient", _PoolClient())).pool_status("pool", now)) assert snapshot == PoolStatus(detail_available=False, queue_depth=None, observed_at=None) @@ -706,7 +704,7 @@ async def read_workers_for_work_pool(self, _pool: str): # noqa: PLR6301 async def get_scheduled_flow_runs_for_work_pool(self, _pool: str): # noqa: PLR6301 return [] - from infrahub_sync.managed.orchestration import PrefectOrchestration + from infrahub_sync.service.orchestration import PrefectOrchestration snapshot = asyncio.run( PrefectOrchestration(cast("RemoteExecutionClient", _PoolClient())).pool_status( @@ -722,8 +720,8 @@ def test_duplicate_worker_uuid_snapshot_is_unavailable_and_cannot_drive_reconcil tmp_path, worker_order: str ) -> None: """Conflicting records for one worker identity invalidate the whole pool snapshot.""" - from infrahub_sync.managed.orchestration import PrefectOrchestration - from infrahub_sync.managed.service import _service_status # noqa: PLC2701 - public boundary behavior test. + from infrahub_sync.service.orchestration import PrefectOrchestration + from infrahub_sync.service.service import _service_status # noqa: PLC2701 - public boundary behavior test. now = datetime(2026, 8, 29, 12, tzinfo=timezone.utc) worker_id = uuid4() @@ -824,7 +822,7 @@ def test_malformed_pool_snapshot_is_unavailable_and_causes_no_liveness_transitio value: object, ) -> None: """Provider-supplied invalid worker detail cannot drive status or product state.""" - from infrahub_sync.managed.service import _service_status # noqa: PLC2701 - public boundary behavior test. + from infrahub_sync.service.service import _service_status # noqa: PLC2701 - public boundary behavior test. now = datetime(2026, 8, 29, 12, tzinfo=timezone.utc) worker: dict[str, object] = { @@ -932,7 +930,7 @@ def test_pool_status_refuses_count_and_availability_invariant_violations(snapsho def test_public_worker_status_refuses_count_and_availability_invariant_violations(payload: dict[str, object]) -> None: from pydantic import ValidationError - from infrahub_sync.managed.models import WorkerStatusResource + from infrahub_sync.service.models import WorkerStatusResource with pytest.raises(ValidationError): WorkerStatusResource.model_validate(payload) @@ -942,9 +940,9 @@ def test_request_time_reconciliation_terminalizes_each_pending_link(tmp_path) -> """A run read does not wait for the next background loop to apply admission TTL.""" from fastapi.testclient import TestClient - from infrahub_sync.managed.app import create_app - from infrahub_sync.managed.auth import Principal - from infrahub_sync.managed.service import ManagedRunService + from infrahub_sync.service.app import create_app + from infrahub_sync.service.auth import Principal + from infrahub_sync.service.service import RunService now = datetime(2026, 8, 29, 12, tzinfo=timezone.utc) projection = local_product_projection(tmp_path) @@ -970,7 +968,7 @@ def resolve(token: str) -> Principal: del token return Principal(actor="operator") - app = create_app(ManagedRunService(projection, orchestration), _Resolver(), reconciler=reconciler) # type: ignore[arg-type] + app = create_app(RunService(projection, orchestration), _Resolver(), reconciler=reconciler) # type: ignore[arg-type] response = TestClient(app).get("/runs/run-request-reconcile", headers={"Authorization": "Bearer token"}) assert response.status_code == 200 diff --git a/tests/managed/test_registered_plan_apply.py b/tests/service/test_registered_plan_apply.py similarity index 91% rename from tests/managed/test_registered_plan_apply.py rename to tests/service/test_registered_plan_apply.py index 5daf29c1..bcbdf579 100644 --- a/tests/managed/test_registered_plan_apply.py +++ b/tests/service/test_registered_plan_apply.py @@ -14,13 +14,13 @@ from infrahub_sync.configuration import ConfigurationPackage from infrahub_sync.configuration.runtime import resolve_runtime_instance from infrahub_sync.execution import RunResult -from infrahub_sync.managed import flow as managed_flow -from infrahub_sync.managed.flow import managed_sync_run from infrahub_sync.plan.config_version import resolve_config_version from infrahub_sync.plan.models import PlannedOperation from infrahub_sync.plan.writer import write_plan_artifact from infrahub_sync.product_store import PrefectExecutionLink, ProductRun, local_product_projection from infrahub_sync.runtime_schema import RuntimeModelPlan, RuntimeSideModels +from infrahub_sync.service import flow as service_flow +from infrahub_sync.service.flow import service_sync_run from tests.configuration.validation_packages import package from tests.plan.artifact_fixtures import operation_record @@ -81,13 +81,13 @@ def _registered_apply( schema_fingerprint=None if manifest_binding is None else SCHEMA_FINGERPRINT, ) calls: list[str] = [] - monkeypatch.setattr(managed_flow, "_runtime", lambda: (str(tmp_path), projection)) - monkeypatch.setattr(managed_flow, "_run_logger", lambda: (managed_flow.logger, False)) + monkeypatch.setattr(service_flow, "_runtime", lambda: (str(tmp_path), projection)) + monkeypatch.setattr(service_flow, "_run_logger", lambda: (service_flow.logger, False)) monkeypatch.setenv("PREFECT__WORKER_ID", WORKER_ID) - monkeypatch.setattr(managed_flow, "_prefect_flow_run_id", lambda: FLOW_RUN_ID) - monkeypatch.setattr(managed_flow, "_require_current_worker_identity", lambda *_args: None) + monkeypatch.setattr(service_flow, "_prefect_flow_run_id", lambda: FLOW_RUN_ID) + monkeypatch.setattr(service_flow, "_require_current_worker_identity", lambda *_args: None) monkeypatch.setattr( - managed_flow, + service_flow, "build_runtime_model_plan", lambda **_kwargs: RuntimeModelPlan( branch="main", @@ -109,7 +109,7 @@ def destination_forbidden(*_args: object, **_kwargs: object) -> RunResult: artifact_path=str(tmp_path / "runs" / runtime.name / run_id), ) - monkeypatch.setattr(managed_flow, "execute_run", destination_forbidden) + monkeypatch.setattr(service_flow, "execute_run", destination_forbidden) return run_id, binding, manifest.plan_checksum, calls @@ -128,14 +128,14 @@ def test_bound_apply_refuses_nonmatching_manifest_before_destination( run_id, binding, checksum, calls = _registered_apply(tmp_path, monkeypatch, manifest_binding=manifest_binding) with pytest.raises(RuntimeError, match="registered saved plan binding"): - managed_sync_run.fn(run_id, "apply", *binding, expected_checksum=checksum, confirm_writes=True) + service_sync_run.fn(run_id, "apply", *binding, expected_checksum=checksum, confirm_writes=True) assert calls == [] def test_bound_apply_accepts_an_exact_manifest_binding(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: run_id, binding, checksum, calls = _registered_apply(tmp_path, monkeypatch, manifest_binding="exact") - managed_sync_run.fn(run_id, "apply", *binding, expected_checksum=checksum, confirm_writes=True) + service_sync_run.fn(run_id, "apply", *binding, expected_checksum=checksum, confirm_writes=True) assert calls == ["execute-run"] @@ -221,12 +221,12 @@ def _refuse_adapter_import(*, sync_instance: Any, adapter: Any) -> type: # noqa raise AssertionError(msg) monkeypatch.setattr("infrahub_sync.utils.import_adapter", _refuse_adapter_import) - monkeypatch.setattr(managed_flow, "_runtime", lambda: (str(tmp_path), projection)) - monkeypatch.setattr(managed_flow, "_run_logger", lambda: (managed_flow.logger, False)) - monkeypatch.setattr(managed_flow, "_prefect_flow_run_id", lambda: FLOW_RUN_ID) - monkeypatch.setattr(managed_flow, "_require_current_worker_identity", lambda *_args: None) + monkeypatch.setattr(service_flow, "_runtime", lambda: (str(tmp_path), projection)) + monkeypatch.setattr(service_flow, "_run_logger", lambda: (service_flow.logger, False)) + monkeypatch.setattr(service_flow, "_prefect_flow_run_id", lambda: FLOW_RUN_ID) + monkeypatch.setattr(service_flow, "_require_current_worker_identity", lambda *_args: None) monkeypatch.setattr( - managed_flow, + service_flow, "build_runtime_model_plan", lambda **_kwargs: RuntimeModelPlan( branch="main", @@ -238,7 +238,7 @@ def _refuse_adapter_import(*, sync_instance: Any, adapter: Any) -> type: # noqa # The apply host itself: the destination credential resolves, the source one does not exist. monkeypatch.delenv("NETBOX_TOKEN") - result = managed_sync_run.fn( + result = service_sync_run.fn( run_id, "apply", *binding, expected_checksum=manifest.plan_checksum, confirm_writes=True ) diff --git a/tests/managed/test_registered_schema_guard.py b/tests/service/test_registered_schema_guard.py similarity index 97% rename from tests/managed/test_registered_schema_guard.py rename to tests/service/test_registered_schema_guard.py index 5f6f8a29..55bfeca6 100644 --- a/tests/managed/test_registered_schema_guard.py +++ b/tests/service/test_registered_schema_guard.py @@ -30,9 +30,6 @@ from infrahub_sync.configuration.capabilities import DestinationSchemaReadError from infrahub_sync.configuration.runtime import resolve_runtime_instance from infrahub_sync.execution import RunResult -from infrahub_sync.managed import flow as managed_flow -from infrahub_sync.managed.flow import managed_sync_run -from infrahub_sync.managed.service import PLAN_ARTIFACT_ID from infrahub_sync.plan.canonical import canonical_json_bytes from infrahub_sync.plan.checksum import compute_plan_checksum from infrahub_sync.plan.config_version import resolve_config_version @@ -42,6 +39,9 @@ from infrahub_sync.product_store import PrefectExecutionLink, ProductRun, local_product_projection from infrahub_sync.runtime_schema import compute_consumed_schema_fingerprint, normalize_destination_schema from infrahub_sync.runtime_schema import worker as worker_module +from infrahub_sync.service import flow as service_flow +from infrahub_sync.service.flow import service_sync_run +from infrahub_sync.service.service import PLAN_ARTIFACT_ID from tests.configuration.validation_packages import package_data from tests.plan.artifact_fixtures import duplicated_key_manifest_bytes @@ -202,10 +202,10 @@ def _harness( spy = _SnapshotSpy() monkeypatch.setattr(worker_module, "read_destination_schema_snapshot", spy) - monkeypatch.setattr(managed_flow, "_runtime", lambda: (str(tmp_path), projection)) - monkeypatch.setattr(managed_flow, "_run_logger", lambda: (managed_flow.logger, False)) - monkeypatch.setattr(managed_flow, "_prefect_flow_run_id", lambda: FLOW_RUN_ID) - monkeypatch.setattr(managed_flow, "_require_current_worker_identity", lambda *_args: None) + monkeypatch.setattr(service_flow, "_runtime", lambda: (str(tmp_path), projection)) + monkeypatch.setattr(service_flow, "_run_logger", lambda: (service_flow.logger, False)) + monkeypatch.setattr(service_flow, "_prefect_flow_run_id", lambda: FLOW_RUN_ID) + monkeypatch.setattr(service_flow, "_require_current_worker_identity", lambda *_args: None) calls: list[dict[str, Any]] = [] @@ -221,7 +221,7 @@ def _execution_sentinel(*_args: object, **kwargs: Any) -> RunResult: # noqa: AN artifact_path=str(run_dir), ) - monkeypatch.setattr(managed_flow, "execute_run", _execution_sentinel) + monkeypatch.setattr(service_flow, "execute_run", _execution_sentinel) return _Harness( binding=binding, checksum=manifest.plan_checksum, @@ -234,7 +234,7 @@ def _execution_sentinel(*_args: object, **kwargs: Any) -> RunResult: # noqa: AN def _apply(harness: _Harness, *, expected_checksum: str | None = None) -> dict[str, Any]: - return managed_sync_run.fn( + return service_sync_run.fn( RUN_ID, "apply", *harness.binding, @@ -245,14 +245,14 @@ def _apply(harness: _Harness, *, expected_checksum: str | None = None) -> dict[s def _execute_apply_stage(harness: _Harness) -> tuple[dict[str, Any], Any]: """Drive the production stage boundary before remote exception sanitization.""" - return managed_flow._execute_stage( + return service_flow._execute_stage( RUN_ID, "apply", *harness.binding, None, harness.checksum, confirm_writes=True, - run_logger=managed_flow.logger, + run_logger=service_flow.logger, secrets=[], config_directory=str(harness.run_dir.parents[2]), projection=harness.projection, @@ -546,7 +546,7 @@ def test_the_later_artifact_read_is_given_the_same_approved_checksum( def test_no_worker_parameter_offers_a_schema_override() -> None: """AR8: no flag or argument reaches the guard's decision.""" - assert tuple(inspect.signature(managed_sync_run.fn).parameters) == ( + assert tuple(inspect.signature(service_sync_run.fn).parameters) == ( "run_id", "stage", "config_id", @@ -612,7 +612,7 @@ def test_a_failed_schema_read_refuses_with_only_its_short_reason( def _published_plan(harness: _Harness) -> PlanResource: """Publish the retained plan through the real review path and read the stored bytes back.""" saved = read_saved_plan(sync_name=harness.instance.name, run_id=RUN_ID) - managed_flow._publish_plan(harness.projection, RUN_ID, saved, []) + service_flow._publish_plan(harness.projection, RUN_ID, saved, []) stored = harness.projection.lookup_artifact(RUN_ID, PLAN_ARTIFACT_ID) assert stored.value is not None return PlanResource.model_validate_json(stored.value) @@ -646,7 +646,7 @@ def test_a_published_unregistered_plan_carries_no_schema_binding( ) saved = read_saved_plan(sync_name=harness.instance.name, run_id="legacy-run") - document = managed_flow._review_document("legacy-run", saved) + document = service_flow._review_document("legacy-run", saved) assert document.schema_fingerprint is None assert json.loads(document.model_dump_json())["schema_fingerprint"] is None diff --git a/tests/service/test_runtime_identity.py b/tests/service/test_runtime_identity.py index c2f1bae7..8576a5f3 100644 --- a/tests/service/test_runtime_identity.py +++ b/tests/service/test_runtime_identity.py @@ -9,20 +9,18 @@ pytest.importorskip("prefect") -from prefect.workers.process import ProcessWorker # noqa: E402 +from prefect.workers.process import ProcessWorker -from infrahub_sync.service.deploy import CATALOGUE # noqa: E402 -from infrahub_sync.service.orchestration import ( # noqa: E402 - SERVICE_DEPLOYMENT_NAME, +from infrahub_sync.service.deploy import CATALOGUE +from infrahub_sync.service.orchestration import ( SERVICE_DEFINITION, + SERVICE_DEPLOYMENT_NAME, SERVICE_FLOW_NAME, ) -from infrahub_sync.service.worker import ServiceProcessWorker, service_worker_name # noqa: E402 +from infrahub_sync.service.worker import ServiceProcessWorker, service_worker_name -_RUNTIME_IDENTITY_SOURCES = ( - Path(__file__).resolve().parents[2] / "infrahub_sync" / "service", - Path(__file__).resolve().parents[2] / "tasks" / "preview.py", -) +_SERVICE_PACKAGE = Path(__file__).resolve().parents[2] / "infrahub_sync" / "service" +_LEGACY_PREFECT_IDENTITY = "infrahub-sync-managed" def test_the_deployment_registers_the_service_flow_and_deployment_names() -> None: @@ -51,8 +49,9 @@ def test_exactly_one_deployment_is_registered() -> None: def test_the_registered_worker_name_carries_the_service_prefix() -> None: name = service_worker_name() - prefix, _, suffix = name.rpartition("-") - assert prefix == "infrahub-sync-service" + prefix = "infrahub-sync-service-" + assert name.startswith(prefix) + suffix = name.removeprefix(prefix) assert str(UUID(suffix)) == suffix @@ -63,14 +62,11 @@ def test_the_worker_dispatch_key_is_service_named_and_distinct_from_prefect() -> def test_no_legacy_prefect_identity_string_survives_beside_the_service_one() -> None: """The identity is renamed, not duplicated: nothing live still says the old name.""" - offenders: list[str] = [] - for source in _RUNTIME_IDENTITY_SOURCES: - paths = sorted(source.rglob("*.py")) if source.is_dir() else [source] - offenders.extend( - f"{path}:{number}" - for path in paths - for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1) - if "infrahub-sync-managed" in line - ) + offenders = [ + f"{path.name}:{number}" + for path in sorted(_SERVICE_PACKAGE.rglob("*.py")) + for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1) + if _LEGACY_PREFECT_IDENTITY in line + ] assert offenders == [] diff --git a/tests/managed/test_managed_worker.py b/tests/service/test_service_worker.py similarity index 83% rename from tests/managed/test_managed_worker.py rename to tests/service/test_service_worker.py index 31c50107..14caf73c 100644 --- a/tests/managed/test_managed_worker.py +++ b/tests/service/test_service_worker.py @@ -1,4 +1,4 @@ -"""Managed process-worker identity resolution and child attribution.""" +"""Service process-worker identity resolution and child attribution.""" from __future__ import annotations @@ -23,14 +23,14 @@ from prefect.server.schemas.actions import LogCreate as ServerLogCreate from prefect.workers.process import ProcessWorker -from infrahub_sync.managed import flow as managed_flow -from infrahub_sync.managed.worker import ManagedProcessWorker, ManagedWorkerIdentityError, managed_worker_name from infrahub_sync.product_store import PrefectExecutionLink, ProductRun, local_product_projection +from infrahub_sync.service import flow as service_flow +from infrahub_sync.service.worker import ServiceProcessWorker, ServiceWorkerIdentityError, service_worker_name if TYPE_CHECKING: from prefect.client.schemas.objects import FlowRun, WorkPool -POOL_NAME = "managed-pool" +POOL_NAME = "service-pool" POOL_ID = UUID("e0679e8a-9460-4ca7-8bf1-70bf967eed2d") FLOW_ID = UUID("ed4778cb-f2cf-4b1f-a87b-68be37659e93") FIRST_WORKER_ID = UUID("8c1da53d-0e6b-4d3d-a0f1-97b6a9ccebf0") @@ -70,7 +70,7 @@ async def read_workers_for_work_pool( return self.records[start:stop] async def read_flow(self, flow_id: UUID) -> SimpleNamespace: # noqa: PLR6301 - Prefect client protocol. - return SimpleNamespace(id=flow_id, name="managed-flow", labels={}) + return SimpleNamespace(id=flow_id, name="service-flow", labels={}) class _FlowWorkerRegistryClient: @@ -102,8 +102,8 @@ def read_workers_for_work_pool( return self.records[start:stop] -def _worker(name: str, records: list[Any]) -> ManagedProcessWorker: - worker = ManagedProcessWorker(work_pool_name=POOL_NAME, name=name) +def _worker(name: str, records: list[Any]) -> ServiceProcessWorker: + worker = ServiceProcessWorker(work_pool_name=POOL_NAME, name=name) worker._client = cast("Any", _WorkerClient(records)) worker._work_pool = cast( "WorkPool", @@ -115,7 +115,7 @@ def _worker(name: str, records: list[Any]) -> ManagedProcessWorker: def _flow_run() -> SimpleNamespace: return SimpleNamespace( id=FLOW_ID, - name="managed-run", + name="service-run", flow_id=SECOND_WORKER_ID, deployment_id=None, job_variables={}, @@ -132,7 +132,7 @@ async def execute_flow_run(self, **kwargs: Any) -> SimpleNamespace: # noqa: ANN return SimpleNamespace(returncode=0, pid=42) -def _stub_submission(worker: ManagedProcessWorker) -> _Runner: +def _stub_submission(worker: ServiceProcessWorker) -> _Runner: runner = _Runner() worker._runner = cast("Any", runner) worker._emit_flow_run_submitted_event = cast("Any", lambda _configuration: None) # type: ignore[method-assign] @@ -147,17 +147,17 @@ def _stub_submission(worker: ManagedProcessWorker) -> _Runner: "records", [ pytest.param([], id="absent"), - pytest.param([_record("managed-a", str(FIRST_WORKER_ID).upper())], id="noncanonical-id"), + pytest.param([_record("service-a", str(FIRST_WORKER_ID).upper())], id="noncanonical-id"), pytest.param( - [_record("managed-a", FIRST_WORKER_ID), _record("managed-a", SECOND_WORKER_ID)], + [_record("service-a", FIRST_WORKER_ID), _record("service-a", SECOND_WORKER_ID)], id="ambiguous-name", ), - pytest.param([_record("managed-a", status=WorkerStatus.OFFLINE)], id="not-online"), - pytest.param([_record("managed-a", work_pool_id=SECOND_WORKER_ID)], id="wrong-pool"), + pytest.param([_record("service-a", status=WorkerStatus.OFFLINE)], id="not-online"), + pytest.param([_record("service-a", work_pool_id=SECOND_WORKER_ID)], id="wrong-pool"), ], ) async def test_unresolved_identity_refuses_before_polling(records: list[object]) -> None: - worker = _worker("managed-a", records) + worker = _worker("service-a", records) polled: list[bool] = [] worker._get_scheduled_flow_runs = cast( # type: ignore[method-assign] @@ -165,7 +165,7 @@ async def test_unresolved_identity_refuses_before_polling(records: list[object]) AsyncMock(side_effect=lambda: polled.append(True) or []), ) - with pytest.raises(ManagedWorkerIdentityError, match="managed worker identity is unavailable"): + with pytest.raises(ServiceWorkerIdentityError, match="service worker identity is unavailable"): await worker._initialize_after_sync() assert worker.backend_id is None @@ -175,8 +175,8 @@ async def test_unresolved_identity_refuses_before_polling(records: list[object]) async def test_restart_resolves_the_current_server_worker_uuid() -> None: - first = _worker("managed-a", [_record("managed-a", FIRST_WORKER_ID)]) - restarted = _worker("managed-a", [_record("managed-a", SECOND_WORKER_ID)]) + first = _worker("service-a", [_record("service-a", FIRST_WORKER_ID)]) + restarted = _worker("service-a", [_record("service-a", SECOND_WORKER_ID)]) await first._refresh_worker_identity() await restarted._refresh_worker_identity() @@ -186,8 +186,8 @@ async def test_restart_resolves_the_current_server_worker_uuid() -> None: def test_supported_worker_entrypoint_generates_a_distinct_name_per_process() -> None: - first = managed_worker_name() - second = managed_worker_name() + first = service_worker_name() + second = service_worker_name() assert first != second assert str(UUID(first[-36:])) == first[-36:] @@ -195,9 +195,9 @@ def test_supported_worker_entrypoint_generates_a_distinct_name_per_process() -> async def test_uniquely_named_workers_do_not_share_identity() -> None: - records = [_record("managed-a", FIRST_WORKER_ID), _record("managed-b", SECOND_WORKER_ID)] - first = _worker("managed-a", records) - second = _worker("managed-b", records) + records = [_record("service-a", FIRST_WORKER_ID), _record("service-b", SECOND_WORKER_ID)] + first = _worker("service-a", records) + second = _worker("service-b", records) await first._refresh_worker_identity() await second._refresh_worker_identity() @@ -210,15 +210,15 @@ async def test_uniquely_named_workers_do_not_share_identity() -> None: async def test_self_hosted_logs_omit_worker_metadata_but_keep_child_identity( caplog: pytest.LogCaptureFixture, ) -> None: - worker = _worker("managed-a", [_record("managed-a", FIRST_WORKER_ID)]) + worker = _worker("service-a", [_record("service-a", FIRST_WORKER_ID)]) await worker._refresh_worker_identity() with caplog.at_level(logging.INFO): - worker._logger.info("managed worker heartbeat completed") - worker.get_flow_run_logger(cast("Any", _flow_run())).info("managed run submitted") + worker._logger.info("service worker heartbeat completed") + worker.get_flow_run_logger(cast("Any", _flow_run())).info("service run submitted") - worker_record = next(record for record in caplog.records if record.message == "managed worker heartbeat completed") - flow_record = next(record for record in caplog.records if record.message == "managed run submitted") + worker_record = next(record for record in caplog.records if record.message == "service worker heartbeat completed") + flow_record = next(record for record in caplog.records if record.message == "service run submitted") assert not hasattr(worker_record, "worker_id") payload = APILogHandler().prepare(flow_record) @@ -229,13 +229,13 @@ async def test_self_hosted_logs_omit_worker_metadata_but_keep_child_identity( ServerLogCreate.model_validate({**payload, "worker_id": str(FIRST_WORKER_ID)}) assert worker.backend_id == FIRST_WORKER_ID - assert worker_record.message == "managed worker heartbeat completed" + assert worker_record.message == "service worker heartbeat completed" async def test_polling_submission_refuses_when_refresh_changes_identity_to_none( monkeypatch: pytest.MonkeyPatch, ) -> None: - worker = _worker("managed-a", [_record("managed-a", FIRST_WORKER_ID)]) + worker = _worker("service-a", [_record("service-a", FIRST_WORKER_ID)]) await worker._refresh_worker_identity() runner = _stub_submission(worker) poll_started = asyncio.Event() @@ -273,14 +273,14 @@ async def _submit_scheduled(*, flow_run_response: list[object]) -> list[object]: await refresh_task assert len(results) == 1 - assert isinstance(results[0], ManagedWorkerIdentityError) + assert isinstance(results[0], ServiceWorkerIdentityError) assert runner.child_environments == [] async def test_polling_submission_refuses_when_refresh_rebinds_to_a_new_uuid( monkeypatch: pytest.MonkeyPatch, ) -> None: - worker = _worker("managed-a", [_record("managed-a", FIRST_WORKER_ID)]) + worker = _worker("service-a", [_record("service-a", FIRST_WORKER_ID)]) await worker._refresh_worker_identity() runner = _stub_submission(worker) poll_started = asyncio.Event() @@ -309,7 +309,7 @@ async def _submit_scheduled(*, flow_run_response: list[object]) -> list[object]: worker._has_successfully_synced = True poll_task = asyncio.create_task(worker.get_and_submit_flow_runs()) await poll_started.wait() - cast("_WorkerClient", worker._client).records = [_record("managed-a", SECOND_WORKER_ID)] + cast("_WorkerClient", worker._client).records = [_record("service-a", SECOND_WORKER_ID)] await worker.sync_with_backend() assert worker.backend_id == SECOND_WORKER_ID @@ -317,14 +317,14 @@ async def _submit_scheduled(*, flow_run_response: list[object]) -> list[object]: results = await poll_task assert len(results) == 1 - assert isinstance(results[0], ManagedWorkerIdentityError) + assert isinstance(results[0], ServiceWorkerIdentityError) assert runner.child_environments == [] async def test_recurring_sync_clears_readiness_until_identity_is_refreshed( monkeypatch: pytest.MonkeyPatch, ) -> None: - worker = _worker("managed-a", [_record("managed-a", FIRST_WORKER_ID)]) + worker = _worker("service-a", [_record("service-a", FIRST_WORKER_ID)]) worker._has_successfully_synced = True observed: list[bool] = [] @@ -347,7 +347,7 @@ async def _base_initialize(self: ProcessWorker) -> None: # noqa: RUF029 - await async def test_prefect_381_injects_the_resolved_worker_uuid_into_the_actual_child_environment() -> None: assert prefect_version == "3.8.1" - worker = _worker("managed-a", [_record("managed-a", FIRST_WORKER_ID)]) + worker = _worker("service-a", [_record("service-a", FIRST_WORKER_ID)]) await worker._refresh_worker_identity() flow_run = cast("FlowRun", _flow_run()) runner = _stub_submission(worker) @@ -356,14 +356,14 @@ async def test_prefect_381_injects_the_resolved_worker_uuid_into_the_actual_chil assert not isinstance(result, Exception) assert result.status_code == 0 assert runner.child_environments[0]["PREFECT__WORKER_ID"] == str(FIRST_WORKER_ID) - assert runner.child_environments[0]["PREFECT__WORKER_NAME"] == "managed-a" + assert runner.child_environments[0]["PREFECT__WORKER_NAME"] == "service-a" async def test_child_refuses_stale_identity_after_start_before_claim( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: - """Force child start, server W1→W2, then the production managed-flow claim.""" + """Force child start, server W1→W2, then the production service-flow claim.""" run_id = "run-child-identity-race" now = datetime.now(timezone.utc) projection = local_product_projection(tmp_path / "product") @@ -381,10 +381,10 @@ async def test_child_refuses_stale_identity_after_start_before_claim( PrefectExecutionLink(flow_run_id=str(FLOW_ID), purpose="plan", attempt=1, submitted_at=now), ) - worker = _worker("managed-a", [_record("managed-a", FIRST_WORKER_ID)]) + worker = _worker("service-a", [_record("service-a", FIRST_WORKER_ID)]) await worker._refresh_worker_identity() _stub_submission(worker) - registry = _FlowWorkerRegistryClient([_record("managed-a", FIRST_WORKER_ID)]) + registry = _FlowWorkerRegistryClient([_record("service-a", FIRST_WORKER_ID)]) child_started = asyncio.Event() release_claim = asyncio.Event() claim_errors: list[RuntimeError] = [] @@ -399,16 +399,16 @@ async def execute_flow_run(self, **kwargs: Any) -> SimpleNamespace: # noqa: ANN with monkeypatch.context() as child: child.setenv("PREFECT__WORKER_ID", child_environment["PREFECT__WORKER_ID"]) child.setenv("PREFECT__WORKER_NAME", child_environment["PREFECT__WORKER_NAME"]) - child.setattr(managed_flow, "get_client", lambda **_kwargs: registry, raising=False) - child.setattr(managed_flow, "_runtime", lambda: (str(tmp_path), projection)) - child.setattr(managed_flow, "_prefect_flow_run_id", lambda: str(FLOW_ID)) + child.setattr(service_flow, "get_client", lambda **_kwargs: registry, raising=False) + child.setattr(service_flow, "_runtime", lambda: (str(tmp_path), projection)) + child.setattr(service_flow, "_prefect_flow_run_id", lambda: str(FLOW_ID)) def _post_claim(*_args: object, **_kwargs: object) -> None: post_claim_work.append(True) - child.setattr(managed_flow, "_execute_stage", _post_claim) + child.setattr(service_flow, "_execute_stage", _post_claim) try: - managed_flow.managed_sync_run.fn(run_id, "plan") + service_flow.service_sync_run.fn(run_id, "plan") except RuntimeError as exc: claim_errors.append(exc) return SimpleNamespace(returncode=0, pid=42) @@ -426,15 +426,15 @@ async def _base_initialize(self: ProcessWorker) -> None: # noqa: RUF029 - Prefe child = asyncio.create_task(worker._submit_run_and_capture_errors(cast("FlowRun", _flow_run()))) await child_started.wait() - cast("_WorkerClient", worker._client).records = [_record("managed-a", SECOND_WORKER_ID)] - registry.records = [_record("managed-a", SECOND_WORKER_ID)] + cast("_WorkerClient", worker._client).records = [_record("service-a", SECOND_WORKER_ID)] + registry.records = [_record("service-a", SECOND_WORKER_ID)] await worker.sync_with_backend() release_claim.set() result = await child assert not isinstance(result, Exception) assert len(claim_errors) == 1 - assert str(claim_errors[0]) == "managed worker execution identity is unavailable" + assert str(claim_errors[0]) == "service worker execution identity is unavailable" assert post_claim_work == [] stored = projection.lookup_run(run_id).value assert stored is not None diff --git a/tests/managed/test_storage.py b/tests/service/test_storage.py similarity index 92% rename from tests/managed/test_storage.py rename to tests/service/test_storage.py index 3818331f..8aa3e0da 100644 --- a/tests/managed/test_storage.py +++ b/tests/service/test_storage.py @@ -1,4 +1,4 @@ -"""Managed-only durable storage adapter contracts.""" +"""Service-only durable storage adapter contracts.""" from __future__ import annotations @@ -69,8 +69,8 @@ def delete_object(self, **kwargs: object) -> None: def test_s3_client_preserves_the_small_object_protocol() -> None: - """The managed SDK adapter translates only the product-store protocol.""" - from infrahub_sync.managed.storage import Boto3S3Client + """The service SDK adapter translates only the product-store protocol.""" + from infrahub_sync.service.storage import Boto3S3Client sdk = _SDK() client = Boto3S3Client(sdk) @@ -94,7 +94,7 @@ def test_s3_client_preserves_the_small_object_protocol() -> None: def test_s3_client_classifies_only_exact_conditional_responses() -> None: """412 is duplicate, while conditional 409 retries exactly three total attempts.""" - from infrahub_sync.managed.storage import Boto3S3Client + from infrahub_sync.service.storage import Boto3S3Client sdk = _SDK() client = Boto3S3Client(sdk) @@ -121,7 +121,7 @@ def test_s3_client_classifies_only_exact_conditional_responses() -> None: @pytest.mark.parametrize("status", [409, 412, 403]) def test_s3_conditional_put_classification_is_the_exact_code_status_product(code: str, status: int) -> None: """Neither a matching code nor a matching status is sufficient on its own.""" - from infrahub_sync.managed.storage import Boto3S3Client + from infrahub_sync.service.storage import Boto3S3Client sdk = _SDK() failure = _client_error(code, status) @@ -144,7 +144,7 @@ def test_s3_conditional_put_classification_is_the_exact_code_status_product(code @pytest.mark.parametrize("status", [404, 403, 200]) def test_s3_missing_get_classification_is_the_exact_code_status_product(code: str, status: int) -> None: """Only the service's exact missing-key code and HTTP status become absence.""" - from infrahub_sync.managed.storage import Boto3S3Client + from infrahub_sync.service.storage import Boto3S3Client failure = _client_error(code, status) @@ -165,7 +165,7 @@ def get_object(self, **_kwargs: object) -> dict[str, object]: def test_s3_get_accepts_only_exact_bytes_and_only_exact_missing_object() -> None: """Lookup must not turn malformed bodies or non-missing SDK failures into absence.""" - from infrahub_sync.managed.storage import Boto3S3Client, S3ProtocolError + from infrahub_sync.service.storage import Boto3S3Client, S3ProtocolError class Body: def __init__(self, value: object) -> None: @@ -198,7 +198,7 @@ def get_object(self, **_kwargs: object) -> object: def test_managed_storage_factory_validates_settings_and_hides_startup_details() -> None: """The factory has one value-free environment contract and startup failure.""" - from infrahub_sync.managed import storage + from infrahub_sync.service import storage required = { "INFRAHUB_SYNC_DATABASE_URL": "postgresql://secret-canary@db/sync", @@ -213,7 +213,7 @@ def projection(**kwargs: object) -> ProductProjection: def database_connect() -> object: return object() - result = storage.managed_product_projection( + result = storage.service_product_projection( environ={**required, "INFRAHUB_SYNC_S3_PREFIX": "/stable/", "INFRAHUB_SYNC_S3_ENDPOINT_URL": "https://s3.test"}, database_connect=database_connect, s3_client_builder=lambda service, **kwargs: captured.setdefault("sdk", {"service": service, **kwargs}), @@ -231,26 +231,26 @@ def database_connect() -> object: ): values = {**required, name: value} with pytest.raises(ValueError) as error: - storage.managed_product_projection(environ=values, s3_client_builder=lambda *_args, **_kwargs: object()) + storage.service_product_projection(environ=values, s3_client_builder=lambda *_args, **_kwargs: object()) assert name in str(error.value) assert "secret-canary" not in str(error.value) def unavailable() -> NoReturn: raise storage.ProductStoreProviderError(sqlstate="08006") - with pytest.raises(storage.ManagedStorageStartupError) as error: - storage.managed_product_projection( + with pytest.raises(storage.ServiceStorageStartupError) as error: + storage.service_product_projection( environ=required, database_connect=unavailable, s3_client_builder=lambda *_args, **_kwargs: object(), ) - assert str(error.value) == "managed durable storage startup failed" + assert str(error.value) == "service durable storage startup failed" assert error.value.__cause__ is None def test_managed_storage_settings_refuse_absence_and_normalize_the_prefix_deterministically() -> None: """Every setting refuses absence or emptiness, and no refusal reflects its value.""" - from infrahub_sync.managed import storage + from infrahub_sync.service import storage required = { storage.DATABASE_URL_ENV: "postgresql://database-secret-canary@db/sync", @@ -267,7 +267,7 @@ def projection_builder(**_kwargs: object) -> ProductProjection: return cast("ProductProjection", object()) def construct(values: dict[str, str]) -> object: - return storage.managed_product_projection( + return storage.service_product_projection( environ=values, database_connect=database_connect, s3_client_builder=s3_client_builder, @@ -292,7 +292,7 @@ def collect_prefix(**kwargs: object) -> ProductProjection: prefixes.append(kwargs["prefix"]) return cast("ProductProjection", object()) - storage.managed_product_projection( + storage.service_product_projection( environ={**required, storage.S3_PREFIX_ENV: "/one/two/"}, database_connect=database_connect, s3_client_builder=s3_client_builder, @@ -312,7 +312,7 @@ def collect_prefix(**kwargs: object) -> ProductProjection: ) def test_managed_storage_rejects_non_postgresql_conninfo_before_any_construction(database_url: str) -> None: """Database URL acceptance is exactly Psycopg's non-empty conninfo domain.""" - from infrahub_sync.managed import storage + from infrahub_sync.service import storage calls: list[str] = [] @@ -321,7 +321,7 @@ def constructed(name: str) -> NoReturn: raise AssertionError(name) with pytest.raises(ValueError) as error: - storage.managed_product_projection( + storage.service_product_projection( environ={ storage.DATABASE_URL_ENV: database_url, storage.S3_BUCKET_ENV: "bucket", @@ -339,7 +339,7 @@ def constructed(name: str) -> NoReturn: def test_managed_storage_contains_sdk_client_construction_failures() -> None: """SDK construction details become the fixed unchained startup refusal.""" - from infrahub_sync.managed import storage + from infrahub_sync.service import storage calls: list[str] = [] @@ -352,8 +352,8 @@ def projection_builder(**_kwargs: object) -> ProductProjection: calls.append("projection") return cast("ProductProjection", object()) - with pytest.raises(storage.ManagedStorageStartupError) as error: - storage.managed_product_projection( + with pytest.raises(storage.ServiceStorageStartupError) as error: + storage.service_product_projection( environ={ storage.DATABASE_URL_ENV: "postgresql://database-secret-canary@db/sync", storage.S3_BUCKET_ENV: "bucket", @@ -364,7 +364,7 @@ def projection_builder(**_kwargs: object) -> ProductProjection: projection_builder=projection_builder, ) - assert str(error.value) == "managed durable storage startup failed" + assert str(error.value) == "service durable storage startup failed" assert error.value.__cause__ is None assert "secret-canary" not in str(error.value) assert calls == ["sdk"] @@ -383,7 +383,7 @@ def projection_builder(**_kwargs: object) -> ProductProjection: ) def test_managed_storage_endpoint_rejects_non_urls_and_userinfo_before_construction(endpoint: str) -> None: """A rejected endpoint never reaches a builder and never reflects its own value.""" - from infrahub_sync.managed import storage + from infrahub_sync.service import storage calls: list[str] = [] @@ -400,7 +400,7 @@ def projection_builder(**_kwargs: object) -> ProductProjection: return cast("ProductProjection", object()) with pytest.raises(ValueError) as error: - storage.managed_product_projection( + storage.service_product_projection( environ={ storage.DATABASE_URL_ENV: "postgresql://database-secret-canary@db/sync", storage.S3_BUCKET_ENV: "bucket-secret-canary", @@ -431,7 +431,7 @@ def projection_builder(**_kwargs: object) -> ProductProjection: ) def test_managed_storage_endpoint_accepts_valid_authorities(endpoint: str) -> None: """An accepted endpoint reaches Boto3 as the operator's own unmodified string.""" - from infrahub_sync.managed import storage + from infrahub_sync.service import storage received: list[object] = [] @@ -445,7 +445,7 @@ def s3_client_builder(_service: object, **kwargs: object) -> object: def projection_builder(**_kwargs: object) -> ProductProjection: return cast("ProductProjection", object()) - storage.managed_product_projection( + storage.service_product_projection( environ={ storage.DATABASE_URL_ENV: "postgresql://db/sync", storage.S3_BUCKET_ENV: "bucket", @@ -460,7 +460,7 @@ def projection_builder(**_kwargs: object) -> ProductProjection: def test_psycopg_adapter_marks_only_driver_errors() -> None: """Psycopg errors retain SQLSTATE; unrelated provider defects escape unmarked.""" - from infrahub_sync.managed import storage + from infrahub_sync.service import storage driver_error = storage.psycopg.OperationalError("driver-secret-canary") factory = storage.PsycopgConnectionFactory(lambda _dsn: (_ for _ in ()).throw(driver_error)) @@ -477,7 +477,7 @@ def test_psycopg_adapter_marks_only_driver_errors() -> None: def test_psycopg_adapter_marks_cursor_transaction_and_cleanup_failures() -> None: """Every DB-API operation exposed to the product store preserves the typed marker.""" - from infrahub_sync.managed import storage + from infrahub_sync.service import storage cursor_message = "cursor-secret-canary" fetch_message = "fetch-secret-canary" diff --git a/tests/managed/test_storage_import_boundary.py b/tests/service/test_storage_import_boundary.py similarity index 69% rename from tests/managed/test_storage_import_boundary.py rename to tests/service/test_storage_import_boundary.py index d2f74814..df0e16ee 100644 --- a/tests/managed/test_storage_import_boundary.py +++ b/tests/service/test_storage_import_boundary.py @@ -1,4 +1,4 @@ -"""Static guard for the deployed managed storage composition boundary.""" +"""Static guard for the deployed service storage composition boundary.""" from __future__ import annotations @@ -8,12 +8,12 @@ import pytest -MANAGED_PACKAGE = Path(__file__).resolve().parents[2] / "infrahub_sync" / "managed" +SERVICE_PACKAGE = Path(__file__).resolve().parents[2] / "infrahub_sync" / "service" FORBIDDEN_PROJECTION = "local_product_projection" def _local_projection_references(path: Path) -> tuple[str, ...]: - """Return direct imports or references to the standalone projection.""" + """Return direct imports or references to the local projection.""" references: list[str] = [] tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) for node in ast.walk(tree): @@ -27,10 +27,10 @@ def _local_projection_references(path: Path) -> tuple[str, ...]: def test_deployed_managed_runtime_cannot_import_or_reference_the_local_projection() -> None: - """API and worker runtime modules must stay on the managed storage factory.""" + """API and worker runtime modules must stay on the service storage factory.""" offenders = { - str(path.relative_to(MANAGED_PACKAGE)): references - for path in sorted(MANAGED_PACKAGE.rglob("*.py")) + str(path.relative_to(SERVICE_PACKAGE)): references + for path in sorted(SERVICE_PACKAGE.rglob("*.py")) if (references := _local_projection_references(path)) } @@ -38,15 +38,15 @@ def test_deployed_managed_runtime_cannot_import_or_reference_the_local_projectio def test_deployed_runtime_defaults_bind_the_managed_projection_call_boundary() -> None: - """API and worker defaults call the managed factory while retaining explicit injection.""" + """API and worker defaults call the service factory while retaining explicit injection.""" pytest.importorskip("boto3") pytest.importorskip("prefect") pytest.importorskip("psycopg") - from infrahub_sync.managed import flow, serve, storage + from infrahub_sync.service import flow, serve, storage api_default = inspect.signature(serve.build_app).parameters["projection_factory"].default worker_default = inspect.signature(flow._runtime).parameters["projection_factory"].default - assert api_default is storage.managed_product_projection - assert worker_default is storage.managed_product_projection + assert api_default is storage.service_product_projection + assert worker_default is storage.service_product_projection diff --git a/tests/managed/test_worker_claim.py b/tests/service/test_worker_claim.py similarity index 85% rename from tests/managed/test_worker_claim.py rename to tests/service/test_worker_claim.py index 8c29c805..d8ac2aa7 100644 --- a/tests/managed/test_worker_claim.py +++ b/tests/service/test_worker_claim.py @@ -1,4 +1,4 @@ -"""Managed worker claim ordering and Prefect attribution conformance.""" +"""Service worker claim ordering and Prefect attribution conformance.""" from __future__ import annotations @@ -20,17 +20,17 @@ from prefect.client.schemas.objects import WorkerStatus from prefect.workers.process import ProcessJobConfiguration, ProcessWorker -from infrahub_sync.managed import flow as managed_flow from infrahub_sync.product_store import PrefectExecutionLink, ProductRun, local_product_projection +from infrahub_sync.service import flow as service_flow if TYPE_CHECKING: from prefect.client.schemas.objects import FlowRun FLOW_ID = "ed4778cb-f2cf-4b1f-a87b-68be37659e93" WORKER_ID = "8c1da53d-0e6b-4d3d-a0f1-97b6a9ccebf0" -WORKER_NAME = "infrahub-sync-managed-test" +WORKER_NAME = "infrahub-sync-service-test" POOL_ID = "e0679e8a-9460-4ca7-8bf1-70bf967eed2d" -POOL_NAME = "managed-pool" +POOL_NAME = "service-pool" class _PrefectWorkerClient: @@ -73,7 +73,7 @@ def read_workers_for_work_pool( def _current_prefect_worker(monkeypatch: pytest.MonkeyPatch) -> None: client = _PrefectWorkerClient() monkeypatch.setenv("PREFECT__WORKER_NAME", WORKER_NAME) - monkeypatch.setattr(managed_flow, "get_client", lambda **_kwargs: client, raising=False) + monkeypatch.setattr(service_flow, "get_client", lambda **_kwargs: client, raising=False) def _projection(tmp_path: Path, *, submitted_at: datetime | None = None, migrated: bool = False): @@ -113,7 +113,7 @@ def test_prefect_381_process_configuration_preserves_worker_attribution_environm assert prefect_version == "3.8.1" flow_run = SimpleNamespace( id=UUID(FLOW_ID), - name="managed-run", + name="service-run", flow_id=UUID("d08f703b-ce73-4269-a7aa-1bfb00f8cc63"), deployment_id=None, ) @@ -150,9 +150,9 @@ def test_worker_claims_canonical_prefect_execution_before_runtime_work( ) -> None: projection = _projection(tmp_path) monkeypatch.setenv("PREFECT__WORKER_ID", WORKER_ID) - monkeypatch.setattr(managed_flow, "_prefect_flow_run_id", lambda: FLOW_ID) + monkeypatch.setattr(service_flow, "_prefect_flow_run_id", lambda: FLOW_ID) - managed_flow._claim_current_execution(projection, "run-worker-claim") + service_flow._claim_current_execution(projection, "run-worker-claim") link = projection.lookup_run("run-worker-claim").value.prefect_executions[0] # type: ignore[union-attr] assert link.claiming_worker_id == WORKER_ID @@ -170,14 +170,14 @@ def _fail(_flow_run_id: UUID) -> SimpleNamespace: raise httpx.ConnectError(canary) monkeypatch.setattr(client, "read_flow_run", _fail) - monkeypatch.setattr(managed_flow, "get_client", lambda **_kwargs: client) + monkeypatch.setattr(service_flow, "get_client", lambda **_kwargs: client) monkeypatch.setenv("PREFECT__WORKER_ID", WORKER_ID) - monkeypatch.setattr(managed_flow, "_prefect_flow_run_id", lambda: FLOW_ID) + monkeypatch.setattr(service_flow, "_prefect_flow_run_id", lambda: FLOW_ID) with pytest.raises(RuntimeError) as caught: - managed_flow._claim_current_execution(projection, "run-worker-claim") + service_flow._claim_current_execution(projection, "run-worker-claim") - assert str(caught.value) == "managed worker execution identity is unavailable" + assert str(caught.value) == "service worker execution identity is unavailable" assert canary not in repr(caught.value) link = projection.lookup_run("run-worker-claim").value.prefect_executions[0] # type: ignore[union-attr] assert link.claimed_at is None @@ -198,12 +198,12 @@ def test_expired_worker_claim_refusal_precedes_all_stage_runtime_construction( constructed: list[str] = [] monkeypatch.setenv("PREFECT__WORKER_ID", WORKER_ID) monkeypatch.setenv("INFRAHUB_SYNC_RUN_ADMISSION_TTL_SECONDS", "1") - monkeypatch.setattr(managed_flow, "_prefect_flow_run_id", lambda: FLOW_ID) - monkeypatch.setattr(managed_flow, "_runtime", lambda: (str(tmp_path), projection)) - monkeypatch.setattr(managed_flow, "_execute_stage", lambda *_args, **_kwargs: constructed.append(stage)) + monkeypatch.setattr(service_flow, "_prefect_flow_run_id", lambda: FLOW_ID) + monkeypatch.setattr(service_flow, "_runtime", lambda: (str(tmp_path), projection)) + monkeypatch.setattr(service_flow, "_execute_stage", lambda *_args, **_kwargs: constructed.append(stage)) - with pytest.raises(RuntimeError, match="managed worker execution claim was refused"): - managed_flow.managed_sync_run.fn("run-worker-claim", stage) + with pytest.raises(RuntimeError, match="service worker execution claim was refused"): + service_flow.service_sync_run.fn("run-worker-claim", stage) assert constructed == [] @@ -225,12 +225,12 @@ def test_claim_refusal_prevents_registry_and_adapter_construction( assert projection.interrupt_execution("run-worker-claim", FLOW_ID) constructed: list[object] = [] monkeypatch.setenv("PREFECT__WORKER_ID", WORKER_ID) - monkeypatch.setattr(managed_flow, "_prefect_flow_run_id", lambda: flow_id) - monkeypatch.setattr(managed_flow, "_runtime", lambda: (str(tmp_path), projection)) - monkeypatch.setattr(managed_flow, "resolve_runtime_instance", lambda *_args, **_kwargs: constructed.append(True)) + monkeypatch.setattr(service_flow, "_prefect_flow_run_id", lambda: flow_id) + monkeypatch.setattr(service_flow, "_runtime", lambda: (str(tmp_path), projection)) + monkeypatch.setattr(service_flow, "resolve_runtime_instance", lambda *_args, **_kwargs: constructed.append(True)) - with pytest.raises(RuntimeError, match="managed worker execution claim was refused"): - managed_flow.managed_sync_run.fn("run-worker-claim", "plan") + with pytest.raises(RuntimeError, match="service worker execution claim was refused"): + service_flow.service_sync_run.fn("run-worker-claim", "plan") assert constructed == [] @@ -243,8 +243,8 @@ def test_worker_identity_rejects_noncanonical_values( monkeypatch.delenv("PREFECT__WORKER_ID", raising=False) else: monkeypatch.setenv("PREFECT__WORKER_ID", value) - monkeypatch.setattr(managed_flow, "_prefect_flow_run_id", lambda: FLOW_ID) + monkeypatch.setattr(service_flow, "_prefect_flow_run_id", lambda: FLOW_ID) - with pytest.raises(RuntimeError, match="managed worker execution identity is invalid"): - managed_flow._claim_current_execution(projection, "run-worker-claim") + with pytest.raises(RuntimeError, match="service worker execution identity is invalid"): + service_flow._claim_current_execution(projection, "run-worker-claim") assert projection.lookup_run("run-worker-claim").value.prefect_executions[0].claimed_at is None # type: ignore[union-attr] diff --git a/tests/test_linter_tasks.py b/tests/test_linter_tasks.py index f607347c..f550bfa8 100644 --- a/tests/test_linter_tasks.py +++ b/tests/test_linter_tasks.py @@ -3,7 +3,7 @@ def test_ty_check_command_excludes_managed_on_python_310() -> None: assert linter._ty_check_command(3, 10) == ( - "uv run ty check --exclude infrahub_sync/managed --exclude tests/managed ." + "uv run ty check --exclude infrahub_sync/service --exclude tests/service ." ) @@ -14,7 +14,7 @@ def test_ty_check_command_checks_managed_on_supported_python() -> None: def test_pylint_command_excludes_managed_on_python_310() -> None: assert linter._pylint_command(3, 10) == ( - "pylint --output-format=json2 --ignore-paths='^infrahub_sync/managed/' infrahub_sync/" + "pylint --output-format=json2 --ignore-paths='^infrahub_sync/service/' infrahub_sync/" ) @@ -28,7 +28,7 @@ def test_pylint_regression_locations_reports_only_regressed_codes() -> None: "messages": [ { "messageId": "E0401", - "path": "infrahub_sync/managed/deploy.py", + "path": "infrahub_sync/service/deploy.py", "line": 8, "symbol": "import-error", }, @@ -37,7 +37,7 @@ def test_pylint_regression_locations_reports_only_regressed_codes() -> None: } assert linter._pylint_regression_locations(report) == [ - "infrahub_sync/managed/deploy.py:8: E0401 (import-error)", + "infrahub_sync/service/deploy.py:8: E0401 (import-error)", ] diff --git a/tests/test_no_prefect_import.py b/tests/test_no_prefect_import.py index e17e9fce..13073f4b 100644 --- a/tests/test_no_prefect_import.py +++ b/tests/test_no_prefect_import.py @@ -20,7 +20,7 @@ REPO_ROOT = Path(__file__).resolve().parent.parent PACKAGE_ROOT = REPO_ROOT / "infrahub_sync" WORKFLOW_PATH = REPO_ROOT / ".github" / "workflows" / "workflow-tests.yml" -OPTIONAL_PACKAGE_NAMES = frozenset({"managed", "orchestration"}) +OPTIONAL_PACKAGE_NAMES = frozenset({"service", "orchestration"}) OPTIONAL_PACKAGE_PREFIXES = tuple(f"infrahub_sync.{name}" for name in sorted(OPTIONAL_PACKAGE_NAMES)) OPTIONAL_DISTRIBUTION_NAMES = frozenset( {"boto3", "botocore", "fastapi", "opsmill_prefect_extras", "prefect", "psycopg", "uvicorn"} @@ -56,7 +56,7 @@ def find_spec(self, fullname, path=None, target=None): optional_roots = {sorted(OPTIONAL_DISTRIBUTION_NAMES)!r} leaked = sorted(m for m in sys.modules if m.partition(".")[0] in optional_roots) assert not leaked, f"optional service modules imported by the base package: {{leaked}}" -print("NO-OPTIONAL-MANAGED-IMPORT-OK") +print("NO-OPTIONAL-SERVICE-IMPORT-OK") """ @@ -103,7 +103,7 @@ def _imported_names(path: Path, *, root: Path = REPO_ROOT) -> set[str]: def test_base_package_imports_and_runs_without_managed_dependencies_in_a_fresh_interpreter() -> None: - """Base imports and CLI sanity must not pull in managed dependencies.""" + """Base imports and CLI sanity must not pull in service dependencies.""" completed = subprocess.run( # noqa: S603 - fixed argv, this interpreter, no shell [sys.executable, "-c", PROBE_SCRIPT], capture_output=True, @@ -112,7 +112,7 @@ def test_base_package_imports_and_runs_without_managed_dependencies_in_a_fresh_i cwd=str(REPO_ROOT), ) assert completed.returncode == 0, completed.stdout + completed.stderr - assert "NO-OPTIONAL-MANAGED-IMPORT-OK" in completed.stdout + assert "NO-OPTIONAL-SERVICE-IMPORT-OK" in completed.stdout def test_execution_surface_imports_no_optional_managed_runtime() -> None: @@ -151,8 +151,8 @@ def test_base_install_workflow_proves_external_managed_runtimes_are_unavailable( ("infrahub_sync/probe.py", "from . import orchestration\n"), ("infrahub_sync/adapters/probe.py", "from ..orchestration import flow\n"), ("infrahub_sync/adapters/__init__.py", "from ..orchestration import flow\n"), - ("infrahub_sync/probe.py", "from .managed import app\n"), - ("infrahub_sync/adapters/probe.py", "from ..managed import app\n"), + ("infrahub_sync/probe.py", "from .service import app\n"), + ("infrahub_sync/adapters/probe.py", "from ..service import app\n"), ], ) def test_the_scan_resolves_relative_imports_of_optional_runtime_packages( diff --git a/tests/test_managed_storage_docs.py b/tests/test_service_storage_docs.py similarity index 87% rename from tests/test_managed_storage_docs.py rename to tests/test_service_storage_docs.py index 8a136635..a32cd5aa 100644 --- a/tests/test_managed_storage_docs.py +++ b/tests/test_service_storage_docs.py @@ -1,4 +1,4 @@ -"""Operator-reference contract for the deployed managed storage profile.""" +"""Operator-reference contract for the deployed service storage profile.""" from pathlib import Path @@ -18,7 +18,7 @@ @pytest.mark.parametrize("name", ["durable-product-records.mdx", "managed-http-api.mdx"]) def test_managed_storage_operator_references_state_the_complete_deployed_contract(name: str) -> None: - """Every managed-storage reference names one PostgreSQL/S3 deployment shape.""" + """Every service-storage reference names one PostgreSQL/S3 deployment shape.""" text = (REFERENCE_ROOT / name).read_text(encoding="utf-8") assert not {setting for setting in MANAGED_STORAGE_SETTINGS if f"`{setting}`" not in text} @@ -32,7 +32,7 @@ def test_managed_storage_operator_references_state_the_complete_deployed_contrac def test_durable_records_reference_limits_the_local_projection_to_the_injected_seam() -> None: - """The local projection is not presented as a deployed managed profile.""" + """The local projection is not presented as a deployed service profile.""" text = (REFERENCE_ROOT / "durable-product-records.mdx").read_text(encoding="utf-8") assert "injected standalone/test seam" in text From 6fa2c6539a5049f35c29b78c5f146a0c4e527399 Mon Sep 17 00:00:00 2001 From: Blake Ellis Date: Tue, 1 Sep 2026 14:52:31 -0400 Subject: [PATCH 03/12] Delete the standalone execution wrapper and its configuration seam Seat: lead-developer Assignment: single-product-reconciliation Co-Authored-By: Claude Opus 5 --- infrahub_sync/product_store/configs.py | 79 +-- infrahub_sync/product_store/standalone.py | 420 ---------------- infrahub_sync/service/config_routes.py | 21 +- tests/cli/test_parity_and_closure.py | 1 - .../test_product_cache_location.py | 59 --- tests/product_store/test_configs_service.py | 454 +++++++----------- .../test_validate_destination_schema.py | 63 +-- tests/service/test_config_routes.py | 58 ++- 8 files changed, 245 insertions(+), 910 deletions(-) delete mode 100644 infrahub_sync/product_store/standalone.py delete mode 100644 tests/conformance/test_product_cache_location.py diff --git a/infrahub_sync/product_store/configs.py b/infrahub_sync/product_store/configs.py index 130d658a..e3d79122 100644 --- a/infrahub_sync/product_store/configs.py +++ b/infrahub_sync/product_store/configs.py @@ -52,14 +52,12 @@ ) from infrahub_sync.configuration.validation import _location_digest from infrahub_sync.execution import REDACTED, redact -from infrahub_sync.product_store.standalone import ProductCacheLocationError, resolve_product_cache_location from infrahub_sync.product_store.store import ( ConfigurationNotFoundError, ConfigurationVersionAllocationError, DuplicateConfigurationError, ProductProjection, ProductStoreProviderError, - local_product_projection, ) if TYPE_CHECKING: @@ -156,9 +154,9 @@ def _service_boundary(operation: Callable[_P, _R]) -> Callable[_P, _R]: Classification is by ``except`` clause only, which matches the exception's actual type without touching the instance. ``isinstance`` is itself inspection — it consults the instance's ``__class__``, a read a hostile property executes on — and an exception no - arm named can have been constructed by a hostile caller argument (a - ``product_cache_location`` whose ``__str__`` raises it), so a runtime classification - table lets a hostile value escape this module as a raw untyped error. Nothing reaches + arm named can have been constructed by a hostile caller argument (a declared package + value whose ``__str__`` raises it), so a runtime classification table lets a hostile + value escape this module as a raw untyped error. Nothing reaches the text from the exception either: one fixed message per operation. The boundary infers only two families from an exception no arm named, and nothing @@ -595,48 +593,6 @@ def _needs_json_coercion(content: Mapping[str, Any]) -> bool: return False -def _projection( - product_cache_location: str | Path | None, - projection: ProductProjection | None, -) -> ProductProjection: - """Open the configuration registry, refusing an absent or non-absolute store location. - - Absence is a refusal rather than a fallback: unlike a run, a registry has nowhere to live - without an explicit store. Only the absoluteness half of the rule is shared with the - run commands. - """ - if projection is not None: - if product_cache_location is not None: - msg = "provide exactly one product projection or product_cache_location" - raise ConfigsRequestError(msg) - return projection - if product_cache_location is None or not str(product_cache_location).strip(): - msg = "product_cache_location is required: the configuration registry has no store without one" - raise ConfigsRequestError(msg) - try: - location = resolve_product_cache_location(product_cache_location) - except ProductCacheLocationError as exc: - raise ConfigsRequestError(str(exc)) from None - try: - return local_product_projection(location) - except ValueError as exc: - raise ConfigsStorageError(str(exc)) from None - except OSError as exc: - # Opening the registry creates the store's own directories, so the filesystem refuses - # here before any query runs: a file where a directory belongs, or a cache root nothing - # may write to. That is a storage refusal, not a raw traceback out of the one declared - # vocabulary both interfaces map. Only the exception type is carried, matching - # ``load_package_content`` - the errno text names paths the caller already supplied and - # adds nothing an operator can act on. - msg = f"product cache location {str(location)!r} could not be opened as a store: {type(exc).__name__}" - raise ConfigsStorageError(msg) from None - - -def _standalone_projection(product_cache_location: Path) -> ProductProjection: - """Open the explicit local compatibility seam for managed route tests.""" - return local_product_projection(product_cache_location) - - # The two machine-readable absence values the read operations distinguish. # ``CONFIGURATION_NOT_FOUND_REASON`` matches the store's own lookup reason for the same # absence; the version value names the case the store cannot: the configuration exists and @@ -759,8 +715,7 @@ def _validation_refusal(exc: CredentialConfigurationError, package: Configuratio def register( *, package: Mapping[str, Any], - product_cache_location: str | Path | None = None, - projection: ProductProjection | None = None, + projection: ProductProjection, ) -> RegisteredConfiguration: """Register a brand-new declared configuration and return it with its first version. @@ -769,7 +724,6 @@ def register( an invalid package raises and is never registered. The findings surface is :func:`validate`. """ - projection = _projection(product_cache_location, projection) parsed = _parse(package) try: version = projection.create_configuration(parsed) @@ -789,8 +743,7 @@ def create_version( *, config_id: str, package: Mapping[str, Any], - product_cache_location: str | Path | None = None, - projection: ProductProjection | None = None, + projection: ProductProjection, ) -> RegisteredVersion: """Add one version to an existing configuration, or return the identical stored one. @@ -798,7 +751,6 @@ def create_version( (:func:`_parse`). """ _require_argument_type(config_id, name="config_id", expected=str) - projection = _projection(product_cache_location, projection) parsed = _parse(package) try: version, created = projection.add_configuration_version(config_id, parsed) @@ -812,16 +764,13 @@ def create_version( @_service_boundary -def list_configs( - *, product_cache_location: str | Path | None = None, projection: ProductProjection | None = None -) -> tuple[ConfigurationSummary, ...]: +def list_configs(*, projection: ProductProjection) -> tuple[ConfigurationSummary, ...]: """Return every registered configuration exactly once, oldest first with an ID tiebreak. The order is the store's own ``ORDER BY created_at, config_id`` — deterministic and total, never a re-sort in this layer. An empty registry is a real answer here, unlike the scoped reads: there is no identifier whose absence could make it a not-found. """ - projection = _projection(product_cache_location, projection) return projection.list_configurations() @@ -829,12 +778,10 @@ def list_configs( def get_config( *, config_id: str, - product_cache_location: str | Path | None = None, - projection: ProductProjection | None = None, + projection: ProductProjection, ) -> ConfigurationSummary: """Return one registered configuration's summary, refusing absence rather than guessing.""" _require_argument_type(config_id, name="config_id", expected=str) - projection = _projection(product_cache_location, projection) return _require_configuration(projection, config_id) @@ -842,8 +789,7 @@ def get_config( def list_versions( *, config_id: str, - product_cache_location: str | Path | None = None, - projection: ProductProjection | None = None, + projection: ProductProjection, ) -> tuple[ConfigurationVersion, ...]: """Return every version of one configuration, ordered by ``registry_version`` ascending. @@ -852,7 +798,6 @@ def list_versions( store's own ``ORDER BY registry_version``, not a re-sort in this layer. """ _require_argument_type(config_id, name="config_id", expected=str) - projection = _projection(product_cache_location, projection) _require_configuration(projection, config_id) return projection.list_configuration_versions(config_id) @@ -862,8 +807,7 @@ def get_version( *, config_id: str, registry_version: int, - product_cache_location: str | Path | None = None, - projection: ProductProjection | None = None, + projection: ProductProjection, ) -> ConfigurationVersion: """Return one immutable registered version exactly as it was persisted. @@ -875,7 +819,6 @@ def get_version( """ _require_argument_type(config_id, name="config_id", expected=str) _require_registry_version(registry_version) - projection = _projection(product_cache_location, projection) _require_configuration(projection, config_id) stored = projection.lookup_configuration_version(config_id, registry_version).value if stored is None: @@ -889,8 +832,7 @@ def validate( *, config_id: str, registry_version: int, - product_cache_location: str | Path | None = None, - projection: ProductProjection | None = None, + projection: ProductProjection, destination_schema: DestinationSchemaOptions | None = None, ) -> ValidationReport: """Report every declared defect in one registered version, in contract order. @@ -909,7 +851,6 @@ def validate( _require_registry_version(registry_version) if destination_schema is not None: _require_argument_type(destination_schema, name="destination_schema", expected=DestinationSchemaOptions) - projection = _projection(product_cache_location, projection) lookup = projection.lookup_configuration_version(config_id, registry_version) stored = lookup.value if stored is None: diff --git a/infrahub_sync/product_store/standalone.py b/infrahub_sync/product_store/standalone.py deleted file mode 100644 index bae492a8..00000000 --- a/infrahub_sync/product_store/standalone.py +++ /dev/null @@ -1,420 +0,0 @@ -"""Opt-in DB-003 product projection for standalone core callers.""" - -from __future__ import annotations - -import logging -from datetime import datetime, timezone -from pathlib import Path -from typing import TYPE_CHECKING, Any, Literal, overload - -from pydantic import BaseModel, ConfigDict - -from infrahub_sync.cache.paths import generate_run_id -from infrahub_sync.execution import ACTION_KEYS, Operation, RunResult, collect_secret_values, execute_run -from infrahub_sync.plan.config_version import resolve_config_version -from infrahub_sync.plan.models import ApplyRecord -from infrahub_sync.plan.review import SavedPlan, read_saved_plan -from infrahub_sync.product_store.models import ProductRun -from infrahub_sync.product_store.store import DuplicateRunError, ProductProjection, local_product_projection - -if TYPE_CHECKING: - from collections.abc import Sequence - - from infrahub_sync import SyncInstance - -PLAN_REVIEW_ARTIFACT_ID = "plan-review" -logger = logging.getLogger(__name__) - - -class SavedPlanReviewArtifact(BaseModel): - """Transport-neutral form of the managed ``plan-review`` artifact.""" - - model_config = ConfigDict(extra="forbid") - - run_id: str - checksum: str - checksum_ok: bool - verification_notes: tuple[str, ...] - summary: dict[str, Any] - operations: tuple[dict[str, Any], ...] - # Mirrors `PlanResource`: DB-003 requires the two published documents to be the same - # bytes. `None` on this path, which publishes unregistered plans. - schema_fingerprint: str | None = None - - -class StandaloneProductRecordError(Exception): - """A configured standalone projection cannot continue the requested run.""" - - -class ProductCacheLocationError(StandaloneProductRecordError, ValueError): - """The declared product-cache location breaks the one rule every entry point shares. - - It is a ``ValueError`` as well as a projection refusal so a Pydantic field validator can - let it propagate unchanged, which is how the Python API reaches the same rule as the CLI - instead of restating it (envelope OES-21). - """ - - -def resolve_product_cache_location(value: str | Path) -> Path: - """Return the absolute product-cache root for `value`, or refuse it. - - The one rule, in one place: ``~`` is expanded, and what remains must be absolute. Every - entry point that accepts a product-cache location calls this — the CLI run commands - through :func:`execute_standalone`, the version 1 Python API through its request models, - and the ``configs`` service. Only absoluteness is shared: whether a *missing* location is - a refusal or a legacy fallback belongs to the caller, because the run commands treat - ``None`` as cache-only behavior while the registry has no meaning without a store. - """ - try: - expanded = Path(value).expanduser() - except RuntimeError: - msg = f"product_cache_location has an unresolvable user home: {str(value)!r}" - raise ProductCacheLocationError(msg) from None - if not expanded.is_absolute(): - msg = f"product_cache_location must be absolute after user expansion: {str(value)!r}" - raise ProductCacheLocationError(msg) - return expanded - - -def _review_document(run_id: str, saved: SavedPlan) -> SavedPlanReviewArtifact: - return SavedPlanReviewArtifact( - run_id=run_id, - checksum=saved.manifest.plan_checksum, - checksum_ok=saved.checksum_ok, - verification_notes=tuple(saved.verification_notes), - summary=saved.summary().model_dump(mode="json"), - operations=tuple(operation.model_dump(mode="json") for operation in saved.operations()), - schema_fingerprint=saved.manifest.registered_schema_fingerprint, - ) - - -def _plan_result(run_id: str, saved: SavedPlan) -> dict[str, Any]: - summary = saved.summary() - return { - "run_id": run_id, - "stage": "plan", - "outcome": "no-change" if summary.total == 0 else "planned", - "summary": summary.model_dump(mode="json"), - } - - -def _verification_result(run_id: str, saved: SavedPlan) -> dict[str, Any]: - return { - "run_id": run_id, - "stage": "verify", - "outcome": "verified", - "checksum": saved.manifest.plan_checksum, - "checksum_ok": saved.checksum_ok, - "verification_notes": list(saved.verification_notes), - } - - -def _execution_result(result: RunResult, *, operation: Operation) -> dict[str, Any]: - return { - "run_id": result.run_id, - "operation": operation, - "outcome": result.status, - "changed": result.changed, - "summary": {key: result.summary[key] for key in ACTION_KEYS}, - } - - -def _finish_execution( - projection: ProductProjection, - *, - run_id: str, - result: RunResult, - operation: Operation, - sync_name: str, - secrets: Sequence[str], -) -> None: - projection.finish_run( - run_id, - phase="applied", - outcome=result.status, - summary={"sync_name": sync_name, **{key: result.summary[key] for key in ACTION_KEYS}}, - results=_execution_result(result, operation=operation), - secrets=secrets, - ) - - -def _publish_plan( - projection: ProductProjection, - run_id: str, - saved: SavedPlan, - secrets: Sequence[str], -) -> None: - document = _review_document(run_id, saved) - projection.publish_artifact( - run_id, - artifact_id=PLAN_REVIEW_ARTIFACT_ID, - kind="saved-plan-review", - media_type="application/json", - data=document.model_dump_json().encode(), - secrets=secrets, - ) - - -def _require_existing_run( - projection: ProductProjection, - *, - run_id: str, - sync_instance: SyncInstance, - configuration_reference: str, -) -> ProductRun: - stored = projection.lookup_run(run_id).value - if stored is None: - msg = ( - f"Configured product storage has no Sync run {run_id!r}; use the same " - "product-cache location that recorded the plan." - ) - raise StandaloneProductRecordError(msg) - if stored.configuration_reference != configuration_reference: - msg = f"Configured product record {run_id!r} does not match the current configuration fingerprint." - raise StandaloneProductRecordError(msg) - if stored.summary.get("sync_name") != sync_instance.name: - msg = f"Configured product record {run_id!r} belongs to a different synchronization." - raise StandaloneProductRecordError(msg) - return stored - - -def _record_failure( - projection: ProductProjection, - *, - run_id: str, - operation: Operation, - exc: BaseException, - secrets: Sequence[str], -) -> None: - evidence: dict[str, Any] = { - "stage": operation, - "outcome": "failed", - "error_type": type(exc).__name__, - } - apply_record = getattr(exc, "apply_record", None) - if isinstance(apply_record, ApplyRecord): - evidence.update(apply_record.as_summary_keys()) - try: - projection.merge_results(run_id, {f"{operation}_failure": evidence}, secrets=secrets) - if operation == "verify": - return - refreshed = projection.lookup_run(run_id).value - if refreshed is None: - return - partial = { - key: evidence[key] - for key in ( - "applied_operations", - "skipped_delete_operations", - "skipped_delete_count", - "failed_operation", - "may_have_partially_written", - ) - if key in evidence - } - projection.finish_run( - run_id, - phase=f"{operation}-failed", - outcome="failed", - summary={**refreshed.summary, "failed_stage": operation, **partial}, - results=refreshed.results, - secrets=secrets, - ) - except Exception as persistence_error: # noqa: BLE001 # pylint: disable=broad-exception-caught - logger.warning( - "Standalone Sync failure evidence could not be persisted (%s)", - type(persistence_error).__name__, - ) - - -def _prepare_projection( - sync_instance: SyncInstance, - *, - operation: Operation, - semantic_operation: Operation, - product_cache_location: str | Path, - kwargs: dict[str, Any], -) -> tuple[ProductProjection, str, Sequence[str]]: - supplied_run_id = kwargs.get("run_id") - if supplied_run_id is None and operation in ("plan", "sync"): - supplied_run_id = generate_run_id() - kwargs["run_id"] = supplied_run_id - if not isinstance(supplied_run_id, str): - msg = f"run_id is required for configured standalone operation={operation}" - raise StandaloneProductRecordError(msg) - - # The one shared rule (envelope OES-21). Before this, the CLI reached absoluteness only - # through the projection constructor's own check and reported a different sentence than - # the Python API did for the same input. - cache_location = resolve_product_cache_location(product_cache_location) - try: - projection = local_product_projection(cache_location) - except ValueError as exc: - raise StandaloneProductRecordError(str(exc)) from None - secrets = collect_secret_values(sync_instance) - configuration_reference = resolve_config_version(sync_instance) - if operation in ("plan", "sync"): - try: - projection.create_run( - ProductRun( - run_id=supplied_run_id, - operation=semantic_operation, - configuration_reference=configuration_reference, - started_at=datetime.now(timezone.utc), - phase="accepted", - summary={"sync_name": sync_instance.name}, - ), - secrets=secrets, - ) - except DuplicateRunError as exc: - msg = f"Configured product run {supplied_run_id!r} already exists; use a fresh run ID." - raise StandaloneProductRecordError(msg) from exc - else: - _require_existing_run( - projection, - run_id=supplied_run_id, - sync_instance=sync_instance, - configuration_reference=configuration_reference, - ) - return projection, supplied_run_id, secrets - - -@overload -def execute_standalone( - sync_instance: SyncInstance, - *, - operation: Literal["verify"], - product_cache_location: str | Path | None = ..., - product_operation: Operation | None = ..., - complete_plan: bool = ..., - _core_executor: Any = ..., - **kwargs: Any, -) -> SavedPlan: ... - - -@overload -def execute_standalone( - sync_instance: SyncInstance, - *, - operation: Literal["plan"], - product_cache_location: str | Path | None = ..., - product_operation: Operation | None = ..., - complete_plan: bool = ..., - _core_executor: Any = ..., - _return_saved_plan: Literal[True], - **kwargs: Any, -) -> SavedPlan: ... - - -@overload -def execute_standalone( - sync_instance: SyncInstance, - *, - operation: Literal["plan", "sync", "apply"], - product_cache_location: str | Path | None = ..., - product_operation: Operation | None = ..., - complete_plan: bool = ..., - _core_executor: Any = ..., - **kwargs: Any, -) -> RunResult: ... - - -def execute_standalone( # pylint: disable=too-many-branches - sync_instance: SyncInstance, - *, - operation: Operation, - product_cache_location: str | Path | None = None, - product_operation: Operation | None = None, - complete_plan: bool = True, - _core_executor: Any = None, - **kwargs: Any, -) -> RunResult | SavedPlan: - """Run the shared core and project its lifecycle when local storage is configured. - - ``product_cache_location=None`` preserves the legacy standalone cache-only - behavior. Managed callers do not use this adapter and retain their existing - HTTP/Prefect lifecycle owner. - """ - core_executor = execute_run if _core_executor is None else _core_executor - if product_cache_location is None: - return core_executor(sync_instance, operation=operation, **kwargs) - - semantic_operation = product_operation or operation - projection, supplied_run_id, secrets = _prepare_projection( - sync_instance, - operation=operation, - semantic_operation=semantic_operation, - product_cache_location=product_cache_location, - kwargs=kwargs, - ) - - plan_published = False - - def publish_committed_plan() -> None: - nonlocal plan_published - saved_plan = read_saved_plan(sync_name=sync_instance.name, run_id=supplied_run_id, config=sync_instance) - _publish_plan(projection, supplied_run_id, saved_plan, secrets) - plan_published = True - - if operation == "sync": - kwargs["_plan_committed"] = publish_committed_plan - - try: - result = core_executor(sync_instance, operation=operation, **kwargs) - if operation == "plan": - saved = ( - result - if isinstance(result, SavedPlan) - else read_saved_plan(sync_name=sync_instance.name, run_id=supplied_run_id, config=sync_instance) - ) - _publish_plan(projection, supplied_run_id, saved, secrets) - if complete_plan: - plan_result = _plan_result(supplied_run_id, saved) - projection.finish_run( - supplied_run_id, - phase="planned", - outcome=plan_result["outcome"], - summary={"sync_name": sync_instance.name, **saved.summary().model_dump(mode="json")}, - results=plan_result, - secrets=secrets, - ) - elif operation == "verify": - assert isinstance(result, SavedPlan) - if kwargs.get("_require_verified"): - projection.merge_results( - supplied_run_id, - {"verification": _verification_result(supplied_run_id, result)}, - secrets=secrets, - ) - elif operation == "apply": - assert isinstance(result, RunResult) - _finish_execution( - projection, - run_id=supplied_run_id, - result=result, - operation=semantic_operation, - sync_name=sync_instance.name, - secrets=secrets, - ) - else: - assert isinstance(result, RunResult) - if not plan_published: - publish_committed_plan() - _finish_execution( - projection, - run_id=supplied_run_id, - result=result, - operation=semantic_operation, - sync_name=sync_instance.name, - secrets=secrets, - ) - except BaseException as exc: - _record_failure( - projection, - run_id=supplied_run_id, - operation=semantic_operation, - exc=exc, - secrets=secrets, - ) - raise - return result diff --git a/infrahub_sync/service/config_routes.py b/infrahub_sync/service/config_routes.py index 3f884f65..258f4aa9 100644 --- a/infrahub_sync/service/config_routes.py +++ b/infrahub_sync/service/config_routes.py @@ -3,7 +3,6 @@ from datetime import datetime, timezone from functools import wraps from hashlib import sha256 -from pathlib import Path from typing import Annotated, Any, Literal from uuid import uuid4 @@ -75,29 +74,22 @@ def _strict_integer(value: str, *, minimum: int, maximum: int) -> int: class ConfigurationRoutes: - """Bind configuration operations to this server's durable cache location.""" + """Bind configuration operations to this server's durable product projection.""" def __init__( self, - product_cache_location: Path | None = None, *, - product_projection: ProductProjection | None = None, + product_projection: ProductProjection, service: Any = configs, secrets: tuple[str, ...] = (), ) -> None: - if (product_cache_location is None) == (product_projection is None): - msg = "provide exactly one product projection or product_cache_location" - raise ValueError(msg) - self._location = product_cache_location self._projection = product_projection self._service = service self._secrets = secrets def _call(self, operation: Any, **kwargs: Any) -> Any: try: - if self._projection is not None: - return operation(projection=self._projection, **kwargs) - return operation(product_cache_location=self._location, **kwargs) + return operation(projection=self._projection, **kwargs) except self._service.ConfigsError as error: error_type = type(error) if error_type is self._service.ConfigsRequestError: @@ -271,11 +263,8 @@ def _audit(self, actor: str, operation: str, reason: str, outcome: str) -> None: ) def _store_projection(self) -> ProductProjection: - """Return the injected projection or the explicit local compatibility projection.""" - if self._projection is not None: - return self._projection - assert self._location is not None - return configs._standalone_projection(self._location) + """Return the injected durable projection this server writes receipts and audit to.""" + return self._projection def configuration_router(routes: ConfigurationRoutes, authenticate: Any, idempotency_key: Any) -> APIRouter: diff --git a/tests/cli/test_parity_and_closure.py b/tests/cli/test_parity_and_closure.py index 0e5785c2..d75fb9f7 100644 --- a/tests/cli/test_parity_and_closure.py +++ b/tests/cli/test_parity_and_closure.py @@ -123,7 +123,6 @@ def test_cli_imports_only_the_shared_client_boundary() -> None: "httpx", "Authorization", "Bearer ", - "execute_standalone", "execute_run", "product_store", "get_potenda_from_instance", diff --git a/tests/conformance/test_product_cache_location.py b/tests/conformance/test_product_cache_location.py deleted file mode 100644 index 8cb20aee..00000000 --- a/tests/conformance/test_product_cache_location.py +++ /dev/null @@ -1,59 +0,0 @@ -"""One product-cache-location rule, reached by every entry point that accepts the option. - -Envelope OES-21. The absoluteness rule was stated three times before this slice — once in -the version 1 request models, once implicitly by the local projection's constructor, and -nowhere in the shared layer — so two entry points refused the same input with two different -sentences. What is asserted here is that one function now produces the refusal and that the -shared service renders it verbatim. - -The asymmetry is deliberate and is asserted too: a *missing* location is a legacy cache-only -fallback for a run and a refusal for the registry, which has nowhere to live without a store. -Only absoluteness is shared. -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -import pytest - -from infrahub_sync.product_store import configs as configs_service -from infrahub_sync.product_store.standalone import ProductCacheLocationError, resolve_product_cache_location - -if TYPE_CHECKING: - from pathlib import Path - -RELATIVE = "relative/product-cache" -UNRESOLVABLE = "~db006-user-that-cannot-exist/product-cache" - - -def test_the_relative_path_refusal_is_one_sentence_at_every_entry_point() -> None: - with pytest.raises(ProductCacheLocationError) as rule: - resolve_product_cache_location(RELATIVE) - expected = str(rule.value) - - with pytest.raises(configs_service.ConfigsRequestError) as service: - configs_service.validate(config_id="c", registry_version=1, product_cache_location=RELATIVE) - assert str(service.value) == expected - - -def test_the_unresolvable_home_refusal_is_one_sentence_at_every_entry_point() -> None: - with pytest.raises(ProductCacheLocationError, match="unresolvable user home"): - resolve_product_cache_location(UNRESOLVABLE) - - -def test_user_home_expansion_is_still_accepted_at_every_entry_point( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """The repair narrows the accepted set to non-absolute paths only, not to `~` as well.""" - home = tmp_path / "home" - home.mkdir() - monkeypatch.setenv("HOME", str(home)) - tilde = "~/product-cache" - - assert resolve_product_cache_location(tilde) == home / "product-cache" - - -def test_a_missing_location_falls_back_for_runs_and_refuses_for_the_registry() -> None: - with pytest.raises(configs_service.ConfigsRequestError, match="product_cache_location is required"): - configs_service.validate(config_id="c", registry_version=1, product_cache_location=None) diff --git a/tests/product_store/test_configs_service.py b/tests/product_store/test_configs_service.py index c767fb27..e0aca12e 100644 --- a/tests/product_store/test_configs_service.py +++ b/tests/product_store/test_configs_service.py @@ -34,11 +34,13 @@ from pathlib import Path from typing import NoReturn + from infrahub_sync.product_store import ProductProjection -def _store(tmp_path: Path) -> str: + +def _store(tmp_path: Path) -> ProductProjection: root = tmp_path / "product-cache" root.mkdir() - return str(root) + return local_product_projection(root) def _registered_configuration_count(root: Path) -> int: @@ -67,9 +69,9 @@ def _invalid_package_data() -> dict[str, Any]: def test_register_returns_the_configuration_and_its_first_version(tmp_path: Path) -> None: - location = _store(tmp_path) + projection = _store(tmp_path) - registered = configs_service.register(package=package_data(), product_cache_location=location) + registered = configs_service.register(package=package_data(), projection=projection) assert registered.version.registry_version == 1 assert registered.configuration.config_id == registered.version.config_id @@ -81,10 +83,10 @@ def test_register_returns_the_configuration_and_its_first_version(tmp_path: Path def test_register_refuses_an_invalid_package_and_persists_nothing(tmp_path: Path) -> None: - location = _store(tmp_path) + projection = _store(tmp_path) with pytest.raises(configs_service.ConfigsValidationError) as raised: - configs_service.register(package=_invalid_package_data(), product_cache_location=location) + configs_service.register(package=_invalid_package_data(), projection=projection) assert raised.value.family == "validation" codes = [finding.code for finding in raised.value.findings] @@ -96,22 +98,22 @@ def test_register_refuses_an_invalid_package_and_persists_nothing(tmp_path: Path def test_a_warning_only_package_registers_and_reports_its_warnings(tmp_path: Path) -> None: # Errors prevent execution; warnings do not. A package whose only findings are # warnings registers, versions, and validates error-free through the whole service. - location = _store(tmp_path) + projection = _store(tmp_path) data = package_data() data["omissions"] = [{"kind": "InfraDevice", "fields": ["serial_number"]}] changed = package_data() changed["omissions"] = [{"kind": "InfraDevice"}] - registered = configs_service.register(package=data, product_cache_location=location) + registered = configs_service.register(package=data, projection=projection) versioned = configs_service.create_version( config_id=registered.configuration.config_id, package=changed, - product_cache_location=location, + projection=projection, ) report = configs_service.validate( config_id=registered.configuration.config_id, registry_version=registered.version.registry_version, - product_cache_location=location, + projection=projection, ) assert versioned.created @@ -122,13 +124,13 @@ def test_a_warning_only_package_registers_and_reports_its_warnings(tmp_path: Pat def test_create_version_is_idempotent_for_an_identical_package(tmp_path: Path) -> None: - location = _store(tmp_path) - registered = configs_service.register(package=package_data(), product_cache_location=location) + projection = _store(tmp_path) + registered = configs_service.register(package=package_data(), projection=projection) repeated = configs_service.create_version( config_id=registered.version.config_id, package=package_data(), - product_cache_location=location, + projection=projection, ) assert repeated.created is False @@ -136,15 +138,15 @@ def test_create_version_is_idempotent_for_an_identical_package(tmp_path: Path) - def test_create_version_allocates_the_next_ordinal_for_new_content(tmp_path: Path) -> None: - location = _store(tmp_path) - registered = configs_service.register(package=package_data(), product_cache_location=location) + projection = _store(tmp_path) + registered = configs_service.register(package=package_data(), projection=projection) changed = package_data() changed["configuration"]["source"]["settings"]["url"] = "https://second.netbox.test" added = configs_service.create_version( config_id=registered.version.config_id, package=changed, - product_cache_location=location, + projection=projection, ) assert added.created is True @@ -156,7 +158,7 @@ def test_create_version_refuses_an_unregistered_configuration(tmp_path: Path) -> configs_service.create_version( config_id="20260808T1200-aaaaaaaa", package=package_data(), - product_cache_location=_store(tmp_path), + projection=_store(tmp_path), ) assert raised.value.family == "not-found" @@ -165,8 +167,8 @@ def test_create_version_refuses_an_unregistered_configuration(tmp_path: Path) -> def test_validate_reports_every_defect_of_a_registered_version_in_sorted_order( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - location = _store(tmp_path) - registered = configs_service.register(package=package_data(), product_cache_location=location) + projection = _store(tmp_path) + registered = configs_service.register(package=package_data(), projection=projection) # A registered package was valid when it was registered; re-validating it against a # different adapter set is the whole reason this surface exists. monkeypatch.setattr("infrahub_sync.configuration.validation.BUILTIN_ADAPTER_CAPABILITIES", {}) @@ -174,7 +176,7 @@ def test_validate_reports_every_defect_of_a_registered_version_in_sorted_order( report = configs_service.validate( config_id=registered.version.config_id, registry_version=1, - product_cache_location=location, + projection=projection, ) assert [(finding.code, finding.location) for finding in report.findings] == [ @@ -185,13 +187,13 @@ def test_validate_reports_every_defect_of_a_registered_version_in_sorted_order( def test_validate_reports_no_findings_for_a_still_valid_version(tmp_path: Path) -> None: - location = _store(tmp_path) - registered = configs_service.register(package=package_data(), product_cache_location=location) + projection = _store(tmp_path) + registered = configs_service.register(package=package_data(), projection=projection) report = configs_service.validate( config_id=registered.version.config_id, registry_version=1, - product_cache_location=location, + projection=projection, ) assert report.findings == () @@ -199,14 +201,14 @@ def test_validate_reports_no_findings_for_a_still_valid_version(tmp_path: Path) def test_validate_refuses_an_unregistered_version(tmp_path: Path) -> None: - location = _store(tmp_path) - registered = configs_service.register(package=package_data(), product_cache_location=location) + projection = _store(tmp_path) + registered = configs_service.register(package=package_data(), projection=projection) with pytest.raises(configs_service.ConfigsNotFoundError) as raised: configs_service.validate( config_id=registered.version.config_id, registry_version=7, - product_cache_location=location, + projection=projection, ) assert raised.value.family == "not-found" @@ -214,7 +216,7 @@ def test_validate_refuses_an_unregistered_version(tmp_path: Path) -> None: def test_unparseable_package_content_is_a_request_refusal(tmp_path: Path) -> None: with pytest.raises(configs_service.ConfigsRequestError) as raised: - configs_service.register(package={"format_version": 99}, product_cache_location=_store(tmp_path)) + configs_service.register(package={"format_version": 99}, projection=_store(tmp_path)) assert raised.value.family == "request" @@ -243,16 +245,6 @@ def test_the_error_vocabulary_is_one_closed_family_set() -> None: assert configs_service.describe(configs_service.ConfigsNotFoundError("gone"), ()) == "not-found: gone" -def test_a_missing_store_location_is_a_refusal_rather_than_a_fallback() -> None: - with pytest.raises(configs_service.ConfigsRequestError, match="product_cache_location is required"): - configs_service.validate(config_id="c", registry_version=1, product_cache_location="") - - -def test_a_relative_store_location_is_refused() -> None: - with pytest.raises(configs_service.ConfigsRequestError, match="must be absolute after user expansion"): - configs_service.validate(config_id="c", registry_version=1, product_cache_location="relative/product-cache") - - # --- Registry reads --------------------------------------------------------------------- # # The rows below are chosen so raw insertion order disagrees with the declared listing order @@ -268,24 +260,24 @@ def test_a_relative_store_location_is_refused() -> None: ) -def _register_out_of_order(location: str, monkeypatch: pytest.MonkeyPatch) -> None: +def _register_out_of_order(projection: ProductProjection, monkeypatch: pytest.MonkeyPatch) -> None: """Register the rows above through the service, with generated IDs and clock pinned.""" ids = iter([config_id for config_id, _ in _OUT_OF_ORDER_REGISTRATIONS]) clock = iter([created_at for _, created_at in _OUT_OF_ORDER_REGISTRATIONS]) monkeypatch.setattr(product_store_store, "_generate_config_id", lambda: next(ids)) monkeypatch.setattr(product_store_store, "datetime", SimpleNamespace(now=lambda tz: next(clock))) # noqa: ARG005 for _ in _OUT_OF_ORDER_REGISTRATIONS: - configs_service.register(package=package_data(), product_cache_location=location) + configs_service.register(package=package_data(), projection=projection) def test_list_configs_returns_every_configuration_in_created_at_then_config_id_order( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """Every registered configuration exactly once, in the one declared deterministic order.""" - location = _store(tmp_path) - _register_out_of_order(location, monkeypatch) + projection = _store(tmp_path) + _register_out_of_order(projection, monkeypatch) - listed = configs_service.list_configs(product_cache_location=location) + listed = configs_service.list_configs(projection=projection) assert [(summary.config_id, summary.created_at) for summary in listed] == [ ("config-z", datetime(2026, 8, 8, 12, 0, tzinfo=timezone.utc)), @@ -293,7 +285,7 @@ def test_list_configs_returns_every_configuration_in_created_at_then_config_id_o ("config-b", datetime(2026, 8, 8, 12, 30, tzinfo=timezone.utc)), ] # Order determinism: a re-read of the same store returns the identical sequence. - assert configs_service.list_configs(product_cache_location=location) == listed + assert configs_service.list_configs(projection=projection) == listed def test_get_config_and_list_versions_return_one_configurations_own_records(tmp_path: Path) -> None: @@ -302,24 +294,24 @@ def test_get_config_and_list_versions_return_one_configurations_own_records(tmp_ A second, unrelated configuration is registered into the same store so an unscoped read would be caught leaking its rows. """ - location = _store(tmp_path) - registered = configs_service.register(package=package_data(), product_cache_location=location) + projection = _store(tmp_path) + registered = configs_service.register(package=package_data(), projection=projection) changed = package_data() changed["configuration"]["source"]["settings"]["url"] = "https://second.netbox.test" added = configs_service.create_version( config_id=registered.version.config_id, package=changed, - product_cache_location=location, + projection=projection, ) - unrelated = configs_service.register(package=package_data(), product_cache_location=location) + unrelated = configs_service.register(package=package_data(), projection=projection) summary = configs_service.get_config( config_id=registered.version.config_id, - product_cache_location=location, + projection=projection, ) versions = configs_service.list_versions( config_id=registered.version.config_id, - product_cache_location=location, + projection=projection, ) assert summary == registered.configuration @@ -334,7 +326,7 @@ def test_get_version_on_a_missing_configuration_names_the_configuration_as_absen configs_service.get_version( config_id="missing-configuration", registry_version=1, - product_cache_location=_store(tmp_path), + projection=_store(tmp_path), ) assert raised.value.family == "not-found" @@ -349,20 +341,20 @@ def test_get_version_on_a_missing_version_is_distinct_from_a_missing_configurati two-step read is race-safe) and the two absences surface as distinct machine-readable values -- what service-boundary later maps to two different status codes. """ - location = _store(tmp_path) - registered = configs_service.register(package=package_data(), product_cache_location=location) + projection = _store(tmp_path) + registered = configs_service.register(package=package_data(), projection=projection) with pytest.raises(configs_service.ConfigsNotFoundError) as missing_version: configs_service.get_version( config_id=registered.version.config_id, registry_version=7, - product_cache_location=location, + projection=projection, ) with pytest.raises(configs_service.ConfigsNotFoundError) as missing_configuration: configs_service.get_version( config_id="missing-configuration", registry_version=7, - product_cache_location=location, + projection=projection, ) assert missing_version.value.family == "not-found" @@ -373,19 +365,19 @@ def test_get_version_on_a_missing_version_is_distinct_from_a_missing_configurati # The two reads whose store queries return a tuple, so a missing configuration's natural # defect is a silent empty result -- indistinguishable from a real answer about a registered # configuration with no rows to show. -_MISSING_CONFIGURATION_READS: tuple[tuple[str, Callable[[str], object]], ...] = ( +_MISSING_CONFIGURATION_READS: tuple[tuple[str, Callable[[ProductProjection], object]], ...] = ( ( "get_config", - lambda location: configs_service.get_config( + lambda projection: configs_service.get_config( config_id="missing-configuration", - product_cache_location=location, + projection=projection, ), ), ( "list_versions", - lambda location: configs_service.list_versions( + lambda projection: configs_service.list_versions( config_id="missing-configuration", - product_cache_location=location, + projection=projection, ), ), ) @@ -394,7 +386,7 @@ def test_get_version_on_a_missing_version_is_distinct_from_a_missing_configurati @pytest.mark.parametrize("call", [pytest.param(call, id=name) for name, call in _MISSING_CONFIGURATION_READS]) def test_a_read_of_a_missing_configuration_refuses_rather_than_answering_empty( tmp_path: Path, - call: Callable[[str], object], + call: Callable[[ProductProjection], object], ) -> None: with pytest.raises(configs_service.ConfigsNotFoundError) as raised: call(_store(tmp_path)) @@ -405,13 +397,13 @@ def test_a_read_of_a_missing_configuration_refuses_rather_than_answering_empty( def test_get_version_round_trips_the_registered_content_and_checksum(tmp_path: Path) -> None: """A read returns exactly what registration reported -- field equality, not "no error".""" - location = _store(tmp_path) - registered = configs_service.register(package=package_data(), product_cache_location=location) + projection = _store(tmp_path) + registered = configs_service.register(package=package_data(), projection=projection) stored = configs_service.get_version( config_id=registered.version.config_id, registry_version=1, - product_cache_location=location, + projection=projection, ) assert stored.declared_content == registered.version.declared_content @@ -419,94 +411,63 @@ def test_get_version_round_trips_the_registered_content_and_checksum(tmp_path: P assert stored == registered.version -# The read entry points, each reached with a syntactically fine request, so the only thing a -# raised refusal can be about is the store location (envelope OES-21's evidence pattern). -_READ_ENTRY_POINTS: tuple[tuple[str, Callable[[str | None], object]], ...] = ( - ("list_configs", lambda location: configs_service.list_configs(product_cache_location=location)), - ("get_config", lambda location: configs_service.get_config(config_id="c", product_cache_location=location)), - ("list_versions", lambda location: configs_service.list_versions(config_id="c", product_cache_location=location)), - ( - "get_version", - lambda location: configs_service.get_version( - config_id="c", - registry_version=1, - product_cache_location=location, - ), - ), -) - - -@pytest.mark.parametrize("call", [pytest.param(call, id=name) for name, call in _READ_ENTRY_POINTS]) -def test_a_read_with_a_missing_store_location_is_a_refusal_rather_than_a_fallback( - call: Callable[[str | None], object], -) -> None: - with pytest.raises(configs_service.ConfigsRequestError, match="product_cache_location is required"): - call(None) - - -@pytest.mark.parametrize("call", [pytest.param(call, id=name) for name, call in _READ_ENTRY_POINTS]) -def test_a_read_with_a_relative_store_location_is_refused(call: Callable[[str | None], object]) -> None: - with pytest.raises(configs_service.ConfigsRequestError, match="must be absolute after user expansion"): - call("relative/product-cache") - - # A value of the wrong type entirely, annotated ``Any`` so the calls below type-check. That is # the only way it reaches the service: a caller's own defect that got past static checking. _WRONG_TYPED_VALUE: Any = object() -_WRONG_TYPED_CALLS: tuple[tuple[str, Callable[[str], object]], ...] = ( +_WRONG_TYPED_CALLS: tuple[tuple[str, Callable[[ProductProjection], object]], ...] = ( ( "validate-config-id", - lambda location: configs_service.validate( + lambda projection: configs_service.validate( config_id=_WRONG_TYPED_VALUE, registry_version=1, - product_cache_location=location, + projection=projection, ), ), ( "validate-registry-version", - lambda location: configs_service.validate( + lambda projection: configs_service.validate( config_id="c", registry_version=_WRONG_TYPED_VALUE, - product_cache_location=location, + projection=projection, ), ), ( "create-version-config-id", - lambda location: configs_service.create_version( + lambda projection: configs_service.create_version( config_id=_WRONG_TYPED_VALUE, package=package_data(), - product_cache_location=location, + projection=projection, ), ), ( "get-config-config-id", - lambda location: configs_service.get_config( + lambda projection: configs_service.get_config( config_id=_WRONG_TYPED_VALUE, - product_cache_location=location, + projection=projection, ), ), ( "list-versions-config-id", - lambda location: configs_service.list_versions( + lambda projection: configs_service.list_versions( config_id=_WRONG_TYPED_VALUE, - product_cache_location=location, + projection=projection, ), ), ( "get-version-config-id", - lambda location: configs_service.get_version( + lambda projection: configs_service.get_version( config_id=_WRONG_TYPED_VALUE, registry_version=1, - product_cache_location=location, + projection=projection, ), ), ( "get-version-registry-version", - lambda location: configs_service.get_version( + lambda projection: configs_service.get_version( config_id="c", registry_version=_WRONG_TYPED_VALUE, - product_cache_location=location, + projection=projection, ), ), ) @@ -515,7 +476,7 @@ def test_a_read_with_a_relative_store_location_is_refused(call: Callable[[str | @pytest.mark.parametrize("call", [pytest.param(call, id=name) for name, call in _WRONG_TYPED_CALLS]) def test_a_wrong_typed_identifier_is_the_callers_input_and_not_the_store( tmp_path: Path, - call: Callable[[str], object], + call: Callable[[ProductProjection], object], ) -> None: """A wrong-typed identifier must not send an operator to look at their own disk. @@ -533,58 +494,11 @@ def test_a_wrong_typed_identifier_is_the_callers_input_and_not_the_store( def test_a_well_typed_identifier_still_gets_the_stores_own_answer(tmp_path: Path) -> None: """The type guard checks the type and nothing else, so absence is still the store's verdict.""" with pytest.raises(configs_service.ConfigsNotFoundError) as raised: - configs_service.validate(config_id="c", registry_version=1, product_cache_location=_store(tmp_path)) + configs_service.validate(config_id="c", registry_version=1, projection=_store(tmp_path)) assert raised.value.family == "not-found" -_ENTRY_POINTS: tuple[tuple[str, Callable[[str], object]], ...] = ( - ("register", lambda location: configs_service.register(package=package_data(), product_cache_location=location)), - ( - "create_version", - lambda location: configs_service.create_version( - config_id="c", - package=package_data(), - product_cache_location=location, - ), - ), - ( - "validate", - lambda location: configs_service.validate(config_id="c", registry_version=1, product_cache_location=location), - ), - ("list_configs", lambda location: configs_service.list_configs(product_cache_location=location)), - ("get_config", lambda location: configs_service.get_config(config_id="c", product_cache_location=location)), - ("list_versions", lambda location: configs_service.list_versions(config_id="c", product_cache_location=location)), - ( - "get_version", - lambda location: configs_service.get_version( - config_id="c", - registry_version=1, - product_cache_location=location, - ), - ), -) - - -@pytest.mark.parametrize("call", [pytest.param(call, id=name) for name, call in _ENTRY_POINTS]) -def test_a_store_the_filesystem_refuses_is_a_storage_refusal( - tmp_path: Path, - call: Callable[[str], object], -) -> None: - # A real store failure, not a monkeypatched raise. Opening the registry creates its own - # directories, and the filesystem is what refuses here, so this test can see which base - # class the service catches - which a faked raise cannot. A plain file where the registry - # wants a directory is the form that needs no permission change, so it behaves identically - # as root and under CI, where an unwritable-directory test would not fail at all. - occupied = tmp_path / "product-cache" - occupied.write_text("not a directory\n", encoding="utf-8") - - with pytest.raises(configs_service.ConfigsStorageError) as raised: - call(str(occupied)) - - assert raised.value.family == "storage" - - def test_a_package_file_is_loaded_from_json_or_yaml(tmp_path: Path) -> None: json_file = tmp_path / "package.json" json_file.write_text('{"format_version": 1}', encoding="utf-8") @@ -697,106 +611,75 @@ def test_a_package_file_whose_key_json_cannot_hold_is_a_request_refusal(tmp_path assert raised.value.family == "request" -def _corrupt_registry(tmp_path: Path) -> str: - """Return a store location whose registry file exists, is readable, and is not a database.""" +def _corrupt_registry(tmp_path: Path) -> ProductProjection: + """Return an opened projection whose registry file is no longer a database.""" root = tmp_path / "product-cache" root.mkdir() + projection = local_product_projection(root) (root / "product-records.sqlite3").write_bytes(b"this file is not a database\n") - return str(root) - - -@pytest.mark.parametrize("call", [pytest.param(call, id=name) for name, call in _ENTRY_POINTS]) -def test_a_corrupt_registry_file_is_a_storage_refusal(tmp_path: Path, call: Callable[[str], object]) -> None: - # A real failure, and a cheap one: SQLite does not read the file header when it opens, so - # the store is constructed successfully and every *query* raises sqlite3.DatabaseError - # afterwards. The arms around each store call name the store's own error types, so all - # three operations handed the caller a raw sqlite3 traceback. - with pytest.raises(configs_service.ConfigsStorageError) as raised: - call(_corrupt_registry(tmp_path)) - - assert raised.value.family == "storage" + return projection -def _load_a_written_package(tmp_path: Path) -> object: - """Load one legal package file, so only the patched dependency can fail the call.""" - path = tmp_path / "package.yaml" - path.write_text("format_version: 1\n", encoding="utf-8") - return configs_service.load_package_content(path) - - -_PUBLIC_OPERATIONS: tuple[tuple[str, Callable[[pytest.MonkeyPatch], None], Callable[[Path], object]], ...] = ( - ( - "load_package_content", - lambda patch: patch.setattr(configs_service.yaml, "safe_load", _raise_unforeseen), - _load_a_written_package, - ), - ( - "register", - lambda patch: patch.setattr(configs_service, "local_product_projection", _raise_unforeseen), - lambda tmp_path: configs_service.register(package=package_data(), product_cache_location=_store(tmp_path)), - ), +_ENTRY_POINTS: tuple[tuple[str, Callable[[ProductProjection], object]], ...] = ( + ("register", lambda projection: configs_service.register(package=package_data(), projection=projection)), ( "create_version", - lambda patch: patch.setattr(configs_service, "local_product_projection", _raise_unforeseen), - lambda tmp_path: configs_service.create_version( + lambda projection: configs_service.create_version( config_id="c", package=package_data(), - product_cache_location=_store(tmp_path), + projection=projection, ), ), ( "validate", - lambda patch: patch.setattr(configs_service, "local_product_projection", _raise_unforeseen), - lambda tmp_path: configs_service.validate( - config_id="c", - registry_version=1, - product_cache_location=_store(tmp_path), - ), - ), - ( - "list_configs", - lambda patch: patch.setattr(configs_service, "local_product_projection", _raise_unforeseen), - lambda tmp_path: configs_service.list_configs(product_cache_location=_store(tmp_path)), - ), - ( - "get_config", - lambda patch: patch.setattr(configs_service, "local_product_projection", _raise_unforeseen), - lambda tmp_path: configs_service.get_config(config_id="c", product_cache_location=_store(tmp_path)), - ), - ( - "list_versions", - lambda patch: patch.setattr(configs_service, "local_product_projection", _raise_unforeseen), - lambda tmp_path: configs_service.list_versions(config_id="c", product_cache_location=_store(tmp_path)), + lambda projection: configs_service.validate(config_id="c", registry_version=1, projection=projection), ), + ("list_configs", lambda projection: configs_service.list_configs(projection=projection)), + ("get_config", lambda projection: configs_service.get_config(config_id="c", projection=projection)), + ("list_versions", lambda projection: configs_service.list_versions(config_id="c", projection=projection)), ( "get_version", - lambda patch: patch.setattr(configs_service, "local_product_projection", _raise_unforeseen), - lambda tmp_path: configs_service.get_version( + lambda projection: configs_service.get_version( config_id="c", registry_version=1, - product_cache_location=_store(tmp_path), + projection=projection, ), ), ) -@pytest.mark.parametrize( - ("patch_dependency", "call"), - [pytest.param(patch, call, id=name) for name, patch, call in _PUBLIC_OPERATIONS], -) -def test_a_failing_dependency_leaves_a_public_operation_inside_the_declared_vocabulary( +@pytest.mark.parametrize("call", [pytest.param(call, id=name) for name, call in _ENTRY_POINTS]) +def test_a_corrupt_registry_file_is_a_storage_refusal( + tmp_path: Path, call: Callable[[ProductProjection], object] +) -> None: + # A real failure, and a cheap one: the registry is replaced after the store opened it, so + # every *query* raises sqlite3.DatabaseError. The arms around each store call name the + # store's own error types, so all three operations handed the caller a raw sqlite3 + # traceback. + with pytest.raises(configs_service.ConfigsStorageError) as raised: + call(_corrupt_registry(tmp_path)) + + assert raised.value.family == "storage" + + +def _load_a_written_package(tmp_path: Path) -> object: + """Load one legal package file, so only the patched dependency can fail the call.""" + path = tmp_path / "package.yaml" + path.write_text("format_version: 1\n", encoding="utf-8") + return configs_service.load_package_content(path) + + +def test_a_failing_text_helper_leaves_a_public_operation_inside_the_declared_vocabulary( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, - patch_dependency: Callable[[pytest.MonkeyPatch], None], - call: Callable[[Path], object], ) -> None: # The one place a monkeypatched raise is the right tool: this drives the *unforeseen* arm, # and an exception type the service has never met is by definition one no real dependency # raises today. Every other failure in this section is caused for real. - patch_dependency(monkeypatch) + monkeypatch.setattr(configs_service.yaml, "safe_load", _raise_unforeseen) with pytest.raises(configs_service.ConfigsError) as raised: - call(tmp_path) + _load_a_written_package(tmp_path) assert raised.value.family in _DECLARED_FAMILIES # The documented default: neither the caller's input nor the store, and it says so rather @@ -809,18 +692,20 @@ def _raise_interrupt(*args: object, **kwargs: object) -> object: raise KeyboardInterrupt -def test_an_interrupt_is_not_caught_by_the_error_boundary( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: +class _InterruptingProjection: + """A projection whose every operation raises the interrupt the boundary must not catch.""" + + def __getattr__(self, _name: str) -> Callable[..., object]: + return _raise_interrupt + + +def test_an_interrupt_is_not_caught_by_the_error_boundary() -> None: # The boundary catches Exception, never BaseException, so an interrupt reaches the # interpreter instead of being reported as a registry refusal. Widening it would swallow # Ctrl-C and SystemExit, which is the decision the run boundary already made explicitly, # and nothing else here would notice. - monkeypatch.setattr(configs_service, "local_product_projection", _raise_interrupt) - with pytest.raises(KeyboardInterrupt): - configs_service.register(package=package_data(), product_cache_location=_store(tmp_path)) + configs_service.register(package=package_data(), projection=cast("Any", _InterruptingProjection())) def _public_service_functions() -> dict[str, Any]: @@ -1355,32 +1240,30 @@ def test_register_refuses_a_prebuilt_package_instance(tmp_path: Path, instance: # Any instance — the exact class included — can carry behavior validation never judged: # a subclass overriding ``declared_content()``, or ``model_construct`` skipping # validation entirely. The boundary accepts declared JSON-native content only. - location = _store(tmp_path) + projection = _store(tmp_path) # Typed away deliberately: the whole point is a call the signature no longer admits. prebuilt = cast("Any", instance) with pytest.raises(configs_service.ConfigsRequestError, match="must be a JSON-native dict"): - configs_service.register(package=prebuilt, product_cache_location=location) + configs_service.register(package=prebuilt, projection=projection) assert _registered_configuration_count(tmp_path / "product-cache") == 0 @pytest.mark.parametrize("instance", [pytest.param(instance, id=name) for name, instance in _prebuilt_instances()]) def test_create_version_refuses_a_prebuilt_package_instance(tmp_path: Path, instance: ConfigurationPackage) -> None: - location = _store(tmp_path) - registered = configs_service.register(package=package_data(), product_cache_location=location) + projection = _store(tmp_path) + registered = configs_service.register(package=package_data(), projection=projection) prebuilt = cast("Any", instance) with pytest.raises(configs_service.ConfigsRequestError, match="must be a JSON-native dict"): configs_service.create_version( config_id=registered.configuration.config_id, package=prebuilt, - product_cache_location=location, + projection=projection, ) - versions = configs_service.list_versions( - config_id=registered.configuration.config_id, product_cache_location=location - ) + versions = configs_service.list_versions(config_id=registered.configuration.config_id, projection=projection) assert [version.registry_version for version in versions] == [1] @@ -1391,11 +1274,11 @@ def test_a_hostile_declared_content_override_cannot_reach_the_store(tmp_path: Pa inline canary landed in the durable store row. Now the instance itself is refused as the caller's own input, and nothing is written. """ - location = _store(tmp_path) + projection = _store(tmp_path) hostile = cast("Any", _HostileDeclaredContent.model_validate(package_data())) with pytest.raises(configs_service.ConfigsRequestError): - configs_service.register(package=hostile, product_cache_location=location) + configs_service.register(package=hostile, projection=projection) root = tmp_path / "product-cache" assert _registered_configuration_count(root) == 0 @@ -1406,7 +1289,7 @@ def test_a_hostile_declared_content_override_cannot_reach_the_store(tmp_path: Pa def test_a_non_mapping_package_is_the_callers_input_not_an_internal_error(tmp_path: Path) -> None: with pytest.raises(configs_service.ConfigsRequestError, match="package must be a JSON-native dict"): - configs_service.register(package=_WRONG_TYPED_VALUE, product_cache_location=_store(tmp_path)) + configs_service.register(package=_WRONG_TYPED_VALUE, projection=_store(tmp_path)) # --- Property closure P1: the write boundary accepts exactly JSON-native data ----------- @@ -1499,17 +1382,17 @@ def test_only_recursively_exact_json_data_enters_the_write_boundary(tmp_path: Pa # The closed acceptance property: a package is either recursively exact JSON-native # data — exact dict/list containers, exact str/int/float/bool/None leaves — or it is # the caller's own input, refused request-class before any protocol operation runs. - location = _store(tmp_path) + projection = _store(tmp_path) with pytest.raises(configs_service.ConfigsRequestError) as raised: - configs_service.register(package=cast("Any", value), product_cache_location=location) + configs_service.register(package=cast("Any", value), projection=projection) assert raised.value.family == "request" assert _registered_configuration_count(tmp_path / "product-cache") == 0 def test_an_exact_dict_package_is_the_accepted_domain(tmp_path: Path) -> None: - registered = configs_service.register(package=package_data(), product_cache_location=_store(tmp_path)) + registered = configs_service.register(package=package_data(), projection=_store(tmp_path)) assert registered.version.registry_version == 1 @@ -1520,7 +1403,7 @@ def test_a_refused_package_is_never_invoked(tmp_path: Path) -> None: sentinel = _ProtocolRecordingPackage() with pytest.raises(configs_service.ConfigsRequestError): - configs_service.register(package=cast("Any", sentinel), product_cache_location=_store(tmp_path)) + configs_service.register(package=cast("Any", sentinel), projection=_store(tmp_path)) assert sentinel.consulted == [] @@ -1549,7 +1432,7 @@ class _MetadataProbe(metaclass=_ExecutingMeta): """An out-of-domain value whose class metadata is executable behavior.""" with pytest.raises(configs_service.ConfigsRequestError) as raised: - configs_service.register(package=cast("Any", _MetadataProbe()), product_cache_location=_store(tmp_path)) + configs_service.register(package=cast("Any", _MetadataProbe()), projection=_store(tmp_path)) assert raised.value.family == "request" assert "must be a JSON-native dict" in str(raised.value) @@ -1578,20 +1461,20 @@ class _Probe(metaclass=_ExecutingMeta): "call", [ pytest.param( - lambda location, value: configs_service.get_config(config_id=value, product_cache_location=location), + lambda projection, value: configs_service.get_config(config_id=value, projection=projection), id="config-id", ), pytest.param( - lambda location, value: configs_service.get_version( - config_id="c", registry_version=value, product_cache_location=location + lambda projection, value: configs_service.get_version( + config_id="c", registry_version=value, projection=projection ), id="registry-version", ), pytest.param( - lambda location, value: configs_service.validate( + lambda projection, value: configs_service.validate( config_id="c", registry_version=1, - product_cache_location=location, + projection=projection, destination_schema=value, ), id="destination-schema", @@ -1599,7 +1482,7 @@ class _Probe(metaclass=_ExecutingMeta): ], ) def test_a_wrong_typed_argument_never_has_its_class_metadata_read( - tmp_path: Path, call: Callable[[str, Any], object] + tmp_path: Path, call: Callable[[ProductProjection, Any], object] ) -> None: # The package boundary's rule applied to every argument guard: the refusals # formatted type(value).__name__, and a metaclass executes on that read — the @@ -1628,18 +1511,17 @@ def __class__(self) -> type: raise RuntimeError(msg) with pytest.raises(configs_service.ConfigsRequestError) as raised: - configs_service.get_config(config_id=cast("Any", _RaisingClassProbe()), product_cache_location=_store(tmp_path)) + configs_service.get_config(config_id=cast("Any", _RaisingClassProbe()), projection=_store(tmp_path)) assert raised.value.family == "request" assert reads == [] -def test_the_boundary_classifies_without_consulting_an_exceptions_class_property(tmp_path: Path) -> None: +def test_the_boundary_classifies_without_consulting_an_exceptions_class_property() -> None: # The boundary's isinstance classification consulted exc.__class__ — an instance # read a hostile property executes on — so an exception a hostile caller argument - # constructs escaped list_configs() as a raw RuntimeError, outside the declared - # vocabulary. Except clauses match the actual type without touching the instance. - del tmp_path + # an injected dependency raises escaped list_configs() as a raw RuntimeError, outside the + # declared vocabulary. Except clauses match the actual type without touching the instance. reads: list[str] = [] class _HostileError(Exception): @@ -1649,26 +1531,27 @@ def __class__(self) -> type: msg = "__class__ was consulted" raise RuntimeError(msg) - class _RaisingLocation: - def __str__(self) -> str: - msg = "str() exploded: third-party secret text" - raise _HostileError(msg) + class _RaisingProjection: + def __getattr__(self, _name: str) -> Callable[..., object]: + def fail(*_args: object, **_kwargs: object) -> object: + msg = "the store exploded: third-party secret text" + raise _HostileError(msg) + + return fail with pytest.raises(configs_service.ConfigsInternalError) as raised: - configs_service.list_configs(product_cache_location=cast("Any", _RaisingLocation())) + configs_service.list_configs(projection=cast("Any", _RaisingProjection())) assert raised.value.family == "internal" assert str(raised.value) == "configs list_configs failed" assert reads == [] -def test_the_boundary_reads_nothing_from_an_exception_it_did_not_name(tmp_path: Path) -> None: +def test_the_boundary_reads_nothing_from_an_exception_it_did_not_name() -> None: # The boundary's own refusal formatted type(exc).__name__ — but an exception a - # hostile caller argument constructs (here: a product_cache_location whose - # __str__ raises it) has an untrusted class, and the metaclass executing on that + # an injected dependency raises has an untrusted class, and the metaclass executing on that # read escaped the module as a raw RuntimeError, outside the declared vocabulary # entirely. The boundary reads nothing: family by isinstance, one fixed message. - del tmp_path reads: list[str] = [] class _ExecutingMeta(type): @@ -1681,13 +1564,16 @@ def __name__(cls) -> str: # noqa: PLW3201 - shadowing type's own descriptor is class _HostileError(Exception, metaclass=_ExecutingMeta): """An exception whose class name read is executable behavior.""" - class _RaisingLocation: - def __str__(self) -> str: - msg = "str() exploded: third-party secret text" - raise _HostileError(msg) + class _RaisingProjection: + def __getattr__(self, _name: str) -> Callable[..., object]: + def fail(*_args: object, **_kwargs: object) -> object: + msg = "the store exploded: third-party secret text" + raise _HostileError(msg) + + return fail with pytest.raises(configs_service.ConfigsInternalError) as raised: - configs_service.list_configs(product_cache_location=cast("Any", _RaisingLocation())) + configs_service.list_configs(projection=cast("Any", _RaisingProjection())) assert raised.value.family == "internal" assert str(raised.value) == "configs list_configs failed" @@ -1726,15 +1612,15 @@ class _IntSubclass(int): def test_an_out_of_domain_registry_version_is_the_callers_input( tmp_path: Path, operation: str, registry_version: object ) -> None: - location = _store(tmp_path) - registered = configs_service.register(package=package_data(), product_cache_location=location) + projection = _store(tmp_path) + registered = configs_service.register(package=package_data(), projection=projection) call = getattr(configs_service, operation) with pytest.raises(configs_service.ConfigsRequestError) as raised: call( config_id=registered.configuration.config_id, registry_version=registry_version, - product_cache_location=location, + projection=projection, ) assert raised.value.family == "request" @@ -1743,14 +1629,14 @@ def test_an_out_of_domain_registry_version_is_the_callers_input( def test_the_domain_maximum_is_still_the_stores_own_answer(tmp_path: Path) -> None: # The property's upper edge from the inside: 2**63 - 1 is the last version the # registry could ever allocate, so it reaches the store and is its own not-found. - location = _store(tmp_path) - registered = configs_service.register(package=package_data(), product_cache_location=location) + projection = _store(tmp_path) + registered = configs_service.register(package=package_data(), projection=projection) with pytest.raises(configs_service.ConfigsNotFoundError) as raised: configs_service.get_version( config_id=registered.configuration.config_id, registry_version=2**63 - 1, - product_cache_location=location, + projection=projection, ) assert raised.value.reason == configs_service.CONFIGURATION_VERSION_NOT_FOUND_REASON @@ -1759,14 +1645,14 @@ def test_the_domain_maximum_is_still_the_stores_own_answer(tmp_path: Path) -> No def test_a_large_valid_registry_version_is_still_the_stores_own_answer(tmp_path: Path) -> None: # The guard checks the domain and nothing else: a well-formed version the registry has # not allocated is still absence, reported by the store's own not-found. - location = _store(tmp_path) - registered = configs_service.register(package=package_data(), product_cache_location=location) + projection = _store(tmp_path) + registered = configs_service.register(package=package_data(), projection=projection) with pytest.raises(configs_service.ConfigsNotFoundError) as raised: configs_service.get_version( config_id=registered.configuration.config_id, registry_version=10**12, - product_cache_location=location, + projection=projection, ) assert raised.value.reason == configs_service.CONFIGURATION_VERSION_NOT_FOUND_REASON diff --git a/tests/product_store/test_validate_destination_schema.py b/tests/product_store/test_validate_destination_schema.py index 528463b4..743ceb3c 100644 --- a/tests/product_store/test_validate_destination_schema.py +++ b/tests/product_store/test_validate_destination_schema.py @@ -24,6 +24,7 @@ from infrahub_sync.configuration.schema_validation import DestinationSchemaOptions from infrahub_sync.configuration.validation import collect_findings from infrahub_sync.product_store import configs as configs_service +from infrahub_sync.product_store import local_product_projection from infrahub_sync.runtime_schema import compute_consumed_schema_fingerprint, normalize_destination_schema from tests.configuration.validation_packages import package, package_data @@ -31,6 +32,7 @@ from collections.abc import ItemsView, Iterator from infrahub_sync.configuration import ConfigurationPackage + from infrahub_sync.product_store import ProductProjection _SNAPSHOT: dict[str, Any] = { "InfraDevice": { @@ -64,25 +66,26 @@ def _inject_accessor(monkeypatch: pytest.MonkeyPatch) -> _SpiedAccessor: return accessor -def _registered(tmp_path: Path, data: dict[str, Any] | None = None) -> tuple[str, str]: - location = str(tmp_path / "product-cache") - Path(location).mkdir() +def _registered(tmp_path: Path, data: dict[str, Any] | None = None) -> tuple[str, ProductProjection]: + root = tmp_path / "product-cache" + root.mkdir() + projection = local_product_projection(root) registered = configs_service.register( package=package_data() if data is None else data, - product_cache_location=location, + projection=projection, ) - return registered.version.config_id, location + return registered.version.config_id, projection def _validate( config_id: str, - location: str, + projection: ProductProjection, destination_schema: DestinationSchemaOptions | None = None, ) -> configs_service.ValidationReport: return configs_service.validate( config_id=config_id, registry_version=1, - product_cache_location=location, + projection=projection, destination_schema=destination_schema, ) @@ -100,9 +103,9 @@ def test_a_default_validate_never_calls_the_accessor_and_carries_no_fingerprint( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: accessor = _inject_accessor(monkeypatch) - config_id, location = _registered(tmp_path) + config_id, projection = _registered(tmp_path) - report = _validate(config_id, location) + report = _validate(config_id, projection) assert accessor.calls == [] assert report.destination_schema_fingerprint is None @@ -110,7 +113,7 @@ def test_a_default_validate_never_calls_the_accessor_and_carries_no_fingerprint( def test_a_default_validate_performs_no_network_io(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - config_id, location = _registered(tmp_path) + config_id, projection = _registered(tmp_path) def _refuse(*args: object, **kwargs: object) -> object: del args, kwargs @@ -119,7 +122,7 @@ def _refuse(*args: object, **kwargs: object) -> object: monkeypatch.setattr(socket, "socket", _refuse) - report = _validate(config_id, location) + report = _validate(config_id, projection) assert report.findings == () @@ -128,7 +131,7 @@ def test_the_opt_in_performs_no_network_io_when_the_snapshot_is_injected( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: accessor = _inject_accessor(monkeypatch) - config_id, location = _registered(tmp_path) + config_id, projection = _registered(tmp_path) def _refuse(*args: object, **kwargs: object) -> object: del args, kwargs @@ -137,7 +140,7 @@ def _refuse(*args: object, **kwargs: object) -> object: monkeypatch.setattr(socket, "socket", _refuse) - report = _validate(config_id, location, destination_schema=DestinationSchemaOptions()) + report = _validate(config_id, projection, destination_schema=DestinationSchemaOptions()) assert accessor.calls == ["main"] assert report.findings == () @@ -169,9 +172,9 @@ def test_the_service_and_schema_modules_construct_no_adapters() -> None: def test_the_opt_in_records_the_snapshot_fingerprint(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: _inject_accessor(monkeypatch) - config_id, location = _registered(tmp_path) + config_id, projection = _registered(tmp_path) - report = _validate(config_id, location, destination_schema=DestinationSchemaOptions()) + report = _validate(config_id, projection, destination_schema=DestinationSchemaOptions()) assert report.destination_schema_fingerprint == compute_consumed_schema_fingerprint( configuration=package(package_data()).configuration, snapshot=normalize_destination_schema(_SNAPSHOT) @@ -180,10 +183,10 @@ def test_the_opt_in_records_the_snapshot_fingerprint(tmp_path: Path, monkeypatch def test_the_opt_in_report_is_identical_across_invocations(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: _inject_accessor(monkeypatch) - config_id, location = _registered(tmp_path, _mapping_package_data("NopeKind")) + config_id, projection = _registered(tmp_path, _mapping_package_data("NopeKind")) - first = _validate(config_id, location, destination_schema=DestinationSchemaOptions()) - second = _validate(config_id, location, destination_schema=DestinationSchemaOptions()) + first = _validate(config_id, projection, destination_schema=DestinationSchemaOptions()) + second = _validate(config_id, projection, destination_schema=DestinationSchemaOptions()) assert first.findings == second.findings assert first.findings != () @@ -203,11 +206,11 @@ def test_core_and_schema_findings_merge_in_the_stable_sort_order( table["infrahub"] = replace(table["infrahub"], destination_schema_accessor=accessor) del table["netbox"] data = _mapping_package_data("NopeKind") - config_id, location = _registered(tmp_path, data) + config_id, projection = _registered(tmp_path, data) monkeypatch.setattr(schema_validation, "BUILTIN_ADAPTER_CAPABILITIES", table) monkeypatch.setattr(validation_module, "BUILTIN_ADAPTER_CAPABILITIES", table) - report = _validate(config_id, location, destination_schema=DestinationSchemaOptions()) + report = _validate(config_id, projection, destination_schema=DestinationSchemaOptions()) assert [(finding.code, finding.location) for finding in report.findings] == [ ("destination-schema-mismatch", "/configuration/schema_mapping/0/name"), @@ -234,9 +237,9 @@ def _raising(package_: ConfigurationPackage, branch: str) -> Mapping[str, Any]: table = dict(BUILTIN_ADAPTER_CAPABILITIES) table["infrahub"] = replace(table["infrahub"], destination_schema_accessor=_raising) monkeypatch.setattr(schema_validation, "BUILTIN_ADAPTER_CAPABILITIES", table) - config_id, location = _registered(tmp_path) + config_id, projection = _registered(tmp_path) - report = _validate(config_id, location, destination_schema=DestinationSchemaOptions()) + report = _validate(config_id, projection, destination_schema=DestinationSchemaOptions()) assert [(finding.code, finding.location) for finding in report.findings] == [ ("destination-schema-read-failed", "/configuration/destination") @@ -248,11 +251,11 @@ def _raising(package_: ConfigurationPackage, branch: str) -> Mapping[str, Any]: def test_a_wrong_typed_opt_in_is_a_request_refusal(tmp_path: Path) -> None: - config_id, location = _registered(tmp_path) + config_id, projection = _registered(tmp_path) wrong_typed = cast("DestinationSchemaOptions", True) # noqa: FBT003 - the wrong type is the fixture with pytest.raises(configs_service.ConfigsRequestError) as raised: - _validate(config_id, location, destination_schema=wrong_typed) + _validate(config_id, projection, destination_schema=wrong_typed) assert raised.value.family == "request" @@ -262,9 +265,9 @@ def test_the_opt_in_against_a_non_declaring_destination_reports_both_gate_findin ) -> None: data = package_data() data["configuration"]["destination"]["name"] = "peeringmanager" - config_id, location = _registered(tmp_path, data) + config_id, projection = _registered(tmp_path, data) - report = _validate(config_id, location, destination_schema=DestinationSchemaOptions()) + report = _validate(config_id, projection, destination_schema=DestinationSchemaOptions()) assert [(finding.code, finding.location) for finding in report.findings] == [ ("destination-schema-validation-unsupported", "/configuration/destination"), @@ -339,9 +342,9 @@ def items(self) -> ItemsView[str, object]: # noqa: PLR6301 - protocol hook, sel raise _HostileError(msg) _mock_live_schema_read(monkeypatch, _RaisingItems()) - config_id, location = _registered(tmp_path) + config_id, projection = _registered(tmp_path) - report = _validate(config_id, location, destination_schema=DestinationSchemaOptions()) + report = _validate(config_id, projection, destination_schema=DestinationSchemaOptions()) assert [(finding.code, finding.location) for finding in report.findings] == [ ("destination-schema-read-failed", "/configuration/destination") @@ -367,9 +370,9 @@ def attributes(self) -> tuple[object, ...]: raise DestinationSchemaReadError(msg, reason="unauthorized") _mock_live_schema_read(monkeypatch, {"InfraDevice": _ForgingNode()}) - config_id, location = _registered(tmp_path) + config_id, projection = _registered(tmp_path) - report = _validate(config_id, location, destination_schema=DestinationSchemaOptions()) + report = _validate(config_id, projection, destination_schema=DestinationSchemaOptions()) assert [(finding.code, finding.location) for finding in report.findings] == [ ("destination-schema-read-failed", "/configuration/destination") diff --git a/tests/service/test_config_routes.py b/tests/service/test_config_routes.py index ad080b8e..199d7fb8 100644 --- a/tests/service/test_config_routes.py +++ b/tests/service/test_config_routes.py @@ -67,7 +67,7 @@ def test_configuration_routes_register_then_read(tmp_path: Path, monkeypatch: py resolver = EnvironmentPrincipalResolver.from_environment() projection = local_product_projection(tmp_path) runs = RunService(projection, _Orchestration(), secrets=resolver.secret_values) - routes = ConfigurationRoutes(tmp_path, secrets=resolver.secret_values) + routes = ConfigurationRoutes(product_projection=local_product_projection(tmp_path), secrets=resolver.secret_values) client = TestClient(create_app(runs, resolver, routes)) response = client.post( "/configs", @@ -87,16 +87,11 @@ def test_configuration_routes_use_the_injected_projection_for_services_receipts_ tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """Every configuration path uses the one projection supplied at composition.""" - from infrahub_sync.service import config_routes bearer = "admin-token-canary-0003" monkeypatch.setenv(PRINCIPALS_ENV, json.dumps({"admin": {"token": bearer, "administrator": True}})) resolver = EnvironmentPrincipalResolver.from_environment() delegate = local_product_projection(tmp_path) - message = "configuration routes reopened a local product projection" - - def local_projection_forbidden(*_args: object, **_kwargs: object) -> NoReturn: - raise AssertionError(message) class ProjectionSentinel: def __init__(self) -> None: @@ -114,8 +109,6 @@ def record(*args: object, **kwargs: object) -> object: return record projection = ProjectionSentinel() - monkeypatch.setattr(config_routes, "local_product_projection", local_projection_forbidden, raising=False) - monkeypatch.setattr(configs_service, "local_product_projection", local_projection_forbidden) client = TestClient( create_app( RunService(cast("ProductProjection", projection), _Orchestration(), secrets=resolver.secret_values), @@ -156,8 +149,6 @@ def record(*args: object, **kwargs: object) -> object: }.issubset(projection.calls) assert projection.calls.count("reserve_mutation") == 2 assert projection.calls.count("create_configuration") == 1 - with pytest.raises(AssertionError, match=message): - configs_service._standalone_projection(tmp_path) def test_configuration_receipt_and_audit_provider_errors_are_storage_failures() -> None: @@ -385,7 +376,7 @@ def test_configuration_mutation_replays_exact_response_and_rejects_changed_conte create_app( RunService(projection, _Orchestration(), secrets=resolver.secret_values), resolver, - ConfigurationRoutes(tmp_path, secrets=resolver.secret_values), + ConfigurationRoutes(product_projection=local_product_projection(tmp_path), secrets=resolver.secret_values), ) ) headers = {"Authorization": "Bearer admin-token-canary-0003", "Idempotency-Key": "register-once"} @@ -416,7 +407,7 @@ def test_duplicate_configuration_version_checksum_returns_existing_version_witho create_app( RunService(projection, _Orchestration(), secrets=resolver.secret_values), resolver, - ConfigurationRoutes(tmp_path, secrets=resolver.secret_values), + ConfigurationRoutes(product_projection=local_product_projection(tmp_path), secrets=resolver.secret_values), ) ) package = package_data() @@ -452,7 +443,7 @@ def test_configuration_mutation_audits_accepted_replayed_and_refused_idempotency create_app( RunService(projection, _Orchestration(), secrets=resolver.secret_values), resolver, - ConfigurationRoutes(tmp_path, secrets=resolver.secret_values), + ConfigurationRoutes(product_projection=local_product_projection(tmp_path), secrets=resolver.secret_values), ) ) headers = {"Authorization": f"Bearer {bearer}", "Idempotency-Key": "audit-once"} @@ -513,7 +504,9 @@ def register(self, **_kwargs: object) -> dict[str, object]: create_app( RunService(projection, _Orchestration(), secrets=resolver.secret_values), resolver, - ConfigurationRoutes(tmp_path, service=service, secrets=resolver.secret_values), + ConfigurationRoutes( + product_projection=local_product_projection(tmp_path), service=service, secrets=resolver.secret_values + ), ) ) headers = {"Authorization": f"Bearer {bearer}", "Idempotency-Key": "retry-after-service-failure"} @@ -534,9 +527,7 @@ def register(self, **_kwargs: object) -> dict[str, object]: assert all(bearer not in event.model_dump_json() for event in projection.audit_events()) -def test_post_commit_readback_failure_blocks_same_key_retry_without_a_second_configuration( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: +def test_post_commit_readback_failure_blocks_same_key_retry_without_a_second_configuration(tmp_path: Path) -> None: """Contain an unknown post-commit outcome; this is not crash recovery or exactly-once execution.""" projection = local_product_projection(tmp_path) writes = 0 @@ -554,8 +545,7 @@ def lookup_configuration(self, _config_id: str) -> NoReturn: # noqa: PLR6301 message = "read-back failed after configuration commit" raise OSError(message) - monkeypatch.setattr(configs_service, "local_product_projection", lambda _location: PostCommitReadbackFailure()) - routes = ConfigurationRoutes(tmp_path) + routes = ConfigurationRoutes(product_projection=cast("ProductProjection", PostCommitReadbackFailure())) request = { "actor": "admin", "idempotency_key": "post-commit-readback-failure", @@ -614,7 +604,7 @@ def register(self, **_kwargs: object) -> dict[str, object]: } service = Service() - routes = ConfigurationRoutes(tmp_path, service=service) + routes = ConfigurationRoutes(product_projection=local_product_projection(tmp_path), service=service) def mutate() -> tuple[int, dict[str, object]]: return routes.mutate( @@ -643,7 +633,7 @@ def mutate() -> tuple[int, dict[str, object]]: def test_configuration_receipts_use_semantic_resource_identities(tmp_path: Path) -> None: """Registration and version receipts are keyed by domain resource, not HTTP path.""" - routes = ConfigurationRoutes(tmp_path) + routes = ConfigurationRoutes(product_projection=local_product_projection(tmp_path)) register = routes.mutate( actor="admin", idempotency_key="semantic-registration", @@ -689,7 +679,7 @@ def list_configs(**_kwargs: object) -> None: raise AssertionError("unrelated defect") # noqa: EM101, TRY003 - the assertion is the transport boundary probe. with pytest.raises(AssertionError, match="unrelated defect"): - ConfigurationRoutes(tmp_path, service=Service()).list_configs() + ConfigurationRoutes(product_projection=local_product_projection(tmp_path), service=Service()).list_configs() def test_configuration_mutation_refuses_unauthenticated_and_non_admin_calls( @@ -711,7 +701,7 @@ def test_configuration_mutation_refuses_unauthenticated_and_non_admin_calls( create_app( RunService(projection, _Orchestration(), secrets=resolver.secret_values), resolver, - ConfigurationRoutes(tmp_path, secrets=resolver.secret_values), + ConfigurationRoutes(product_projection=local_product_projection(tmp_path), secrets=resolver.secret_values), ) ) body = {"package": package_data(), "reason": "register this package"} @@ -776,7 +766,9 @@ def register(self, **_kwargs: object) -> object: create_app( RunService(local_product_projection(tmp_path), _Orchestration(), secrets=resolver.secret_values), resolver, - ConfigurationRoutes(tmp_path, service=service, secrets=resolver.secret_values), + ConfigurationRoutes( + product_projection=local_product_projection(tmp_path), service=service, secrets=resolver.secret_values + ), ) ) headers = { @@ -840,7 +832,9 @@ def validate(**_kwargs: object) -> ValidationReport: create_app( RunService(local_product_projection(tmp_path), _Orchestration(), secrets=resolver.secret_values), resolver, - ConfigurationRoutes(tmp_path, service=Service(), secrets=resolver.secret_values), + ConfigurationRoutes( + product_projection=local_product_projection(tmp_path), service=Service(), secrets=resolver.secret_values + ), ) ) headers = {"Authorization": f"Bearer {bearer}"} @@ -909,7 +903,9 @@ def list_versions(**_kwargs: object) -> list[dict[str, object]]: create_app( RunService(local_product_projection(tmp_path), _Orchestration(), secrets=resolver.secret_values), resolver, - ConfigurationRoutes(tmp_path, service=Service(), secrets=resolver.secret_values), + ConfigurationRoutes( + product_projection=local_product_projection(tmp_path), service=Service(), secrets=resolver.secret_values + ), ) ) headers = {"Authorization": f"Bearer {bearer}"} @@ -953,7 +949,7 @@ def test_configuration_error_matrix_preserves_only_declared_fields( # noqa: PLR ) resolver = EnvironmentPrincipalResolver.from_environment() runs = RunService(local_product_projection(tmp_path), _Orchestration(), secrets=resolver.secret_values) - routes = ConfigurationRoutes(tmp_path) + routes = ConfigurationRoutes(product_projection=local_product_projection(tmp_path)) def fail(**_kwargs: object) -> None: raise failure @@ -996,7 +992,7 @@ def list_configs(**_kwargs: object) -> None: raise Hostile(message) with pytest.raises(ConfigurationAPIError) as raised: - ConfigurationRoutes(tmp_path, service=Service()).list_configs() + ConfigurationRoutes(product_projection=local_product_projection(tmp_path), service=Service()).list_configs() assert raised.value.family == "configs" @@ -1019,7 +1015,7 @@ def list_configs(**_kwargs: object) -> None: raise Hostile("do not disclose") # noqa: EM101, TRY003 with pytest.raises(ConfigurationAPIError) as raised: - ConfigurationRoutes(tmp_path, service=Service()).list_configs() + ConfigurationRoutes(product_projection=local_product_projection(tmp_path), service=Service()).list_configs() assert raised.value.status == 503 assert raised.value.family == "configs" @@ -1045,7 +1041,7 @@ def list_configs(**_kwargs: object) -> None: raise Hostile("do not disclose", reason="secret-reason") # noqa: EM101, TRY003 with pytest.raises(ConfigurationAPIError) as raised: - ConfigurationRoutes(tmp_path, service=Service()).list_configs() + ConfigurationRoutes(product_projection=local_product_projection(tmp_path), service=Service()).list_configs() assert raised.value.status == 503 assert raised.value.family == "configs" assert raised.value.reason is None @@ -1092,7 +1088,7 @@ def resolve(_token: str) -> object: create_app( cast("RunService", runs), cast("PrincipalResolver", Resolver()), - ConfigurationRoutes(tmp_path, service=config_service), + ConfigurationRoutes(product_projection=local_product_projection(tmp_path), service=config_service), ) ) headers = {"Authorization": "Bearer accepted"} From a0d90a3c91c1bf2ee733bba080e6e956f0a9ddb8 Mon Sep 17 00:00:00 2001 From: Blake Ellis Date: Tue, 1 Sep 2026 14:56:13 -0400 Subject: [PATCH 04/12] Rename the managed packaging extra to service Seat: lead-developer Assignment: single-product-reconciliation Co-Authored-By: Claude Opus 5 --- .github/workflows/workflow-linter.yml | 8 ++++---- .github/workflows/workflow-tests.yml | 12 +++++------ AGENTS.md | 2 +- dev/knowledge/quality-gates.md | 4 ++-- development/README.md | 2 +- docs/docs/reference/managed-http-api.mdx | 4 ++-- .../tutorials/netbox-demo-to-infrahub.mdx | 2 +- infrahub_sync/service/__init__.py | 2 +- pyproject.toml | 6 +++--- tasks/preview.py | 2 +- tests/cli/test_parity_and_closure.py | 2 +- tests/client/test_public_surface.py | 4 ++-- tests/preview/test_preview_configuration.py | 2 +- tests/product_store/test_contract.py | 8 ++++---- tests/test_no_prefect_import.py | 6 +++--- tests/test_vendoring_consistency.py | 12 +++++------ uv.lock | 20 +++++++++---------- 17 files changed, 49 insertions(+), 49 deletions(-) diff --git a/.github/workflows/workflow-linter.yml b/.github/workflows/workflow-linter.yml index bedc5821..67b9eaaa 100644 --- a/.github/workflows/workflow-linter.yml +++ b/.github/workflows/workflow-linter.yml @@ -82,10 +82,10 @@ jobs: if: matrix.python-version == '3.10' run: uv sync --python 3.10 --frozen --extra dev --extra prefect - # Optional managed imports resolve only on their supported Python range. - - name: "Install managed dependencies on Python 3.11+" + # Optional service imports resolve only on their supported Python range. + - name: "Install service dependencies on Python 3.11+" if: matrix.python-version != '3.10' - run: uv sync --python ${{ matrix.python-version }} --frozen --extra dev --extra prefect --extra managed + run: uv sync --python ${{ matrix.python-version }} --frozen --extra dev --extra prefect --extra service - name: "Linting: ruff check" run: "uv run ruff check ." @@ -97,7 +97,7 @@ jobs: if: matrix.python-version == '3.10' run: "uv run ty check --exclude infrahub_sync/service --exclude tests/service ." - - name: "Linting: ty check (managed Python 3.11+)" + - name: "Linting: ty check (service Python 3.11+)" if: matrix.python-version != '3.10' run: "uv run ty check ." diff --git a/.github/workflows/workflow-tests.yml b/.github/workflows/workflow-tests.yml index 2a6e4808..f9fdac01 100644 --- a/.github/workflows/workflow-tests.yml +++ b/.github/workflows/workflow-tests.yml @@ -63,11 +63,11 @@ jobs: if: matrix.python-version == '3.10' run: uv sync --python 3.10 --frozen --extra dev --extra prefect - # Managed HTTP support is Python 3.11+; the separate 3.10 leg still proves + # The Sync HTTP service is Python 3.11+; the separate 3.10 leg still proves # the direct Prefect orchestration profile. - - name: "Install managed dependencies on Python 3.11+" + - name: "Install service dependencies on Python 3.11+" if: matrix.python-version != '3.10' - run: uv sync --python ${{ matrix.python-version }} --frozen --extra dev --extra prefect --extra managed + run: uv sync --python ${{ matrix.python-version }} --frozen --extra dev --extra prefect --extra service # Runs only `-m "not integration"`. Integration tests need a live # Infrahub instance and are opted into via `invoke tests-integration` @@ -81,7 +81,7 @@ jobs: # the base package neither imports nor needs Prefect; only an install where # Prefect is genuinely absent proves the base install still imports and the CLI # still runs. The flow suite skips here, by design. - name: "Unit Tests (base install, Python 3.10, no managed runtimes)" + name: "Unit Tests (base install, Python 3.10, no service runtimes)" needs: ["files-changed"] if: needs.files-changed.outputs.sync == 'true' runs-on: "ubuntu-22.04" @@ -106,9 +106,9 @@ jobs: # opsmill_prefect_extras is vendored first-party code shipped with the package, # so it is present on disk in every install; the base-package guarantee that - # nothing imports it without the managed extra is enforced by + # nothing imports it without the service extra is enforced by # tests/test_no_prefect_import.py. - - name: "Assert managed runtime dependencies are absent from this environment" + - name: "Assert service runtime dependencies are absent from this environment" run: | for module in boto3 botocore fastapi prefect psycopg; do if uv run --no-sync python -c "import ${module}" 2>/dev/null; then diff --git a/AGENTS.md b/AGENTS.md index 92dd5273..6dba0545 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,7 @@ Use Python 3.11–3.13 for the full development profile. The former private (frozen; see its `VENDORED.md`), so no special repository access is required: ```bash -uv sync --extra dev --extra prefect --extra managed +uv sync --extra dev --extra prefect --extra service ``` On Python 3.10, install the direct Prefect profile instead. Managed Sync supports Python diff --git a/dev/knowledge/quality-gates.md b/dev/knowledge/quality-gates.md index 7bb9d0a6..15bcc63e 100644 --- a/dev/knowledge/quality-gates.md +++ b/dev/knowledge/quality-gates.md @@ -54,7 +54,7 @@ Use `rumdl check .` and fix violations by hand. When you only want the Python fo Raw `pylint infrahub_sync/` reports inherited findings. Measured directly on this repository at commit `697b2f4`, using Python 3.13.3, Pylint 4.0.5, and an environment synced with -`--extra dev --extra prefect --extra managed`: +`--extra dev --extra prefect --extra service`: - exit code **28**, which is pylint's bitmask for warning (4) + refactor (8) + convention (16) — not a count; @@ -114,7 +114,7 @@ restore incidental generated-file changes before committing unrelated work. ## CI Python 3.11–3.13 linting runs in an environment synced with -`--extra dev --extra prefect --extra managed`. The type gate checks the full tree except +`--extra dev --extra prefect --extra service`. The type gate checks the full tree except the frozen vendored upstream tests (excluded via `[tool.ty.src]`); the former private `opsmill/prefect-extras` Git dependency is vendored in-repo, so no special access is required. diff --git a/development/README.md b/development/README.md index f635ebb3..12722b58 100644 --- a/development/README.md +++ b/development/README.md @@ -18,7 +18,7 @@ the walkthrough, rerun `preview.smoke`, or reset volumes before running ## Start ```bash -uv sync --extra dev --extra prefect --extra managed +uv sync --extra dev --extra prefect --extra service uv run invoke preview.up ``` diff --git a/docs/docs/reference/managed-http-api.mdx b/docs/docs/reference/managed-http-api.mdx index 570e4ab3..8293e233 100644 --- a/docs/docs/reference/managed-http-api.mdx +++ b/docs/docs/reference/managed-http-api.mdx @@ -20,11 +20,11 @@ profile yet, so install from a repository checkout on the API host and every wor that can run its deployment: ```bash -uv sync --extra dev --extra prefect --extra managed +uv sync --extra dev --extra prefect --extra service ``` Once a release with the managed profile is published, `pip install -'infrahub-sync[managed]'` becomes the deployment path. +'infrahub-sync[service]'` becomes the deployment path. The profile directly installs FastAPI, HTTPX, Uvicorn, Prefect 3.8.1, Psycopg, and Boto3. The OpsMill Prefect Extras integration ships with the package itself as a vendored copy of upstream diff --git a/docs/docs/tutorials/netbox-demo-to-infrahub.mdx b/docs/docs/tutorials/netbox-demo-to-infrahub.mdx index 34e6ff02..aa0bbe3e 100644 --- a/docs/docs/tutorials/netbox-demo-to-infrahub.mdx +++ b/docs/docs/tutorials/netbox-demo-to-infrahub.mdx @@ -91,7 +91,7 @@ You should see the Infrahub web interface with a navigation menu on the left sid ```bash git clone https://github.com/opsmill/infrahub-sync.git ../infrahub-sync -uv add --editable "../infrahub-sync[managed]" pynetbox +uv add --editable "../infrahub-sync[service]" pynetbox ``` This installs the `infrahub-sync` command. `pynetbox` is required by the NetBox adapter. diff --git a/infrahub_sync/service/__init__.py b/infrahub_sync/service/__init__.py index 25eba27e..d34c1db4 100644 --- a/infrahub_sync/service/__init__.py +++ b/infrahub_sync/service/__init__.py @@ -1,5 +1,5 @@ """Optional Sync HTTP service and Prefect integration. This package is intentionally not imported by :mod:`infrahub_sync`; install the -``managed`` extra before importing its modules. +``service`` extra before importing its modules. """ diff --git a/pyproject.toml b/pyproject.toml index 52617b7e..2d0d99fa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,14 +60,14 @@ infrahub-sync = "infrahub_sync.cli:app" prefect = [ "prefect==3.8.1", ] -# Managed HTTP service and worker profile (Python 3.11-3.13). Every runtime -# imported directly by `infrahub_sync.managed` is declared here; the reusable +# Sync HTTP service and worker profile (Python 3.11-3.13). Every runtime +# imported directly by `infrahub_sync.service` is declared here; the reusable # Prefect integration is pinned to the reviewed DB-102/103/104 stack exactly. # The previous `opsmill-prefect-extras` private Git pin is vendored instead: the # package ships from this repository at `opsmill_prefect_extras/`, byte-identical to # upstream commit 97465e75137f6121d0377cd637383cfb3530d734. See # `opsmill_prefect_extras/VENDORED.md` for the freeze and re-adoption rules. -managed = [ +service = [ "boto3>=1.35,<2; python_version >= '3.11'", "fastapi>=0.115,<1; python_version >= '3.11'", "prefect==3.8.1; python_version >= '3.11'", diff --git a/tasks/preview.py b/tasks/preview.py index d640353a..9df85b00 100644 --- a/tasks/preview.py +++ b/tasks/preview.py @@ -196,7 +196,7 @@ def ensure_smoke_branch(env: dict[str, str]) -> None: def _wait_for_http(url: str, description: str, timeout: int = WAIT_TIMEOUT_SECONDS) -> None: - import httpx # noqa: PLC0415 -- lazy so importing the tasks package never requires the managed extras + import httpx # noqa: PLC0415 -- lazy so importing the tasks package never requires the service extras print(f" - [{NAMESPACE}] Waiting for {description} at {url}") deadline = time.monotonic() + timeout diff --git a/tests/cli/test_parity_and_closure.py b/tests/cli/test_parity_and_closure.py index d75fb9f7..90e9c0c6 100644 --- a/tests/cli/test_parity_and_closure.py +++ b/tests/cli/test_parity_and_closure.py @@ -252,7 +252,7 @@ def test_netbox_tutorial_starts_and_authenticates_the_service_boundary() -> None text = (ROOT / "docs/docs/tutorials/netbox-demo-to-infrahub.mdx").read_text(encoding="utf-8") required = ( - "infrahub-sync[managed]", + "infrahub-sync[service]", "prefect server start", "prefect worker start", "infrahub_sync.service.deploy", diff --git a/tests/client/test_public_surface.py b/tests/client/test_public_surface.py index b1c31b92..edb77d0f 100644 --- a/tests/client/test_public_surface.py +++ b/tests/client/test_public_surface.py @@ -22,10 +22,10 @@ def test_embedded_v1_api_is_removed() -> None: def test_httpx_is_a_base_dependency_only() -> None: metadata = (ROOT / "pyproject.toml").read_text(encoding="utf-8") base, optional = metadata.split("[project.optional-dependencies]", maxsplit=1) - managed, _dev = optional.split("dev = [", maxsplit=1) + service, _dev = optional.split("dev = [", maxsplit=1) assert '"httpx>=0.27,<1"' in base - assert "httpx" not in managed + assert "httpx" not in service def test_typing_extensions_supports_the_python_310_client_tests() -> None: diff --git a/tests/preview/test_preview_configuration.py b/tests/preview/test_preview_configuration.py index 1ee7643d..3a8cc69a 100644 --- a/tests/preview/test_preview_configuration.py +++ b/tests/preview/test_preview_configuration.py @@ -385,6 +385,6 @@ def test_netbox_tutorial_uses_one_checkout_for_code_and_configuration() -> None: tutorial = (REPO_ROOT / "docs/docs/tutorials/netbox-demo-to-infrahub.mdx").read_text(encoding="utf-8") assert "git clone https://github.com/opsmill/infrahub-sync.git ../infrahub-sync" in tutorial - assert 'uv add --editable "../infrahub-sync[managed]"' in tutorial + assert 'uv add --editable "../infrahub-sync[service]"' in tutorial assert "cp ../infrahub-sync/examples/netbox_to_infrahub/config.yml" in tutorial assert "v3-preview.1" not in tutorial diff --git a/tests/product_store/test_contract.py b/tests/product_store/test_contract.py index bc7f8ca2..512a53e8 100644 --- a/tests/product_store/test_contract.py +++ b/tests/product_store/test_contract.py @@ -4235,7 +4235,7 @@ def side_effect(operation: str, parameters: tuple[Any, ...] = ()) -> MagicMock: def _reachable_postgresql_dsn() -> str | None: """Return a reachable PostgreSQL DSN from ``PRODUCT_STORE_TEST_POSTGRESQL_DSN``, or None. - The managed extra supplies ``psycopg``; an absent driver or unreachable endpoint still + The service extra supplies ``psycopg``; an absent driver or unreachable endpoint still skips this opt-in test before it can contact a service. """ dsn = os.environ.get("PRODUCT_STORE_TEST_POSTGRESQL_DSN") @@ -4243,7 +4243,7 @@ def _reachable_postgresql_dsn() -> str | None: return None try: # pylint: disable-next=import-outside-toplevel,import-error - import psycopg # ty: ignore[unresolved-import] - TODO: optional managed dependency + import psycopg # ty: ignore[unresolved-import] - TODO: optional service dependency except ImportError: return None try: @@ -4281,7 +4281,7 @@ def test_postgresql_run_store_initializes_against_a_real_server() -> None: if dsn is None: pytest.skip("psycopg is not installed, or PRODUCT_STORE_TEST_POSTGRESQL_DSN is unset/unreachable") # pylint: disable-next=import-outside-toplevel,import-error - import psycopg # ty: ignore[unresolved-import] - TODO: optional managed dependency + import psycopg # ty: ignore[unresolved-import] - TODO: optional service dependency from infrahub_sync.service.storage import PsycopgConnectionFactory @@ -4364,7 +4364,7 @@ def _assert_real_postgresql_refuses_partial_configuration_binding(dsn: str) -> N reasonable; it has exactly one caller. """ # pylint: disable-next=import-outside-toplevel,import-error - import psycopg # ty: ignore[unresolved-import] - TODO: optional managed dependency + import psycopg # ty: ignore[unresolved-import] - TODO: optional service dependency def raw_insert( run_id: str, *, config_id: str | None, registry_version: int | None, package_checksum: str | None diff --git a/tests/test_no_prefect_import.py b/tests/test_no_prefect_import.py index 13073f4b..f5fa05de 100644 --- a/tests/test_no_prefect_import.py +++ b/tests/test_no_prefect_import.py @@ -102,7 +102,7 @@ def _imported_names(path: Path, *, root: Path = REPO_ROOT) -> set[str]: return names -def test_base_package_imports_and_runs_without_managed_dependencies_in_a_fresh_interpreter() -> None: +def test_base_package_imports_and_runs_without_service_dependencies_in_a_fresh_interpreter() -> None: """Base imports and CLI sanity must not pull in service dependencies.""" completed = subprocess.run( # noqa: S603 - fixed argv, this interpreter, no shell [sys.executable, "-c", PROBE_SCRIPT], @@ -115,7 +115,7 @@ def test_base_package_imports_and_runs_without_managed_dependencies_in_a_fresh_i assert "NO-OPTIONAL-SERVICE-IMPORT-OK" in completed.stdout -def test_execution_surface_imports_no_optional_managed_runtime() -> None: +def test_execution_surface_imports_no_optional_service_runtime() -> None: """The shared surface imports no optional runtime distribution or package.""" imported = _imported_names(PACKAGE_ROOT / "execution.py") assert not [name for name in imported if name.partition(".")[0] in OPTIONAL_DISTRIBUTION_NAMES] @@ -135,7 +135,7 @@ def test_no_base_package_module_imports_an_optional_runtime_package() -> None: assert not {path: names for path, names in offenders.items() if names} -def test_base_install_workflow_proves_external_managed_runtimes_are_unavailable() -> None: +def test_base_install_workflow_proves_external_service_runtimes_are_unavailable() -> None: """The real base-profile leg checks the exact AR6 external dependency set.""" workflow = WORKFLOW_PATH.read_text(encoding="utf-8") match = re.search(r"for module in (?P[^;\n]+); do", workflow) diff --git a/tests/test_vendoring_consistency.py b/tests/test_vendoring_consistency.py index fd5e1d37..7af08299 100644 --- a/tests/test_vendoring_consistency.py +++ b/tests/test_vendoring_consistency.py @@ -30,8 +30,8 @@ def test_vendored_package_state_is_consistent() -> None: vendored = VENDORED_DIR.is_dir() wheel_packages = data["tool"]["hatch"]["build"]["targets"]["wheel"]["packages"] - managed_deps = data["project"]["optional-dependencies"]["managed"] - has_git_dep = any("prefect-extras.git" in dep for dep in managed_deps) + service_deps = data["project"]["optional-dependencies"]["service"] + has_git_dep = any("prefect-extras.git" in dep for dep in service_deps) if vendored: assert "opsmill_prefect_extras" in wheel_packages, "vendored dir present but not shipped in the wheel" @@ -44,12 +44,12 @@ def test_vendored_package_state_is_consistent() -> None: assert not VENDORED_TESTS_DIR.exists(), "vendored tests remain after the package was re-adopted" -def test_managed_storage_drivers_are_not_base_dependencies() -> None: - """Only the managed profile carries PostgreSQL and S3 client dependencies.""" +def test_service_storage_drivers_are_not_base_dependencies() -> None: + """Only the service profile carries PostgreSQL and S3 client dependencies.""" data = _pyproject() base_dependencies = data["project"]["dependencies"] - managed_dependencies = data["project"]["optional-dependencies"]["managed"] + service_dependencies = data["project"]["optional-dependencies"]["service"] for package in ("boto3", "psycopg"): assert not any(dependency.lower().startswith(package) for dependency in base_dependencies) - assert any(dependency.lower().startswith(package) for dependency in managed_dependencies) + assert any(dependency.lower().startswith(package) for dependency in service_dependencies) diff --git a/uv.lock b/uv.lock index 3a929aad..2176a30d 100644 --- a/uv.lock +++ b/uv.lock @@ -1133,22 +1133,22 @@ dev = [ { name = "types-ujson" }, { name = "yamllint" }, ] -managed = [ +prefect = [ + { name = "prefect" }, +] +service = [ { name = "boto3", marker = "python_full_version >= '3.11'" }, { name = "fastapi", marker = "python_full_version >= '3.11'" }, { name = "prefect", marker = "python_full_version >= '3.11'" }, { name = "psycopg", extra = ["binary"], marker = "python_full_version >= '3.11'" }, { name = "uvicorn", marker = "python_full_version >= '3.11'" }, ] -prefect = [ - { name = "prefect" }, -] [package.metadata] requires-dist = [ - { name = "boto3", marker = "python_full_version >= '3.11' and extra == 'managed'", specifier = ">=1.35,<2" }, + { name = "boto3", marker = "python_full_version >= '3.11' and extra == 'service'", specifier = ">=1.35,<2" }, { name = "diffsync", specifier = ">=2.1,<3.0" }, - { name = "fastapi", marker = "python_full_version >= '3.11' and extra == 'managed'", specifier = ">=0.115,<1" }, + { name = "fastapi", marker = "python_full_version >= '3.11' and extra == 'service'", specifier = ">=0.115,<1" }, { name = "filelock", specifier = ">=3.13" }, { name = "fsspec", specifier = ">=2024.6" }, { name = "httpx", specifier = ">=0.27,<1" }, @@ -1157,9 +1157,9 @@ requires-dist = [ { name = "ipython", marker = "extra == 'dev'" }, { name = "netutils", specifier = ">=1.9,<2.0" }, { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=4.0,<5.0" }, - { name = "prefect", marker = "python_full_version >= '3.11' and extra == 'managed'", specifier = "==3.8.1" }, + { name = "prefect", marker = "python_full_version >= '3.11' and extra == 'service'", specifier = "==3.8.1" }, { name = "prefect", marker = "extra == 'prefect'", specifier = "==3.8.1" }, - { name = "psycopg", extras = ["binary"], marker = "python_full_version >= '3.11' and extra == 'managed'", specifier = ">=3.2,<4" }, + { name = "psycopg", extras = ["binary"], marker = "python_full_version >= '3.11' and extra == 'service'", specifier = ">=3.2,<4" }, { name = "pyarrow", specifier = ">=17,<22" }, { name = "pylint", marker = "extra == 'dev'" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.2,<10" }, @@ -1182,10 +1182,10 @@ requires-dist = [ { name = "types-toml", marker = "extra == 'dev'" }, { name = "types-ujson", marker = "extra == 'dev'" }, { name = "typing-extensions", specifier = ">=4.4" }, - { name = "uvicorn", marker = "python_full_version >= '3.11' and extra == 'managed'", specifier = ">=0.34,<1" }, + { name = "uvicorn", marker = "python_full_version >= '3.11' and extra == 'service'", specifier = ">=0.34,<1" }, { name = "yamllint", marker = "extra == 'dev'", specifier = ">=1.37.1" }, ] -provides-extras = ["prefect", "managed", "dev"] +provides-extras = ["prefect", "service", "dev"] [[package]] name = "iniconfig" From c26f69a17e98d5092e314d7132b082fd1c09b2ac Mon Sep 17 00:00:00 2001 From: Blake Ellis Date: Tue, 1 Sep 2026 14:57:30 -0400 Subject: [PATCH 05/12] Add failing tests for the service environment variable names Seat: lead-developer Assignment: single-product-reconciliation Co-Authored-By: Claude Opus 5 --- tests/service/test_environment_names.py | 89 +++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 tests/service/test_environment_names.py diff --git a/tests/service/test_environment_names.py b/tests/service/test_environment_names.py new file mode 100644 index 00000000..3bd7d572 --- /dev/null +++ b/tests/service/test_environment_names.py @@ -0,0 +1,89 @@ +"""The service reads only its own environment names; the retired ones are inert.""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any + +import pytest + +pytest.importorskip("fastapi") +pytest.importorskip("prefect") + +from infrahub_sync.service import auth, deploy, serve # noqa: E402 + +if TYPE_CHECKING: + from pathlib import Path + +RETIRED_NAMES = ( + "INFRAHUB_SYNC_MANAGED_BEARER_TOKENS", + "INFRAHUB_SYNC_MANAGED_WORK_POOL", + "INFRAHUB_SYNC_MANAGED_FLOW_WORKING_DIRECTORY", + "INFRAHUB_SYNC_MANAGED_HOST", + "INFRAHUB_SYNC_MANAGED_CACHE_LOCATION", +) +PRINCIPALS = json.dumps({"admin": {"token": "service-token-canary-0001", "administrator": True}}) + + +@pytest.fixture(autouse=True) +def _clear_environment(monkeypatch: pytest.MonkeyPatch) -> None: + for name in (*RETIRED_NAMES, auth.PRINCIPALS_ENV, deploy.WORK_POOL_ENV, deploy.FLOW_WORKING_DIRECTORY_ENV): + monkeypatch.delenv(name, raising=False) + + +def test_the_service_environment_names_are_the_declared_ones() -> None: + assert auth.PRINCIPALS_ENV == "INFRAHUB_SYNC_SERVICE_BEARER_TOKENS" + assert deploy.WORK_POOL_ENV == "INFRAHUB_SYNC_SERVICE_WORK_POOL" + assert deploy.FLOW_WORKING_DIRECTORY_ENV == "INFRAHUB_SYNC_SERVICE_FLOW_WORKING_DIRECTORY" + + +def test_a_retired_bearer_token_name_is_ignored(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("INFRAHUB_SYNC_MANAGED_BEARER_TOKENS", PRINCIPALS) + + with pytest.raises(ValueError, match=auth.PRINCIPALS_ENV): + auth.EnvironmentPrincipalResolver.from_environment() + + +def test_a_retired_flow_working_directory_name_is_ignored( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setenv("INFRAHUB_SYNC_MANAGED_FLOW_WORKING_DIRECTORY", str(tmp_path)) + + with pytest.raises(ValueError, match=deploy.FLOW_WORKING_DIRECTORY_ENV): + deploy.required_flow_working_directory() + + +def test_a_retired_work_pool_name_is_ignored_by_the_reconciler(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("INFRAHUB_SYNC_MANAGED_WORK_POOL", "retired-pool") + monkeypatch.setenv(auth.PRINCIPALS_ENV, PRINCIPALS) + captured: dict[str, Any] = {} + + class _Reconciler: + def __init__(self, _projection: Any, _orchestration: Any, _policy: Any, work_pool: str) -> None: + captured["work_pool"] = work_pool + + monkeypatch.setattr(serve, "RunLivenessReconciler", _Reconciler) + serve.build_app( + projection_factory=lambda: object(), + run_service_factory=lambda *_args, **_kwargs: object(), + configuration_routes_factory=lambda **_kwargs: object(), + app_factory=lambda *args: args, + ) + + assert captured["work_pool"] == "default" + + +def test_a_retired_host_name_is_ignored_by_the_server(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("INFRAHUB_SYNC_MANAGED_HOST", "10.0.0.1") + monkeypatch.setattr(serve, "build_app", lambda: object()) + captured: dict[str, Any] = {} + monkeypatch.setattr(serve.uvicorn, "run", lambda app, **kwargs: captured.update(app=app, **kwargs)) + + serve.main() + + assert captured["host"] == "127.0.0.1" + + +def test_the_retired_product_cache_setting_is_gone() -> None: + with pytest.raises(ModuleNotFoundError): + __import__("infrahub_sync.service._settings") From 3026b08ab7ddd549503bc38ef1875d79d162ec32 Mon Sep 17 00:00:00 2001 From: Blake Ellis Date: Tue, 1 Sep 2026 15:01:41 -0400 Subject: [PATCH 06/12] Rename the four service environment variables and drop the retired cache setting Seat: lead-developer Assignment: single-product-reconciliation Co-Authored-By: Claude Opus 5 --- docs/docs/reference/managed-http-api.mdx | 14 +++++++------- docs/docs/tutorials/netbox-demo-to-infrahub.mdx | 8 ++++---- infrahub_sync/service/_settings.py | 3 --- infrahub_sync/service/app.py | 2 +- infrahub_sync/service/auth.py | 2 +- infrahub_sync/service/deploy.py | 4 ++-- infrahub_sync/service/serve.py | 4 ++-- tasks/preview.py | 6 +++--- tests/cli/test_parity_and_closure.py | 2 +- tests/preview/test_preview_configuration.py | 3 +-- tests/service/test_environment_names.py | 12 +++++------- tests/test_service_storage_docs.py | 5 ++--- 12 files changed, 29 insertions(+), 36 deletions(-) delete mode 100644 infrahub_sync/service/_settings.py diff --git a/docs/docs/reference/managed-http-api.mdx b/docs/docs/reference/managed-http-api.mdx index 8293e233..7239e443 100644 --- a/docs/docs/reference/managed-http-api.mdx +++ b/docs/docs/reference/managed-http-api.mdx @@ -45,11 +45,11 @@ S3 credentials use Boto3's standard credential-provider chain. ## Configure principals -`INFRAHUB_SYNC_MANAGED_BEARER_TOKENS` contains a non-empty JSON object keyed by actor. Each +`INFRAHUB_SYNC_SERVICE_BEARER_TOKENS` contains a non-empty JSON object keyed by actor. Each entry has a bearer token of at least 16 characters and an optional administrator flag: ```bash -export INFRAHUB_SYNC_MANAGED_BEARER_TOKENS='{ +export INFRAHUB_SYNC_SERVICE_BEARER_TOKENS='{ "automation@example.com": { "token": "replace-with-a-secret-token", "administrator": false @@ -78,8 +78,8 @@ deployment: ```bash export PREFECT_API_URL="http://127.0.0.1:4200/api" -export INFRAHUB_SYNC_MANAGED_WORK_POOL="sync-process-pool" -export INFRAHUB_SYNC_MANAGED_FLOW_WORKING_DIRECTORY="/path/to/checkout" +export INFRAHUB_SYNC_SERVICE_WORK_POOL="sync-process-pool" +export INFRAHUB_SYNC_SERVICE_FLOW_WORKING_DIRECTORY="/path/to/checkout" python -m infrahub_sync.managed.deploy ``` @@ -93,7 +93,7 @@ Start each worker through the managed entry point. The work pool must already ex ```bash python -m infrahub_sync.managed.worker \ - --pool "$INFRAHUB_SYNC_MANAGED_WORK_POOL" + --pool "$INFRAHUB_SYNC_SERVICE_WORK_POOL" ``` Each invocation generates a new UUID-suffixed worker name. After its Prefect heartbeat, @@ -131,7 +131,7 @@ Configure the worker environment before it starts: | `INFRAHUB_SYNC_S3_PREFIX` | Optional object-key prefix; defaults to `infrahub-sync`. | | `INFRAHUB_SYNC_S3_ENDPOINT_URL` | Optional absolute `http` or `https` URL with no userinfo. The value reaches Boto3 unchanged; Boto3 owns any narrower SDK compatibility. | | `INFRAHUB_SYNC_S3_REGION` | Optional region passed to Boto3. | -| `INFRAHUB_SYNC_MANAGED_WORK_POOL` | Name of the existing Prefect pool used by the deployment, worker, and API reconciliation. It is never returned by the API. | +| `INFRAHUB_SYNC_SERVICE_WORK_POOL` | Name of the existing Prefect pool used by the deployment, worker, and API reconciliation. It is never returned by the API. | | `INFRAHUB_SYNC_RUN_ADMISSION_TTL_SECONDS` | Optional decimal integer from 1 through 86400; defaults to 300. An unclaimed execution becomes abandoned at this inclusive deadline. | | `PREFECT_WORKER_QUERY_SECONDS` | Prefect worker polling interval. It must be a finite positive decimal no greater than 3600; the API derives its liveness thresholds from the same value. | | Adapter credential variables | Credentials required by the selected Sync configuration. Keep them in the worker environment or its secret provider. | @@ -165,7 +165,7 @@ export PREFECT_API_URL="http://127.0.0.1:4200/api" export INFRAHUB_SYNC_DATABASE_URL="postgresql://sync:replace-me@postgres/infrahub_sync" export INFRAHUB_SYNC_S3_BUCKET="infrahub-sync-artifacts" export INFRAHUB_SYNC_S3_PREFIX="infrahub-sync" -export INFRAHUB_SYNC_MANAGED_HOST="127.0.0.1" +export INFRAHUB_SYNC_SERVICE_HOST="127.0.0.1" python -m infrahub_sync.managed.serve ``` diff --git a/docs/docs/tutorials/netbox-demo-to-infrahub.mdx b/docs/docs/tutorials/netbox-demo-to-infrahub.mdx index aa0bbe3e..52f6eacd 100644 --- a/docs/docs/tutorials/netbox-demo-to-infrahub.mdx +++ b/docs/docs/tutorials/netbox-demo-to-infrahub.mdx @@ -291,11 +291,11 @@ export INFRAHUB_SYNC_S3_ENDPOINT_URL="http://127.0.0.1:9000" export INFRAHUB_SYNC_S3_REGION="us-east-1" export AWS_ACCESS_KEY_ID="sync-local" export AWS_SECRET_ACCESS_KEY="sync-local-secret" -export INFRAHUB_SYNC_MANAGED_WORK_POOL="sync-process-pool" -export INFRAHUB_SYNC_MANAGED_FLOW_WORKING_DIRECTORY="$PWD" +export INFRAHUB_SYNC_SERVICE_WORK_POOL="sync-process-pool" +export INFRAHUB_SYNC_SERVICE_FLOW_WORKING_DIRECTORY="$PWD" export INFRAHUB_SYNC_CONFIG_DIRECTORY="$PWD/sync-projects" export INFRAHUB_SYNC_CACHE_DIR="$PWD/.infrahub-sync-cache" -export INFRAHUB_SYNC_MANAGED_BEARER_TOKENS='{"tutorial-admin":{"token":"tutorial-sync-api-token","administrator":true}}' +export INFRAHUB_SYNC_SERVICE_BEARER_TOKENS='{"tutorial-admin":{"token":"tutorial-sync-api-token","administrator":true}}' export NETBOX_URL="https://demo.netbox.dev" export NETBOX_TOKEN="nbt_..." export INFRAHUB_ADDRESS="http://localhost:8000" @@ -316,7 +316,7 @@ uv run prefect worker start --pool sync-process-pool 5. In terminal 3, start the Sync API: ```bash -export INFRAHUB_SYNC_MANAGED_HOST="127.0.0.2" +export INFRAHUB_SYNC_SERVICE_HOST="127.0.0.2" uv run python -m infrahub_sync.service.serve ``` diff --git a/infrahub_sync/service/_settings.py b/infrahub_sync/service/_settings.py deleted file mode 100644 index b50a218e..00000000 --- a/infrahub_sync/service/_settings.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Environment names shared by Sync API and worker composition roots.""" - -PRODUCT_CACHE_ENV = "INFRAHUB_SYNC_MANAGED_CACHE_LOCATION" diff --git a/infrahub_sync/service/app.py b/infrahub_sync/service/app.py index ffedd2c0..9a7146b9 100644 --- a/infrahub_sync/service/app.py +++ b/infrahub_sync/service/app.py @@ -142,7 +142,7 @@ def get_version() -> VersionResource: @application.get("/status") async def get_status() -> ServiceStatusResource: """Return unauthenticated lifecycle state without provider identifiers.""" - return await service.status(os.environ.get("INFRAHUB_SYNC_MANAGED_WORK_POOL", "default")) + return await service.status(os.environ.get("INFRAHUB_SYNC_SERVICE_WORK_POOL", "default")) @application.middleware("http") async def contain_unhandled_error( diff --git a/infrahub_sync/service/auth.py b/infrahub_sync/service/auth.py index 5bc3f03c..c02a4c15 100644 --- a/infrahub_sync/service/auth.py +++ b/infrahub_sync/service/auth.py @@ -9,7 +9,7 @@ from pydantic import BaseModel, ConfigDict, Field, ValidationError -PRINCIPALS_ENV = "INFRAHUB_SYNC_MANAGED_BEARER_TOKENS" +PRINCIPALS_ENV = "INFRAHUB_SYNC_SERVICE_BEARER_TOKENS" class Principal(BaseModel): diff --git a/infrahub_sync/service/deploy.py b/infrahub_sync/service/deploy.py index 2d1320cf..adef462b 100644 --- a/infrahub_sync/service/deploy.py +++ b/infrahub_sync/service/deploy.py @@ -13,8 +13,8 @@ from .orchestration import SERVICE_DEFINITION -WORK_POOL_ENV = "INFRAHUB_SYNC_MANAGED_WORK_POOL" -FLOW_WORKING_DIRECTORY_ENV = "INFRAHUB_SYNC_MANAGED_FLOW_WORKING_DIRECTORY" +WORK_POOL_ENV = "INFRAHUB_SYNC_SERVICE_WORK_POOL" +FLOW_WORKING_DIRECTORY_ENV = "INFRAHUB_SYNC_SERVICE_FLOW_WORKING_DIRECTORY" CATALOGUE = WorkflowCatalogue(SERVICE_DEFINITION) diff --git a/infrahub_sync/service/serve.py b/infrahub_sync/service/serve.py index 0af476ad..1946cb25 100644 --- a/infrahub_sync/service/serve.py +++ b/infrahub_sync/service/serve.py @@ -64,14 +64,14 @@ def build_app( projection, orchestration, policy, - os.environ.get("INFRAHUB_SYNC_MANAGED_WORK_POOL", "default"), + os.environ.get("INFRAHUB_SYNC_SERVICE_WORK_POOL", "default"), ) return app_factory(service, resolver, configuration_routes, reconciler) def main() -> None: """Serve the Sync API; Prefect workers and deployments are separate.""" - uvicorn.run(build_app(), host=os.environ.get("INFRAHUB_SYNC_MANAGED_HOST", "127.0.0.1"), port=8000) + uvicorn.run(build_app(), host=os.environ.get("INFRAHUB_SYNC_SERVICE_HOST", "127.0.0.1"), port=8000) if __name__ == "__main__": diff --git a/tasks/preview.py b/tasks/preview.py index 9df85b00..34c21c14 100644 --- a/tasks/preview.py +++ b/tasks/preview.py @@ -139,11 +139,11 @@ def _runtime_env(values: dict[str, str]) -> dict[str, str]: "INFRAHUB_SYNC_S3_REGION": "us-east-1", "AWS_ACCESS_KEY_ID": values["PREVIEW_MINIO_ACCESS_KEY"], "AWS_SECRET_ACCESS_KEY": values["PREVIEW_MINIO_SECRET_KEY"], - "INFRAHUB_SYNC_MANAGED_BEARER_TOKENS": values["PREVIEW_BEARER_TOKENS"], - "INFRAHUB_SYNC_MANAGED_WORK_POOL": values["PREVIEW_WORK_POOL"], + "INFRAHUB_SYNC_SERVICE_BEARER_TOKENS": values["PREVIEW_BEARER_TOKENS"], + "INFRAHUB_SYNC_SERVICE_WORK_POOL": values["PREVIEW_WORK_POOL"], "INFRAHUB_SYNC_RUN_ADMISSION_TTL_SECONDS": values["PREVIEW_RUN_ADMISSION_TTL_SECONDS"], "PREFECT_WORKER_QUERY_SECONDS": values["PREVIEW_PREFECT_WORKER_QUERY_SECONDS"], - "INFRAHUB_SYNC_MANAGED_FLOW_WORKING_DIRECTORY": str(REPO_ROOT), + "INFRAHUB_SYNC_SERVICE_FLOW_WORKING_DIRECTORY": str(REPO_ROOT), } ) return env diff --git a/tests/cli/test_parity_and_closure.py b/tests/cli/test_parity_and_closure.py index 90e9c0c6..e2492ca8 100644 --- a/tests/cli/test_parity_and_closure.py +++ b/tests/cli/test_parity_and_closure.py @@ -257,7 +257,7 @@ def test_netbox_tutorial_starts_and_authenticates_the_service_boundary() -> None "prefect worker start", "infrahub_sync.service.deploy", "infrahub_sync.service.serve", - "INFRAHUB_SYNC_MANAGED_BEARER_TOKENS", + "INFRAHUB_SYNC_SERVICE_BEARER_TOKENS", "INFRAHUB_SYNC_API_URL", "INFRAHUB_SYNC_API_TOKEN", "The worker, not the CLI, reads the NetBox", diff --git a/tests/preview/test_preview_configuration.py b/tests/preview/test_preview_configuration.py index 3a8cc69a..ff085908 100644 --- a/tests/preview/test_preview_configuration.py +++ b/tests/preview/test_preview_configuration.py @@ -73,8 +73,7 @@ def test_preview_declares_the_managed_postgresql_and_minio_storage_shape() -> No assert environment["AWS_ACCESS_KEY_ID"] == "preview-minio-access" assert environment["AWS_SECRET_ACCESS_KEY"] == PREVIEW_MINIO_SECRET assert "INFRAHUB_SYNC_CACHE_DIR" in environment - assert "INFRAHUB_SYNC_MANAGED_CACHE_LOCATION" not in environment - assert environment["INFRAHUB_SYNC_MANAGED_WORK_POOL"] == "preview-pool" + assert environment["INFRAHUB_SYNC_SERVICE_WORK_POOL"] == "preview-pool" assert environment["INFRAHUB_SYNC_RUN_ADMISSION_TTL_SECONDS"] == "600" assert environment["PREFECT_WORKER_QUERY_SECONDS"] == "15" diff --git a/tests/service/test_environment_names.py b/tests/service/test_environment_names.py index 3bd7d572..1c7f41d0 100644 --- a/tests/service/test_environment_names.py +++ b/tests/service/test_environment_names.py @@ -10,7 +10,7 @@ pytest.importorskip("fastapi") pytest.importorskip("prefect") -from infrahub_sync.service import auth, deploy, serve # noqa: E402 +from infrahub_sync.service import auth, deploy, serve if TYPE_CHECKING: from pathlib import Path @@ -44,9 +44,7 @@ def test_a_retired_bearer_token_name_is_ignored(monkeypatch: pytest.MonkeyPatch) auth.EnvironmentPrincipalResolver.from_environment() -def test_a_retired_flow_working_directory_name_is_ignored( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path -) -> None: +def test_a_retired_flow_working_directory_name_is_ignored(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: monkeypatch.setenv("INFRAHUB_SYNC_MANAGED_FLOW_WORKING_DIRECTORY", str(tmp_path)) with pytest.raises(ValueError, match=deploy.FLOW_WORKING_DIRECTORY_ENV): @@ -59,12 +57,12 @@ def test_a_retired_work_pool_name_is_ignored_by_the_reconciler(monkeypatch: pyte captured: dict[str, Any] = {} class _Reconciler: - def __init__(self, _projection: Any, _orchestration: Any, _policy: Any, work_pool: str) -> None: + def __init__(self, _projection: object, _orchestration: object, _policy: object, work_pool: str) -> None: captured["work_pool"] = work_pool monkeypatch.setattr(serve, "RunLivenessReconciler", _Reconciler) serve.build_app( - projection_factory=lambda: object(), + projection_factory=object, run_service_factory=lambda *_args, **_kwargs: object(), configuration_routes_factory=lambda **_kwargs: object(), app_factory=lambda *args: args, @@ -75,7 +73,7 @@ def __init__(self, _projection: Any, _orchestration: Any, _policy: Any, work_poo def test_a_retired_host_name_is_ignored_by_the_server(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("INFRAHUB_SYNC_MANAGED_HOST", "10.0.0.1") - monkeypatch.setattr(serve, "build_app", lambda: object()) + monkeypatch.setattr(serve, "build_app", object) captured: dict[str, Any] = {} monkeypatch.setattr(serve.uvicorn, "run", lambda app, **kwargs: captured.update(app=app, **kwargs)) diff --git a/tests/test_service_storage_docs.py b/tests/test_service_storage_docs.py index a32cd5aa..a1d66e5b 100644 --- a/tests/test_service_storage_docs.py +++ b/tests/test_service_storage_docs.py @@ -5,7 +5,7 @@ import pytest REFERENCE_ROOT = Path(__file__).resolve().parents[1] / "docs" / "docs" / "reference" -MANAGED_STORAGE_SETTINGS = frozenset( +SERVICE_STORAGE_SETTINGS = frozenset( { "INFRAHUB_SYNC_DATABASE_URL", "INFRAHUB_SYNC_S3_BUCKET", @@ -21,13 +21,12 @@ def test_managed_storage_operator_references_state_the_complete_deployed_contrac """Every service-storage reference names one PostgreSQL/S3 deployment shape.""" text = (REFERENCE_ROOT / name).read_text(encoding="utf-8") - assert not {setting for setting in MANAGED_STORAGE_SETTINGS if f"`{setting}`" not in text} + assert not {setting for setting in SERVICE_STORAGE_SETTINGS if f"`{setting}`" not in text} assert "standard credential-provider chain" in text assert "absolute `http` or `https` URL with no userinfo" in text assert "reaches Boto3 unchanged" in text assert "`INFRAHUB_SYNC_CACHE_DIR`" in text assert "PH-2" in text - assert "INFRAHUB_SYNC_MANAGED_CACHE_LOCATION" not in text assert not [claim for claim in ("backup", "restore", "production hardening") if claim in text.lower()] From 423c887909332e24db8db34b51f8926dadc4c3e2 Mon Sep 17 00:00:00 2001 From: Blake Ellis Date: Tue, 1 Sep 2026 15:08:41 -0400 Subject: [PATCH 07/12] Move docs, changelog, and guidance to the service vocabulary Seat: lead-developer Assignment: single-product-reconciliation Co-Authored-By: Claude Opus 5 --- AGENTS.md | 41 ++++++++++--------- README.md | 4 +- ...aged-cancellation-acknowledgement.fixed.md | 1 - ...vice-cancellation-acknowledgement.fixed.md | 1 + ...ded.md => +service-sync-http-api.added.md} | 4 +- .../+standalone-product-projection.added.md | 2 - dev/constitution.md | 16 ++++---- dev/guides/adding-an-adapter.md | 16 ++++---- dev/knowledge/orchestration-prefect.md | 10 ++--- dev/knowledge/quality-gates.md | 20 ++++----- dev/knowledge/sync-architecture.md | 5 ++- development/README.md | 12 +++--- development/docker-compose.preview.yml | 4 +- development/preview.env | 8 ++-- docs/docs/contributing.mdx | 2 +- docs/docs/orchestration.mdx | 6 +-- docs/docs/readme.mdx | 6 +-- .../reference/durable-product-records.mdx | 26 ++++++------ docs/docs/reference/prefect-remote-run.mdx | 4 +- ...managed-http-api.mdx => sync-http-api.mdx} | 38 ++++++++--------- .../tutorials/netbox-demo-to-infrahub.mdx | 10 ++--- docs/sidebars.ts | 2 +- infrahub_sync/service/models.py | 2 +- tasks/preview.py | 2 +- tests/cli/test_parity_and_closure.py | 2 +- tests/test_service_storage_docs.py | 6 +-- 26 files changed, 125 insertions(+), 125 deletions(-) delete mode 100644 changelog/+managed-cancellation-acknowledgement.fixed.md create mode 100644 changelog/+service-cancellation-acknowledgement.fixed.md rename changelog/{+managed-sync-http-api.added.md => +service-sync-http-api.added.md} (79%) delete mode 100644 changelog/+standalone-product-projection.added.md rename docs/docs/reference/{managed-http-api.mdx => sync-http-api.mdx} (94%) diff --git a/AGENTS.md b/AGENTS.md index 6dba0545..3311f348 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ ## Agent Operating Principles 1. **Plan → Ask → Act → Verify → Record** — plan briefly, ask for missing context, act with the smallest change, verify locally, then record with a concise commit or PR note. -2. **Default to read-only and dry runs** — prefer `list`, `diff`, and `generate` before `sync`. Write/apply only with explicit instruction and human approval. +2. **Default to read-only and dry runs** — prefer `configs` inspection, `runs plan`, and `diff` before `sync`. Write/apply only with explicit instruction and human approval. 3. **Be specific and reversible** — small, scoped commits. Don't mix large refactors with behavior changes in one PR. 4. **Match existing patterns** — keep CLI, adapters, examples, and directory structure consistent with the codebase. 5. **Idempotency and safety** — favor operations safe to re-run. Never print or guess secrets. Handle timeouts, auth, and network errors explicitly. @@ -21,7 +21,7 @@ Use Python 3.11–3.13 for the full development profile. The former private uv sync --extra dev --extra prefect --extra service ``` -On Python 3.10, install the direct Prefect profile instead. Managed Sync supports Python +On Python 3.10, install the direct Prefect profile instead. Sync supports Python 3.11–3.13 only: ```bash @@ -47,25 +47,25 @@ documented in [`dev/knowledge/quality-gates.md`](dev/knowledge/quality-gates.md) The `prefect` extra is not optional for development: without it `ty` cannot resolve `infrahub_sync/orchestration/`'s imports and `tests/orchestration/test_flow.py` skips -itself whole. On Python 3.11–3.13, the `managed` extra is required too; otherwise `ty` -cannot resolve the managed service's FastAPI and Prefect imports. On Python 3.10, -`invoke linter.lint-ty` and `invoke linter.lint-pylint` exclude `infrahub_sync/managed` -(ty also excludes `tests/managed`), matching CI's direct Prefect gate. CI also runs a base-install job that keeps the Prefect-free +itself whole. On Python 3.11–3.13, the `service` extra is required too; otherwise `ty` +cannot resolve the Sync service's FastAPI and Prefect imports. On Python 3.10, +`invoke linter.lint-ty` and `invoke linter.lint-pylint` exclude `infrahub_sync/service` +(ty also excludes `tests/service`), matching CI's direct Prefect gate. CI also runs a base-install job that keeps the Prefect-free guarantee honest. **CLI sanity after changes:** ```bash uv run infrahub-sync --help -uv run infrahub-sync list --directory examples/ -uv run infrahub-sync generate --name from-netbox --directory examples/ +uv run infrahub-sync configs --help +uv run infrahub-sync runs --help ``` -The `from-netbox` generation check is integration-backed. Before running it, +The `from-netbox` example check is integration-backed. Before running it, follow the [NetBox demo tutorial](docs/docs/tutorials/netbox-demo-to-infrahub.mdx) -through **Generate the sync code**. That setup uses a fresh Infrahub instance, -loads the matching schema library, creates a current `nbt_...` NetBox demo -token, and installs `pynetbox`. The public demo data changes over time; the +through **Register the configuration package**. That setup uses a fresh Infrahub +instance, loads the matching schema library, creates a current `nbt_...` NetBox +demo token, and installs `pynetbox`. The public demo data changes over time; the bounded live acceptance test records its current data preconditions in `tests/integration/test_saved_plan_apply_integration.py`. @@ -79,7 +79,7 @@ uv run invoke docs.docusaurus **Policy:** - New or changed code is Ruff-clean and typed where touched (docstrings, specific exceptions). -- The codebase is clean under ty with no `[[tool.ty.overrides]]` blocks in `pyproject.toml`. Don't reintroduce overrides to mask type errors — fix the underlying issue, or use a targeted `# ty: ignore[]` with a short TODO at the call site. Run `uv run ty check .` in the full Python 3.11–3.13 profile (the frozen vendored upstream tests are excluded via `[tool.ty.src]` in `pyproject.toml`; the vendored package itself stays checked). On Python 3.10, run `uv run ty check --exclude infrahub_sync/managed --exclude tests/managed .`. +- The codebase is clean under ty with no `[[tool.ty.overrides]]` blocks in `pyproject.toml`. Don't reintroduce overrides to mask type errors — fix the underlying issue, or use a targeted `# ty: ignore[]` with a short TODO at the call site. Run `uv run ty check .` in the full Python 3.11–3.13 profile (the frozen vendored upstream tests are excluded via `[tool.ty.src]` in `pyproject.toml`; the vendored package itself stays checked). On Python 3.10, run `uv run ty check --exclude infrahub_sync/service --exclude tests/service .`. - If you add tests, run `uv run pytest -q`. ## Repository Structure @@ -104,10 +104,11 @@ Available adapters (`infrahub_sync/adapters/`): `infrahub`, `netbox`, `nautobot` ## CLI Commands -- `infrahub-sync list` — show available sync projects (safe). -- `infrahub-sync diff` — compute differences (safe). -- `infrahub-sync generate` — generate Python from YAML config (servers required). -- `infrahub-sync sync` — perform synchronization (servers and approval required). +- `infrahub-sync configs` — register and inspect configuration packages (safe). +- `infrahub-sync runs plan` — review a saved plan (safe). +- `infrahub-sync diff` — create a plan run and review its summary (Sync API required). +- `infrahub-sync sync` — perform synchronization (Sync API and approval required). +- `infrahub-sync apply` — apply a reviewed plan by checksum (Sync API and approval required). ## Configuration and Examples @@ -176,7 +177,7 @@ uv run rumdl fmt . # fix ## Known Issues and Limitations - Optional dependencies (e.g. `pynetbox`, `pynautobot`) may be missing, producing import warnings. -- `generate` and `sync` require running servers (Infrahub, NetBox, Nautobot). +- `diff`, `sync`, and `apply` require a running Sync API and its destination servers. - Docs npm audit may flag dev-only vulnerabilities; they do not affect the Python package. ## Git and PR Process @@ -192,7 +193,7 @@ uv run rumdl fmt . # fix - [ ] Format and lint clean on changed areas. - [ ] The type-check command for the active Python profile exits 0; new code typed. -- [ ] CLI behaviors validated (`--help`, `list`, targeted `generate`). +- [ ] CLI behaviors validated (`--help`, `configs --help`, `runs --help`). - [ ] Docs updated if flags or config changed. - [ ] Error handling uses specific exception types and clear messages. @@ -223,7 +224,7 @@ step-by-step procedure. Supporting developer reference lives under `dev/`: - [Adapter guidelines](dev/guidelines/README.md) — the rules for writing and testing an adapter. - [Adapter guides](dev/guides/README.md) — adding and testing an adapter, step by step. -Core rule unchanged: provide read-only `list` / `diff` pathways and validate them before enabling `sync`. +Core rule unchanged: provide a read-only `diff` pathway and validate it before enabling `sync`. ## Beyond Adapters diff --git a/README.md b/README.md index 07859fc0..4d799c5d 100644 --- a/README.md +++ b/README.md @@ -136,11 +136,11 @@ may change. | CLI | Configuration registration, plan review, run admission, and reviewed-plan apply | Base installation and Sync API access | | Python client | Typed access to every shipped Sync API resource | Base installation and Sync API access | | Direct Prefect deployment | Starting and observing one plan or confirmed sync through Prefect's API | `prefect` extra and a Prefect server | -| Managed Sync HTTP API | Authenticated remote runs, durable records and artifacts, reviewed apply, idempotency, and cancellation | `managed` extra, Prefect, a work pool, a worker, and shared durable storage | +| Sync HTTP API | Authenticated remote runs, durable records and artifacts, reviewed apply, idempotency, and cancellation | `service` extra, Prefect, a work pool, a worker, and shared durable storage | See the [Python API](https://docs.infrahub.app/sync/reference/python-api), [Prefect remote run](https://docs.infrahub.app/sync/reference/prefect-remote-run), and -[managed HTTP API](https://docs.infrahub.app/sync/reference/managed-http-api) references +[Sync HTTP API](https://docs.infrahub.app/sync/reference/sync-http-api) references for their contracts and setup. For a bounded checkout-based live review, follow the [`custom-example` plan and apply guide](examples/custom_adapter/README.md). Its source fixture is deterministic; the review still uses a live, writable Infrahub destination. diff --git a/changelog/+managed-cancellation-acknowledgement.fixed.md b/changelog/+managed-cancellation-acknowledgement.fixed.md deleted file mode 100644 index 5324a338..00000000 --- a/changelog/+managed-cancellation-acknowledgement.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Fixed managed cancellation requests being reported as accepted when Prefect aborted, deferred, or returned an invalid state transition result. diff --git a/changelog/+service-cancellation-acknowledgement.fixed.md b/changelog/+service-cancellation-acknowledgement.fixed.md new file mode 100644 index 00000000..9dd25c20 --- /dev/null +++ b/changelog/+service-cancellation-acknowledgement.fixed.md @@ -0,0 +1 @@ +Fixed Sync API cancellation requests being reported as accepted when Prefect aborted, deferred, or returned an invalid state transition result. diff --git a/changelog/+managed-sync-http-api.added.md b/changelog/+service-sync-http-api.added.md similarity index 79% rename from changelog/+managed-sync-http-api.added.md rename to changelog/+service-sync-http-api.added.md index cb1833b1..b101e50d 100644 --- a/changelog/+managed-sync-http-api.added.md +++ b/changelog/+service-sync-http-api.added.md @@ -1,7 +1,7 @@ -Added an authenticated managed Sync HTTP API for creating and inspecting durable runs, +Added an authenticated Sync HTTP API for creating and inspecting durable runs, reviewing retained plans, running read-only verification, applying an exact approved checksum, retrieving results and artifacts, and requesting cancellation through Prefect. Actor-scoped durable mutation receipts make exact retries converge on one Sync run and -Prefect flow run without storing the raw client key. Install the `managed` extra to run the +Prefect flow run without storing the raw client key. Install the `service` extra to run the separate Prefect deployment and API; the base CLI and existing four-parameter direct Prefect deployment remain unchanged. diff --git a/changelog/+standalone-product-projection.added.md b/changelog/+standalone-product-projection.added.md deleted file mode 100644 index 6adbcf99..00000000 --- a/changelog/+standalone-product-projection.added.md +++ /dev/null @@ -1,2 +0,0 @@ -Add opt-in durable ProductRun and plan-review artifact publication for standalone CLI and -public Python plan, verify, reviewed apply, and confirmed sync operations. diff --git a/dev/constitution.md b/dev/constitution.md index 06bde77e..af167e5d 100644 --- a/dev/constitution.md +++ b/dev/constitution.md @@ -28,8 +28,8 @@ principles below put safety, reproducibility, and connector consistency ahead of The non-mutating path is the default path, and applying changes is always a deliberate act. -- `list`, `diff`, and `generate` are non-applying and MUST stay safe to run at any time, - against any environment, without approval. +- `configs` inspection, `runs plan`, and `diff` are non-applying and MUST stay safe to run + at any time, against any environment, without approval. - `sync` mutates a destination system and MUST require explicit user instruction, confirmed target servers, and human approval. It MUST NOT run as an implicit side effect of another command. @@ -62,11 +62,11 @@ Adapters are the primary extension point; every connector MUST honor the same co - A new adapter MUST live in `infrahub_sync/adapters/.py` and follow the existing adapter patterns rather than inventing new structure. -- It MUST provide `list` and `diff` pathways before `sync` is enabled. +- It MUST provide a `diff` pathway before `sync` is enabled. - It MUST ship a connection config schema and a sanitized example under `examples/`. - It MUST document required environment variables and expected error cases, and add a page under `docs/docs/adapters/`. -- `list` / `diff` / `generate` / `sync` MUST flow through the core sync engine (`potenda`); +- `diff` / `sync` / `apply` MUST flow through the core sync engine (`potenda`); no ad-hoc per-adapter sync logic that bypasses it. **Rationale:** Consistent adapters keep the CLI predictable, make each new connector @@ -124,8 +124,8 @@ Prefer the simplest solution that works and matches the patterns already in the - YAGNI: build what the task needs, not speculative abstraction. A new abstraction needs at least two real callers. - New dependencies MUST be justified. -- Generated code (the Python that `generate` produces from YAML configs) MUST be regenerated - from its YAML source, never hand-edited. +- Generated code (the Python the internal generator produces from YAML configs) MUST be + regenerated from its YAML source, never hand-edited. - Keep commits small and scoped; do not mix large refactors with behavior changes. **Rationale:** A connector library accretes complexity quickly. Keeping each change small, @@ -167,8 +167,8 @@ After changes, verify the CLI still behaves: ```bash uv run infrahub-sync --help -uv run infrahub-sync list --directory examples/ -uv run infrahub-sync generate --name from-netbox --directory examples/ +uv run infrahub-sync configs --help +uv run infrahub-sync runs --help ``` ### Logging diff --git a/dev/guides/adding-an-adapter.md b/dev/guides/adding-an-adapter.md index 2ebf18d5..4cb3a13c 100644 --- a/dev/guides/adding-an-adapter.md +++ b/dev/guides/adding-an-adapter.md @@ -133,8 +133,8 @@ for this destination, or apply against a destination whose adapter implements th planned-write surface. ``` -Nothing else about the adapter degrades: `list`, `diff`, `sync` and plan *review* -(`diff --from-plan `) all work unchanged. Only `apply` is unavailable. `infrahub` is +Nothing else about the adapter degrades: `diff`, `sync` and plan *review* +(`runs plan RUN_ID`) all work unchanged. Only `apply` is unavailable. `infrahub` is the only one of the nine adapters shipped in this repository that implements the surface today; the other eight refuse an `apply` exactly as described above. @@ -243,13 +243,13 @@ Validate read-only paths before ever running `sync`: uv run invoke format uv run invoke lint -uv run infrahub-sync list --directory examples/ -uv run infrahub-sync generate --name mysystem-example --directory examples/mysystem_to_infrahub/ -uv run infrahub-sync diff --name mysystem-example --directory examples/mysystem_to_infrahub/ +uv run infrahub-sync configs register --file examples/mysystem_to_infrahub/config.yml --reason "add mysystem example" +uv run infrahub-sync configs validate --config-id mysystem-example --version 1 +uv run infrahub-sync diff --config-id mysystem-example --version 1 --reason "verify the new adapter" ``` -`list` and `generate` need no live source; `diff` reads both sides but writes nothing. Run -`sync` only with explicit approval against a known-safe target. +`configs register` and `configs validate` read no source; `diff` plans against both sides but +writes nothing. Run `sync` only with explicit approval against a known-safe target. ## Quality checklist @@ -258,7 +258,7 @@ uv run infrahub-sync diff --name mysystem-example --directory examples/mysystem_ - [ ] Decided whether the adapter implements the planned-write surface — **both** `new_peer_resolver` and `apply_planned_operation`; if it does not, confirmed that `apply` refuses cleanly and that `sync` is the documented path for it. - [ ] Optional SDK imported with `# ty: ignore[unresolved-import]`; credentials from env vars; no secrets logged or committed. - [ ] `uv run invoke format` and `uv run invoke lint` are clean; `uv run ty check .` exits 0. -- [ ] `list` / `generate` / `diff` succeed for the example. +- [ ] `configs register` / `configs validate` / `diff` succeed for the example. - [ ] Unit tests added under `tests/adapters/`; `uv run pytest -q` passes offline. - [ ] Example added under `examples/`; env vars documented. - [ ] Documentation page added under `docs/docs/adapters/` and in the sidebar. diff --git a/dev/knowledge/orchestration-prefect.md b/dev/knowledge/orchestration-prefect.md index 8583f2d7..a58c855a 100644 --- a/dev/knowledge/orchestration-prefect.md +++ b/dev/knowledge/orchestration-prefect.md @@ -13,10 +13,10 @@ by the optional `prefect` extra, and nothing in the base package imports it — The flow calls [the shared execution surface](execution-surface.md) in-process. It never spawns the CLI. -`infrahub_sync/managed/` is a separate, optional operational profile. Its managed flow +`infrahub_sync/service/` is a separate, optional operational profile. Its service flow consumes the API-created product run ID and delegates deployment catalogue validation, deployment convergence, and native submission idempotency to OpsMill Prefect Extras pinned -at commit `97465e75137f6121d0377cd637383cfb3530d734`. The managed HTTP service owns the +at commit `97465e75137f6121d0377cd637383cfb3530d734`. The Sync HTTP service owns the public contract; Prefect remains authoritative for live execution, logs, retries, workers, and cancellation. @@ -36,13 +36,13 @@ Exactly those four parameters. None of them accepts a path, a CLI fragment, a cr an environment override. Everything else the run needs comes from the serving process's own environment. -The separate `infrahub-sync-managed/run` deployment accepts exactly seven parameters: +The separate `infrahub-sync-service/run` deployment accepts exactly seven parameters: `run_id`, `sync_name`, `stage`, `configuration_reference`, `branch`, `expected_checksum`, and `confirm_writes`. It does not replace or extend the four-parameter direct Prefect flow. Credentials, endpoints, adapter instances, product-cache locations, and saved-plan -cache locations stay in the managed worker environment. +cache locations stay in the service worker environment. -When the managed flow runs outside Prefect context in offline executor tests, +When the service flow runs outside Prefect context in offline executor tests, `get_run_logger()` raises `MissingContextError`. The flow catches only that exception and uses its module logger. It does not construct `RunLoggerBridge` in the fallback path. diff --git a/dev/knowledge/quality-gates.md b/dev/knowledge/quality-gates.md index 15bcc63e..7eb73705 100644 --- a/dev/knowledge/quality-gates.md +++ b/dev/knowledge/quality-gates.md @@ -72,7 +72,7 @@ at commit `697b2f4`, using Python 3.13.3, Pylint 4.0.5, and an environment synce The Invoke task reads Pylint's JSON report and makes this inherited set an executable no-regression gate. A new diagnostic code or a count above the table's maximum fails; fewer findings pass, so an improvement never blocks the gate. On Python 3.10 the task -excludes `infrahub_sync/managed`, mirroring the ty exclusion: the managed tree imports +excludes `infrahub_sync/service`, mirroring the ty exclusion: the service tree imports optional dependencies that only install on Python 3.11+, and analysing it without them would add import-error diagnostics rather than remove findings. @@ -100,15 +100,15 @@ Two mistakes cost real time on this repository, both worth avoiding by rule: has repeated basenames across adapter directories (`infrahub/sync_adapter.py`, `netbox/sync_adapter.py`), and flattening makes the collision look like a diff. -## `infrahub-sync generate` rewrites generated files +## Regenerating an example rewrites committed files -`infrahub-sync generate --name from-netbox --directory examples/` is prescribed as a CLI -sanity check, and it **rewrites committed files**. The generator sorts schema nodes, -attributes, and relationships before rendering, so API response order does not affect the -output. Generation can still update files when the live schema differs from the schema used -for the committed example. +The internal `render_adapter` helper is the only way to regenerate a committed example, and +it **rewrites committed files**. The generator sorts schema nodes, attributes, and +relationships before rendering, so API response order does not affect the output. +Regeneration can still update files when the live schema differs from the schema used for +the committed example. -Review the diff after a live generation check. Preserve intentional schema-driven changes; +Review the diff after a live regeneration. Preserve intentional schema-driven changes; restore incidental generated-file changes before committing unrelated work. ## CI @@ -122,10 +122,10 @@ required. Python 3.10 linting uses `--extra dev --extra prefect` and runs: ```bash -uv run ty check --exclude infrahub_sync/managed --exclude tests/managed . +uv run ty check --exclude infrahub_sync/service --exclude tests/service . ``` -Managed Sync supports Python 3.11–3.13 only, so this exclusion is the supported direct +Sync supports Python 3.11–3.13 only, so this exclusion is the supported direct Prefect profile rather than a reduced full-service check. `invoke linter.lint-ty` selects the same command from the active Python version. diff --git a/dev/knowledge/sync-architecture.md b/dev/knowledge/sync-architecture.md index ea7bf4b7..bb5c4ed5 100644 --- a/dev/knowledge/sync-architecture.md +++ b/dev/knowledge/sync-architecture.md @@ -72,8 +72,9 @@ Adapters do not hand-write a model class per object type. Instead: 1. You write the adapter module (the connector logic) and a `config.yml` whose `schema_mapping` describes which source resources map to which destination models. -2. `infrahub-sync generate` (in `infrahub_sync/generator/`) reads the config and the - destination schema and renders DiffSync model classes from Jinja2 templates. +2. The internal generator (`infrahub_sync/generator/`, reached through + `infrahub_sync.utils.render_adapter`) reads the config and the destination schema and + renders DiffSync model classes from Jinja2 templates. 3. `infrahub_sync/plugin_loader.py` resolves the adapter class — built-in by `name`, a dotted import path, a filesystem path, or an installed entry point — and wires the generated models onto the adapter instance at run time. diff --git a/development/README.md b/development/README.md index 12722b58..201303d3 100644 --- a/development/README.md +++ b/development/README.md @@ -1,8 +1,8 @@ # Preview environment One command from a fresh clone to a complete, testable Infrahub Sync v3 stack: -a disposable Infrahub instance, a dedicated Prefect server, the managed Sync -HTTP API, a Prefect worker running the managed deployment, a loaded example +a disposable Infrahub instance, a dedicated Prefect server, the Sync +HTTP API, a Prefect worker running the service deployment, a loaded example schema, and a first saved plan — finished with an automatic smoke run across every preview surface so you never start from a broken environment. @@ -22,7 +22,7 @@ uv sync --extra dev --extra prefect --extra service uv run invoke preview.up ``` -The final summary prints the Infrahub UI, Prefect UI, and managed Sync API +The final summary prints the Infrahub UI, Prefect UI, and Sync API addresses, the bearer principals, and where runtime state lives. Requires Docker and Python 3.11+. @@ -34,9 +34,9 @@ all data). The preview exists to gather feedback on the two new v3 interfaces: -- **Managed HTTP API** — the primary focus. Consume the native endpoints - ([reference](../docs/docs/reference/managed-http-api.mdx)) and drive - executions through Prefect directly (deployment `infrahub-sync-managed/run`, +- **Sync HTTP API** — the primary focus. Consume the native endpoints + ([reference](../docs/docs/reference/sync-http-api.mdx)) and drive + executions through Prefect directly (deployment `infrahub-sync-service/run`, Prefect UI address in the summary). - **Python API** — the documented plan → verify → apply cycle ([reference](../docs/docs/reference/python-api.mdx)). diff --git a/development/docker-compose.preview.yml b/development/docker-compose.preview.yml index 1eb0b010..28e1bbae 100644 --- a/development/docker-compose.preview.yml +++ b/development/docker-compose.preview.yml @@ -6,9 +6,9 @@ # # This file remaps host ports so the preview never collides with another local # Infrahub stack, drops host port publications the preview does not need, and -# adds a dedicated Prefect server for Infrahub Sync's managed execution path — +# adds a dedicated Prefect server for Infrahub Sync's service execution path — # separate from Infrahub's internal task-manager, and pinned to the same Prefect -# version as the `managed` extra (prefect==3.8.1). +# version as the `service` extra (prefect==3.8.1). services: sync-postgres: image: "postgres:16-alpine" diff --git a/development/preview.env b/development/preview.env index ed50bc85..792054fa 100644 --- a/development/preview.env +++ b/development/preview.env @@ -33,20 +33,20 @@ PREVIEW_PREFECT_PORT=4210 PREVIEW_SYNC_API_PORT=8010 # Prefect image for the dedicated sync-prefect service. Keep in lockstep with -# the prefect pin in pyproject.toml's managed extra. +# the prefect pin in pyproject.toml's service extra. PREVIEW_PREFECT_IMAGE_TAG=3.8.1-python3.12 # Public development-default admin token seeded by the official compose file. INFRAHUB_INITIAL_ADMIN_TOKEN=06438eb2-8019-4776-878c-0941b1f1d1ec -# Managed-API principals for the preview (JSON, one line). Local-only values; +# Sync API principals for the preview (JSON, one line). Local-only values; # testers minting their own tokens should override in preview.local.env. PREVIEW_BEARER_TOKENS={"tester@local": {"token": "preview-tester-token-0001", "administrator": true}} -# Prefect work pool the managed deployment targets. +# Prefect work pool the service deployment targets. PREVIEW_WORK_POOL=sync-process-pool -# Managed liveness policy. The API reconciler and the Prefect worker receive +# Service liveness policy. The API reconciler and the Prefect worker receive # the same query interval; the code derives stall, freshness, and reconciliation # cadence from it. The admission TTL bounds a flow that no worker claims. PREVIEW_RUN_ADMISSION_TTL_SECONDS=300 diff --git a/docs/docs/contributing.mdx b/docs/docs/contributing.mdx index 64356051..b8d11394 100644 --- a/docs/docs/contributing.mdx +++ b/docs/docs/contributing.mdx @@ -120,7 +120,7 @@ npx markdownlint-cli --fix "docs/docs/**/*.{md,mdx}" 1. Create `infrahub_sync/adapters/.py` following existing adapter patterns 2. Add connection configuration schema and an example under `examples/` -3. Provide `list` and `diff` pathways before enabling `sync` +3. Provide a `diff` pathway before enabling `sync` 4. Document required environment variables and expected error cases 5. Create a documentation page in `docs/docs/adapters/` 6. Add the adapter to the sidebar in `docs/sidebars.ts` diff --git a/docs/docs/orchestration.mdx b/docs/docs/orchestration.mdx index 077ab416..2c5e62b4 100644 --- a/docs/docs/orchestration.mdx +++ b/docs/docs/orchestration.mdx @@ -42,10 +42,10 @@ Infrahub Sync provides two Prefect-backed execution surfaces: requests through Prefect's API. Sync output is forwarded into the flow-run log. See the [Prefect remote run reference](./reference/prefect-remote-run.mdx) and `examples/prefect_remote_run/README.md` in the repository. -- Install the `managed` extra when an automation client needs a Sync-owned authenticated +- Install the `service` extra when an automation client needs a Sync-owned authenticated HTTP API, durable records and artifacts, reviewed-plan apply, idempotency, cancellation, and worker execution. See the - [managed Sync HTTP API reference](./reference/managed-http-api.mdx). + [Sync HTTP API reference](./reference/sync-http-api.mdx). **When this works well:** @@ -57,7 +57,7 @@ Infrahub Sync provides two Prefect-backed execution surfaces: operational surface area. The direct deployment runs and observes one plan or confirmed sync; it does not add scheduling, retry, queue, or overlap policy. -Use the managed API for the supported worker-backed run lifecycle. For schedules, retry +Use the Sync API for the supported worker-backed run lifecycle. For schedules, retry policies, triggers, or composing several syncs into a larger workflow, configure Prefect around either packaged surface or wrap the CLI in a flow you own. diff --git a/docs/docs/readme.mdx b/docs/docs/readme.mdx index 756ca78d..87319ca2 100644 --- a/docs/docs/readme.mdx +++ b/docs/docs/readme.mdx @@ -71,11 +71,11 @@ Three `diffsync_flags` (`SKIP_UNMATCHED_DST` by default, `SKIP_UNMATCHED_SRC`, ` | CLI | Configuration and run operations through the Sync API | Base installation and Sync API access | | Python client | Typed access to every shipped Sync API resource | Base installation and Sync API access | | Direct Prefect deployment | Starting and observing one plan or confirmed sync through Prefect's API | `prefect` extra and a Prefect server | -| Managed Sync HTTP API | Authenticated remote runs, durable records and artifacts, reviewed apply, idempotency, and cancellation | `managed` extra, Prefect, a work pool, a worker, and shared durable storage | +| Sync HTTP API | Authenticated remote runs, durable records and artifacts, reviewed apply, idempotency, and cancellation | `service` extra, Prefect, a work pool, a worker, and shared durable storage | Use the [Python API](./reference/python-api.mdx), [Prefect remote run](./reference/prefect-remote-run.mdx), or -[managed HTTP API](./reference/managed-http-api.mdx) reference for setup and contracts. +[Sync HTTP API](./reference/sync-http-api.mdx) reference for setup and contracts. ## Who it's for @@ -167,7 +167,7 @@ Yes. Each sync project is independent — a separate directory, configuration, a | Run a sync | [Run a sync](./running-a-sync.mdx) | | Use the Python API | [Python API](./reference/python-api.mdx) | | Run through Prefect | [Prefect remote run](./reference/prefect-remote-run.mdx) | -| Operate the managed HTTP API | [Managed Sync HTTP API](./reference/managed-http-api.mdx) | +| Operate the Sync HTTP API | [Sync HTTP API](./reference/sync-http-api.mdx) | | CLI reference | [Sync CLI](./reference/cli.mdx) | | All adapters | See the **Adapters** section in the sidebar | | Custom CA certificates | [Use custom CA certificates](./custom-certificates.mdx) | diff --git a/docs/docs/reference/durable-product-records.mdx b/docs/docs/reference/durable-product-records.mdx index f0845f57..39aab882 100644 --- a/docs/docs/reference/durable-product-records.mdx +++ b/docs/docs/reference/durable-product-records.mdx @@ -8,12 +8,12 @@ artifacts. It is independent of Prefect: a product run can retain any number of purpose-labelled execution links after Prefect no longer has the corresponding flow-run detail. -The managed Sync HTTP API and its worker store records in PostgreSQL and immutable +The Sync HTTP API and its worker store records in PostgreSQL and immutable artifacts in S3-compatible object storage. Each process creates its own clients from the same environment settings. CLI and public Python callers access these records only through the Sync API. -Managed execution continues to use `INFRAHUB_SYNC_CACHE_DIR` as the absolute shared cache +Service execution continues to use `INFRAHUB_SYNC_CACHE_DIR` as the absolute shared cache root for saved plans. This PH-2 seam is separate from product records and artifacts. This is minimum product-projection configuration. It does not select a new provider type or @@ -110,9 +110,9 @@ explicitly unqualified optional features, and nothing else. ## Storage profiles -### Managed deployment +### Service deployment -The managed API and worker construct the PostgreSQL/S3 profile from these settings: +The Sync API and worker construct the PostgreSQL/S3 profile from these settings: | Variable | Requirement | | --- | --- | @@ -126,14 +126,14 @@ S3 credentials use Boto3's standard credential-provider chain. Configure credent the API, worker, CLI, or smoke-process environment that constructs the client. Sync does not define access-key or secret-key settings. -Constructing a managed process initializes the PostgreSQL schema. The PostgreSQL role must +Constructing a service process initializes the PostgreSQL schema. The PostgreSQL role must have the DDL privileges required to create the product-record tables in its configured schema, plus permission to read and write their rows. ### Test injection -The local SQLite/filesystem projection is an injected standalone/test seam. It is not a -managed deployment option or a public CLI/Python execution mode. In-process service and +The local SQLite/filesystem projection is an injected test seam. It is not a +service deployment option or a public CLI/Python execution mode. In-process service and store tests can inject it directly: ```python @@ -162,7 +162,7 @@ records = production_product_projection( ) ``` -Psycopg and Boto3 are installed only by the managed extra. The record-store and +Psycopg and Boto3 are installed only by the service extra. The record-store and artifact-store protocols accepted by `ProductProjection` are internal implementation seams, not a public custom-provider compatibility contract. @@ -196,7 +196,7 @@ results, phase, and Prefect-link metadata. It rejects records that already have timestamp, outcome, or artifact reference; those completion fields must be added through the publication and finish operations so their integrity checks cannot be bypassed. -Managed mutations reserve a `MutationReceipt` unique by actor and SHA-256 digest of the +Sync API mutations reserve a `MutationReceipt` unique by actor and SHA-256 digest of the client idempotency key. A receipt binds the operation, target, request fingerprint, reason, Sync run, opaque Prefect key, state, and exact accepted response. The raw client key is not stored. Run creation commits its receipt and unfinished product run in one relational @@ -204,7 +204,7 @@ transaction. `AuditEvent` records secret-safe actor, reason, operation, and outc for accepted mutations and refusals. `record_results` updates retained result evidence without changing product phase, outcome, -or finish time. Managed verification uses this operation because verification is read-only +or finish time. Service verification uses this operation because verification is read-only for both the destination and product lifecycle. ## Artifact publication and lookup @@ -213,7 +213,7 @@ Artifact keys contain their SHA-256 digest and never change. Publication first r non-readable relational reference for the run-owned artifact identity. It then writes artifact data and its manifest, and finally marks that exact reference published in a second relational transaction. The injected local seam commits data and manifest using an -atomic directory rename. The managed S3-compatible profile copies staged data to its immutable key +atomic directory rename. The service S3-compatible profile copies staged data to its immutable key and uses a create-only manifest put as the object-store commit point. A crash before the final relational mark leaves durable pending evidence rather than exposing the artifact. The run cannot be finished successfully while any pending publication exists. It can be @@ -277,7 +277,7 @@ Observed on macOS with Python 3.13.3 on 2026-08-09: | VAL-8 88k | 88,117 / 87,868 | 23,015,700 B | 2,732 B | 40,960 B | 11 | 23,059,392 B | | Representative 10k | 10,051 / 10,023 | 2,722,700 B | 2,877 B | 40,960 B | 11 | 2,766,537 B | -The standalone/test fixture stores ten object files (data plus manifest) and one SQLite -run/reference/link database. Filesystem allocation, database page size, and managed +The test fixture stores ten object files (data plus manifest) and one SQLite +run/reference/link database. Filesystem allocation, database page size, and service object-store metadata can change physical billing; the payload and manifest byte counts are the portable sizing inputs. diff --git a/docs/docs/reference/prefect-remote-run.mdx b/docs/docs/reference/prefect-remote-run.mdx index 7a060f21..8fa0176f 100644 --- a/docs/docs/reference/prefect-remote-run.mdx +++ b/docs/docs/reference/prefect-remote-run.mdx @@ -182,9 +182,9 @@ originating logger name preserved. The direct deployment does not provide a Sync-owned HTTP API, remote reviewed-plan apply, per-stage tasks, work pools, workers, triggers, or an overlap policy. Saved-plan review and apply remain available through the CLI. Use the separate -[managed Sync HTTP API](./managed-http-api.mdx) when an automation client needs a stable +[Sync HTTP API](./sync-http-api.mdx) when an automation client needs a stable Sync-owned API, durable results and artifacts, reviewed-plan apply, actor authorization, -or managed worker execution. +or service worker execution. Concurrency guarantees for this direct deployment are limited to the per-configuration lock on one runner host. A second run of the same configuration waits for that lock and diff --git a/docs/docs/reference/managed-http-api.mdx b/docs/docs/reference/sync-http-api.mdx similarity index 94% rename from docs/docs/reference/managed-http-api.mdx rename to docs/docs/reference/sync-http-api.mdx index 7239e443..4f965d5f 100644 --- a/docs/docs/reference/managed-http-api.mdx +++ b/docs/docs/reference/sync-http-api.mdx @@ -1,21 +1,21 @@ --- -title: Managed Sync HTTP API +title: Sync HTTP API sidebar_position: 7 --- -The managed Sync HTTP API accepts authenticated run requests, records a stable Sync run +The Sync HTTP API accepts authenticated run requests, records a stable Sync run identity, and delegates execution to a separate Prefect deployment. Sync owns the HTTP, authorization, plan, result, artifact, audit, and idempotency contracts. Prefect owns live execution state, workers, retries, logs, and cancellation. The [direct Prefect remote run](./prefect-remote-run.mdx) remains a -separate four-parameter deployment. Use the managed API when a trusted automation client +separate four-parameter deployment. Use the Sync API when a trusted automation client needs reviewed-plan apply, durable results, artifact retrieval, or actor-scoped mutation control. -## Install the managed profile +## Install the service profile -The managed profile requires Python 3.11 or later. No published package carries this +The service profile requires Python 3.11 or later. No published package carries this profile yet, so install from a repository checkout on the API host and every worker that can run its deployment: @@ -23,7 +23,7 @@ that can run its deployment: uv sync --extra dev --extra prefect --extra service ``` -Once a release with the managed profile is published, `pip install +Once a release with the service profile is published, `pip install 'infrahub-sync[service]'` becomes the deployment path. The profile directly installs FastAPI, HTTPX, Uvicorn, Prefect 3.8.1, Psycopg, and Boto3. The OpsMill @@ -70,29 +70,29 @@ Any authenticated principal can create and inspect a run. Only the initiating ac administrator can verify, apply, or cancel it. Every accepted mutation and authorization refusal records secret-safe actor, reason, and outcome evidence. -## Deploy the managed flow +## Deploy the service flow -Set the work pool name and the absolute directory managed flow runs execute from — -relative paths in Sync configurations resolve against it — then apply the managed +Set the work pool name and the absolute directory service flow runs execute from — +relative paths in Sync configurations resolve against it — then apply the service deployment: ```bash export PREFECT_API_URL="http://127.0.0.1:4200/api" export INFRAHUB_SYNC_SERVICE_WORK_POOL="sync-process-pool" export INFRAHUB_SYNC_SERVICE_FLOW_WORKING_DIRECTORY="/path/to/checkout" -python -m infrahub_sync.managed.deploy +python -m infrahub_sync.service.deploy ``` -The command validates and applies `infrahub-sync-managed/run` through OpsMill Prefect +The command validates and applies `infrahub-sync-service/run` through OpsMill Prefect Extras, then records the declared working directory as the deployment's only pull step. It does not create a work pool or start a worker. -## Start managed workers +## Start service workers -Start each worker through the managed entry point. The work pool must already exist: +Start each worker through the service entry point. The work pool must already exist: ```bash -python -m infrahub_sync.managed.worker \ +python -m infrahub_sync.service.worker \ --pool "$INFRAHUB_SYNC_SERVICE_WORK_POOL" ``` @@ -105,13 +105,13 @@ flow-run child. The deployment stores no worker identity. A restarted process generates a new name and resolves its current server Worker UUID. It does not reuse an identity from the previous process. -The validated support boundary is one host running one managed worker process. Worker +The validated support boundary is one host running one service worker process. Worker identity is not what limits this: every supported invocation has a distinct name and resolves a distinct UUID. Plan and apply exchange saved plans through the `INFRAHUB_SYNC_CACHE_DIR` filesystem, so an apply must run where its plan was written. A -work pool with more than one managed worker, and scheduling across more than one host, +work pool with more than one service worker, and scheduling across more than one host, are outside the validated boundary. Use this entry point for every worker eligible to run -the managed deployment; manually named or standard Prefect process workers are outside the +the service deployment; manually named or standard Prefect process workers are outside the supported topology. The worker does not poll for runs until its heartbeat is complete and the pool returns @@ -136,7 +136,7 @@ Configure the worker environment before it starts: | `PREFECT_WORKER_QUERY_SECONDS` | Prefect worker polling interval. It must be a finite positive decimal no greater than 3600; the API derives its liveness thresholds from the same value. | | Adapter credential variables | Credentials required by the selected Sync configuration. Keep them in the worker environment or its secret provider. | -The managed flow accepts exactly eight bounded parameters: +The service flow accepts exactly eight bounded parameters: | Parameter | Type | Purpose | | --- | --- | --- | @@ -166,7 +166,7 @@ export INFRAHUB_SYNC_DATABASE_URL="postgresql://sync:replace-me@postgres/infrahu export INFRAHUB_SYNC_S3_BUCKET="infrahub-sync-artifacts" export INFRAHUB_SYNC_S3_PREFIX="infrahub-sync" export INFRAHUB_SYNC_SERVICE_HOST="127.0.0.1" -python -m infrahub_sync.managed.serve +python -m infrahub_sync.service.serve ``` :::warning First startup after upgrading a legacy product store diff --git a/docs/docs/tutorials/netbox-demo-to-infrahub.mdx b/docs/docs/tutorials/netbox-demo-to-infrahub.mdx index 52f6eacd..5c853cf2 100644 --- a/docs/docs/tutorials/netbox-demo-to-infrahub.mdx +++ b/docs/docs/tutorials/netbox-demo-to-infrahub.mdx @@ -85,8 +85,8 @@ You should see the Infrahub web interface with a navigation menu on the left sid ## Install infrahub-sync -1. Clone Infrahub Sync beside the automation project, then install the managed profile - from that checkout. The managed profile is not published to PyPI yet, so the checkout +1. Clone Infrahub Sync beside the automation project, then install the service profile + from that checkout. The service profile is not published to PyPI yet, so the checkout keeps the executable, this tutorial, and its example package at one revision: ```bash @@ -224,7 +224,7 @@ For a fuller explanation of this file, see [Create a sync project](../creating-a The CLI sends configuration and run requests to a Sync API. A Prefect worker performs the adapter work. This local tutorial starts both; a production deployment should follow the -[managed HTTP API reference](../reference/managed-http-api.mdx). +[Sync HTTP API reference](../reference/sync-http-api.mdx). Infrahub already listens on `127.0.0.1:8000`, so the commands below bind the Sync API to `127.0.0.2:8000`. @@ -305,12 +305,12 @@ export INFRAHUB_API_TOKEN="06438eb2-8019-4776-878c-0941b1f1d1ec" Use the complete NetBox token created earlier. The worker, not the CLI, reads the NetBox and Infrahub adapter credentials. -4. In terminal 2, create the process pool, deploy the managed flow, and start its worker: +4. In terminal 2, create the process pool, deploy the service flow, and start its worker: ```bash uv run prefect work-pool create --type process sync-process-pool uv run python -m infrahub_sync.service.deploy -uv run prefect worker start --pool sync-process-pool +uv run python -m infrahub_sync.service.worker --pool sync-process-pool ``` 5. In terminal 3, start the Sync API: diff --git a/docs/sidebars.ts b/docs/sidebars.ts index f694165f..2cafdef2 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -61,7 +61,7 @@ const sidebars: SidebarsConfig = { 'reference/incremental-extraction', 'reference/prefect-remote-run', 'reference/durable-product-records', - 'reference/managed-http-api', + 'reference/sync-http-api', ], }, { diff --git a/infrahub_sync/service/models.py b/infrahub_sync/service/models.py index 8c02dd26..aebb4220 100644 --- a/infrahub_sync/service/models.py +++ b/infrahub_sync/service/models.py @@ -75,7 +75,7 @@ def public_execution_link(link: PrefectExecutionLink) -> PublicExecutionLink: def public_run_resource(run: ProductRun) -> PublicRunResource: - """Project a store run into the standalone public wire resource.""" + """Project a store run into the self-contained public wire resource.""" return PublicRunResource.model_validate( { **run.model_dump(exclude={"prefect_executions"}), diff --git a/tasks/preview.py b/tasks/preview.py index 34c21c14..bb03dcd5 100644 --- a/tasks/preview.py +++ b/tasks/preview.py @@ -469,7 +469,7 @@ def up(context: Context) -> None: print(f" Sync API: {urls['sync_api']} (bearer principals: {', '.join(sorted(tokens))})") print(f" Config directory: {env['INFRAHUB_SYNC_CONFIG_DIRECTORY']}") print(f" Runtime state: {STATE_DIR}") - print(" Next: docs/docs/reference/managed-http-api.mdx and `uv run invoke preview.status`") + print(" Next: docs/docs/reference/sync-http-api.mdx and `uv run invoke preview.status`") def _run_smoke(context: Context, *, expect_main_empty: bool) -> None: diff --git a/tests/cli/test_parity_and_closure.py b/tests/cli/test_parity_and_closure.py index e2492ca8..49567c1c 100644 --- a/tests/cli/test_parity_and_closure.py +++ b/tests/cli/test_parity_and_closure.py @@ -254,8 +254,8 @@ def test_netbox_tutorial_starts_and_authenticates_the_service_boundary() -> None required = ( "infrahub-sync[service]", "prefect server start", - "prefect worker start", "infrahub_sync.service.deploy", + "infrahub_sync.service.worker --pool", "infrahub_sync.service.serve", "INFRAHUB_SYNC_SERVICE_BEARER_TOKENS", "INFRAHUB_SYNC_API_URL", diff --git a/tests/test_service_storage_docs.py b/tests/test_service_storage_docs.py index a1d66e5b..d7ea4a38 100644 --- a/tests/test_service_storage_docs.py +++ b/tests/test_service_storage_docs.py @@ -16,7 +16,7 @@ ) -@pytest.mark.parametrize("name", ["durable-product-records.mdx", "managed-http-api.mdx"]) +@pytest.mark.parametrize("name", ["durable-product-records.mdx", "sync-http-api.mdx"]) def test_managed_storage_operator_references_state_the_complete_deployed_contract(name: str) -> None: """Every service-storage reference names one PostgreSQL/S3 deployment shape.""" text = (REFERENCE_ROOT / name).read_text(encoding="utf-8") @@ -34,5 +34,5 @@ def test_durable_records_reference_limits_the_local_projection_to_the_injected_s """The local projection is not presented as a deployed service profile.""" text = (REFERENCE_ROOT / "durable-product-records.mdx").read_text(encoding="utf-8") - assert "injected standalone/test seam" in text - assert "managed Sync HTTP API and its worker use the local profile" not in text + assert "injected test seam" in text + assert "Sync HTTP API and its worker use the local profile" not in text From 2a1032627a9da983de63f74aede34231368e127e Mon Sep 17 00:00:00 2001 From: Blake Ellis Date: Tue, 1 Sep 2026 15:11:23 -0400 Subject: [PATCH 08/12] Rename the remaining legacy test and handler identifiers to service Seat: lead-developer Assignment: single-product-reconciliation Co-Authored-By: Claude Opus 5 --- infrahub_sync/service/app.py | 2 +- tests/cli/test_parity_and_closure.py | 2 +- tests/client/test_models.py | 2 +- .../test_service_storage_integration.py | 2 +- tests/preview/test_prefect_surface.py | 14 ++--- tests/preview/test_preview_configuration.py | 6 +- tests/preview/test_preview_worker_identity.py | 2 +- tests/preview/test_service_api.py | 2 +- tests/product_store/test_contract.py | 2 +- tests/service/test_config_routes.py | 2 +- tests/service/test_flow_and_prefect.py | 58 +++++++++---------- tests/service/test_http_api.py | 2 +- tests/service/test_storage.py | 12 ++-- tests/service/test_storage_import_boundary.py | 4 +- tests/test_linter_tasks.py | 8 +-- tests/test_service_storage_docs.py | 2 +- 16 files changed, 61 insertions(+), 61 deletions(-) diff --git a/infrahub_sync/service/app.py b/infrahub_sync/service/app.py index 9a7146b9..40760008 100644 --- a/infrahub_sync/service/app.py +++ b/infrahub_sync/service/app.py @@ -99,7 +99,7 @@ def idempotency_key(value: Annotated[str | None, Header(alias="Idempotency-Key") return value @application.exception_handler(ServiceAPIError) - async def managed_error(_request: Request, exc: ServiceAPIError) -> JSONResponse: # noqa: RUF029 + async def service_error(_request: Request, exc: ServiceAPIError) -> JSONResponse: # noqa: RUF029 envelope = ErrorEnvelope( error=ErrorDetail( code=exc.code, diff --git a/tests/cli/test_parity_and_closure.py b/tests/cli/test_parity_and_closure.py index 49567c1c..2abb214c 100644 --- a/tests/cli/test_parity_and_closure.py +++ b/tests/cli/test_parity_and_closure.py @@ -83,7 +83,7 @@ def test_root_help_closes_removed_commands_and_adds_resource_groups() -> None: assert "--api-token" not in help_text -def test_all_standalone_only_options_are_absent_from_live_command_help() -> None: +def test_all_retired_local_execution_options_are_absent_from_live_command_help() -> None: help_text = "\n".join( ( _help("diff"), diff --git a/tests/client/test_models.py b/tests/client/test_models.py index 7d031f05..3177f635 100644 --- a/tests/client/test_models.py +++ b/tests/client/test_models.py @@ -96,7 +96,7 @@ def test_orchestration_timestamps_require_a_timezone(field: str) -> None: OrchestrationSummary.model_validate(payload) -def test_server_projects_store_run_into_standalone_resource() -> None: +def test_server_projects_store_run_into_a_self_contained_resource() -> None: now = datetime.now(timezone.utc) stored = ProductRun( run_id="run-1", diff --git a/tests/integration/test_service_storage_integration.py b/tests/integration/test_service_storage_integration.py index e1f748bc..e935b8fd 100644 --- a/tests/integration/test_service_storage_integration.py +++ b/tests/integration/test_service_storage_integration.py @@ -37,7 +37,7 @@ def _settings_or_skip() -> dict[str, str]: } -def test_independent_managed_projections_share_configurations_runs_and_artifacts() -> None: +def test_independent_service_projections_share_configurations_runs_and_artifacts() -> None: """API-like and worker-like composition roots observe one PostgreSQL/S3 record set.""" settings = _settings_or_skip() api_projection = service_product_projection(environ=settings) diff --git a/tests/preview/test_prefect_surface.py b/tests/preview/test_prefect_surface.py index 8490b223..45cd2de2 100644 --- a/tests/preview/test_prefect_surface.py +++ b/tests/preview/test_prefect_surface.py @@ -11,7 +11,7 @@ pytestmark = pytest.mark.preview -def _managed_deployment(preview_env: dict[str, Any]) -> dict[str, Any]: +def _service_deployment(preview_env: dict[str, Any]) -> dict[str, Any]: response = httpx.get( f"{preview_env['urls']['prefect']}/api/deployments/name/infrahub-sync-service/run", timeout=15, @@ -20,20 +20,20 @@ def _managed_deployment(preview_env: dict[str, Any]) -> dict[str, Any]: return response.json() -def test_managed_deployment_is_applied(preview_env: dict[str, Any]) -> None: - deployment = _managed_deployment(preview_env) +def test_service_deployment_is_applied(preview_env: dict[str, Any]) -> None: + deployment = _service_deployment(preview_env) assert deployment["work_pool_name"] == preview_env["values"]["PREVIEW_WORK_POOL"] -def test_the_managed_deployment_carries_no_static_worker_identity(preview_env: dict[str, Any]) -> None: - job_variables = _managed_deployment(preview_env).get("job_variables") or {} +def test_the_service_deployment_carries_no_static_worker_identity(preview_env: dict[str, Any]) -> None: + job_variables = _service_deployment(preview_env).get("job_variables") or {} identity = (job_variables.get("env") or {}).get("PREFECT__WORKER_ID") assert identity is None, "worker identity must be injected per child by the executing worker" -def test_managed_flow_runs_execute_and_complete(preview_env: dict[str, Any]) -> None: +def test_service_flow_runs_execute_and_complete(preview_env: dict[str, Any]) -> None: """After the Sync API smoke, the service deployment must hold a completed run. Scoped to the service deployment's own flow runs: an unrelated run — a CLI-driven @@ -45,7 +45,7 @@ def test_managed_flow_runs_execute_and_complete(preview_env: dict[str, Any]) -> states a moment after the Sync record finishes, and an apply may legitimately still be running when this test starts. The timeout matches the Sync API run budget. """ - deployment_id = _managed_deployment(preview_env)["id"] + deployment_id = _service_deployment(preview_env)["id"] newest: dict[str, Any] = {} flow_runs: list[dict[str, Any]] = [] deadline = time.monotonic() + 240 diff --git a/tests/preview/test_preview_configuration.py b/tests/preview/test_preview_configuration.py index ff085908..7003ef76 100644 --- a/tests/preview/test_preview_configuration.py +++ b/tests/preview/test_preview_configuration.py @@ -40,7 +40,7 @@ def test_preview_routes_prefect_ui_to_the_published_host_port() -> None: assert 'PREFECT_SERVER_UI_API_URL: "http://localhost:${PREVIEW_PREFECT_PORT:-4210}/api"' in compose -def test_preview_declares_the_managed_postgresql_and_minio_storage_shape() -> None: +def test_preview_declares_the_service_postgresql_and_minio_storage_shape() -> None: """Preview supplies storage and liveness settings to both service processes.""" compose = (DEV_DIR / "docker-compose.preview.yml").read_text(encoding="utf-8") environment = preview._runtime_env( @@ -265,7 +265,7 @@ def test_the_seeded_device_is_one_the_cli_smoke_source_already_owns() -> None: assert SHARED_DEVICE_NAME in {device["name"] for device in devices} -def test_standalone_smoke_ensures_its_branch(monkeypatch: pytest.MonkeyPatch) -> None: +def test_the_smoke_task_ensures_its_branch(monkeypatch: pytest.MonkeyPatch) -> None: events: list[object] = [] context = Context() values = {"COMPOSE_PROJECT_NAME": "preview-test"} @@ -355,7 +355,7 @@ def test_actual_smoke_path_receives_the_preview_aws_credential_chain(monkeypatch assert smoke_environment["AWS_SECRET_ACCESS_KEY"] == PREVIEW_MINIO_SECRET -def test_standalone_smoke_leaves_an_unreachable_environment_to_pytest(monkeypatch: pytest.MonkeyPatch) -> None: +def test_the_smoke_task_leaves_an_unreachable_environment_to_pytest(monkeypatch: pytest.MonkeyPatch) -> None: events: list[object] = [] context = Context() values = {"COMPOSE_PROJECT_NAME": "preview-test"} diff --git a/tests/preview/test_preview_worker_identity.py b/tests/preview/test_preview_worker_identity.py index 0a65e634..d8b2057e 100644 --- a/tests/preview/test_preview_worker_identity.py +++ b/tests/preview/test_preview_worker_identity.py @@ -61,7 +61,7 @@ def run(self, command: str, **kwargs: Any) -> None: # noqa: ANN401, PLR6301 - I return captured -def test_preview_starts_the_supported_managed_worker_entrypoint( +def test_preview_starts_the_supported_service_worker_entrypoint( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: captured = _staged_up(monkeypatch, tmp_path) diff --git a/tests/preview/test_service_api.py b/tests/preview/test_service_api.py index 0e4853aa..3cdfeae8 100644 --- a/tests/preview/test_service_api.py +++ b/tests/preview/test_service_api.py @@ -255,7 +255,7 @@ def test_requests_without_a_bearer_token_are_refused(preview_env: dict[str, Any] assert response.status_code == 401 -def test_managed_plan_and_apply_lifecycle(preview_env: dict[str, Any]) -> None: +def test_service_plan_and_apply_lifecycle(preview_env: dict[str, Any]) -> None: mutated_type = _seed_source_branch(preview_env) assert _device_types(_infrahub_client(preview_env), SMOKE_BRANCH)[SHARED_DEVICE_NAME] != mutated_type diff --git a/tests/product_store/test_contract.py b/tests/product_store/test_contract.py index 512a53e8..0a0d51d6 100644 --- a/tests/product_store/test_contract.py +++ b/tests/product_store/test_contract.py @@ -641,7 +641,7 @@ def provider(request, tmp_path: Path) -> ProductProjection: ) -def test_zero_link_standalone_round_trip(provider: ProductProjection) -> None: +def test_zero_link_run_round_trip(provider: ProductProjection) -> None: expected = _run() provider.create_run(expected) diff --git a/tests/service/test_config_routes.py b/tests/service/test_config_routes.py index 199d7fb8..cef44ee1 100644 --- a/tests/service/test_config_routes.py +++ b/tests/service/test_config_routes.py @@ -1135,7 +1135,7 @@ def application(run: object, resolver: object, routes: object, reconciler: objec assert received[-1] is route_dependency -def test_build_app_composes_one_managed_projection_for_runs_and_configurations() -> None: +def test_build_app_composes_one_service_projection_for_runs_and_configurations() -> None: """The deployed API has one environment-owned product projection.""" projection = object() received: list[object] = [] diff --git a/tests/service/test_flow_and_prefect.py b/tests/service/test_flow_and_prefect.py index d53d38a1..2fce06c4 100644 --- a/tests/service/test_flow_and_prefect.py +++ b/tests/service/test_flow_and_prefect.py @@ -134,14 +134,14 @@ class _LoggerOwnershipProbe: def __init__(self, delegate: _LockDelegate) -> None: self._delegate = delegate self.events: list[str] = [] - self.managed_acquire_attempted = Event() + self.service_acquire_attempted = Event() def acquire(self) -> None: """Signal from inside the contender's acquisition attempt, then delegate.""" thread_name = current_thread().name self.events.append(f"{thread_name}:acquire-attempted") if thread_name == "test-service-flow": - self.managed_acquire_attempted.set() + self.service_acquire_attempted.set() self._delegate.acquire() self.events.append(f"{thread_name}:acquired") @@ -220,7 +220,7 @@ def test_worker_rejects_missing_registered_package_before_runtime_construction( assert constructed == [] -def test_managed_and_direct_prefect_flow_schemas_are_separate_and_exact() -> None: +def test_service_and_direct_prefect_flow_schemas_are_separate_and_exact() -> None: assert tuple(inspect.signature(service_sync_run.fn).parameters) == ( "run_id", "stage", @@ -261,7 +261,7 @@ def test_flow_working_directory_is_required_absolute_and_existing( ] -async def test_managed_deploy_only_converges_the_flow_working_directory( +async def test_service_deploy_only_converges_the_flow_working_directory( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: from infrahub_sync.service import deploy @@ -280,7 +280,7 @@ async def test_managed_deploy_only_converges_the_flow_working_directory( assert calls == [("working-directory", str(tmp_path))] -def test_managed_definition_entrypoint_targets_the_flow_file() -> None: +def test_service_definition_entrypoint_targets_the_flow_file() -> None: """The applied deployment must carry an executable entrypoint. Without one, a Prefect process worker refuses every service flow run with @@ -315,7 +315,7 @@ def bridge_is_forbidden(_logger): assert prefect_context is False -def test_direct_and_managed_log_bridges_serialize_ownership_and_restore_state( # noqa: PLR0914, PLR0915 +def test_direct_and_service_log_bridges_serialize_ownership_and_restore_state( # noqa: PLR0914, PLR0915 monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: @@ -323,7 +323,7 @@ def test_direct_and_managed_log_bridges_serialize_ownership_and_restore_state( direct_canary = "direct-flow-secret-canary" service_canary = "service-flow-secret-canary" direct_logger = _RecordingRunLogger() - managed_logger = _RecordingRunLogger() + service_logger = _RecordingRunLogger() source_logger = logging.getLogger(service_flow.SOURCE_LOGGER_NAME) child_logger = logging.getLogger(f"{service_flow.SOURCE_LOGGER_NAME}.concurrency-test") sentinel_handler = logging.NullHandler() @@ -332,10 +332,10 @@ def test_direct_and_managed_log_bridges_serialize_ownership_and_restore_state( original_propagate = source_logger.propagate direct_entered = Event() release_direct = Event() - managed_entered = Event() - release_managed = Event() + service_entered = Event() + release_service = Event() direct_failures: list[BaseException] = [] - managed_failures: list[BaseException] = [] + service_failures: list[BaseException] = [] def fail_direct_request(*_args: object, **_kwargs: object) -> NoReturn: direct_entered.set() @@ -352,15 +352,15 @@ def run_direct() -> None: def run_service_bridge() -> None: try: with service_flow._remote_log_bridge( - managed_logger, + service_logger, prefect_context=True, secrets=(service_canary,), ): - managed_entered.set() + service_entered.set() child_logger.warning("service record used %s", service_canary) - assert release_managed.wait(timeout=5) + assert release_service.wait(timeout=5) except BaseException as exc: # noqa: BLE001 - retain thread failure for the main test. - managed_failures.append(exc) + service_failures.append(exc) monkeypatch.setattr("infrahub_sync.orchestration.flow.get_run_logger", lambda: direct_logger) monkeypatch.setattr("infrahub_sync.orchestration.flow.collect_secret_values", lambda: (direct_canary,)) @@ -381,18 +381,18 @@ def run_service_bridge() -> None: direct_thread.start() assert direct_entered.wait(timeout=5) service_thread.start() - assert ownership_probe.managed_acquire_attempted.wait(timeout=5) + assert ownership_probe.service_acquire_attempted.wait(timeout=5) child_logger.warning("direct record used %s", direct_canary) release_direct.set() direct_thread.join(timeout=5) assert not direct_thread.is_alive() - assert managed_entered.wait(timeout=5) - release_managed.set() + assert service_entered.wait(timeout=5) + release_service.set() service_thread.join(timeout=5) assert not service_thread.is_alive() - rendered = "\n".join((*direct_logger.rendered, *managed_logger.rendered)) + rendered = "\n".join((*direct_logger.rendered, *service_logger.rendered)) expected_acquisition_order = [ "test-direct-flow:acquire-attempted", "test-direct-flow:acquired", @@ -411,7 +411,7 @@ def run_service_bridge() -> None: ), ( "service bridge received the direct record", - any("direct record" in line for line in managed_logger.rendered), + any("direct record" in line for line in service_logger.rendered), ), ("direct canary reached a run logger", direct_canary in rendered), ("service canary reached a run logger", service_canary in rendered), @@ -425,10 +425,10 @@ def run_service_bridge() -> None: assert len(direct_failures) == 1 assert isinstance(direct_failures[0], RuntimeError) assert str(direct_failures[0]) == "direct flow failed" - assert managed_failures == [] + assert service_failures == [] finally: release_direct.set() - release_managed.set() + release_service.set() for thread in (direct_thread, service_thread): if thread.ident is not None: thread.join(timeout=5) @@ -438,7 +438,7 @@ def run_service_bridge() -> None: @pytest.mark.usefixtures("_claimed_worker_execution") -def test_managed_flow_redacts_worker_logs_exception_chain_and_failed_state( +def test_service_flow_redacts_worker_logs_exception_chain_and_failed_state( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: @@ -513,7 +513,7 @@ def fail_plan(_instance, *, run_id: str, branch: str | None, composed_sync: bool @pytest.mark.usefixtures("_claimed_worker_execution") -def test_managed_apply_failure_retains_partial_write_evidence( +def test_service_apply_failure_retains_partial_write_evidence( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: @@ -557,7 +557,7 @@ def fail_apply(*_args: object, **_kwargs: object) -> NoReturn: @pytest.mark.usefixtures("_claimed_worker_execution") -def test_managed_verify_failure_merges_evidence_and_terminalizes_exact_link( +def test_service_verify_failure_merges_evidence_and_terminalizes_exact_link( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: @@ -657,7 +657,7 @@ def commit_then_fail(*args: object, **kwargs: object) -> bool: @pytest.mark.usefixtures("_claimed_worker_execution") -def test_managed_confirmed_sync_retains_the_semantic_sync_operation( +def test_service_confirmed_sync_retains_the_semantic_sync_operation( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: @@ -702,7 +702,7 @@ def core(_instance: object, *, operation: str, **_kwargs: object) -> SavedPlan | @pytest.mark.usefixtures("_claimed_worker_execution") -def test_managed_plan_worker_updates_the_api_created_run_and_publishes_review( +def test_service_plan_worker_updates_the_api_created_run_and_publishes_review( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: run_id = "run-service-plan" @@ -740,7 +740,7 @@ def plan(_instance, *, run_id: str, branch: str | None, composed_sync: bool): # @pytest.mark.usefixtures("_claimed_worker_execution") -def test_managed_verify_is_read_only_for_product_lifecycle(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: +def test_service_verify_is_read_only_for_product_lifecycle(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: run_id = "run-service-verify" projection = _create_product_run(tmp_path.resolve(), run_id) before = projection.lookup_run(run_id).value @@ -776,7 +776,7 @@ def test_managed_verify_is_read_only_for_product_lifecycle(monkeypatch: pytest.M @pytest.mark.usefixtures("_claimed_worker_execution") -def test_confirmed_managed_sync_calls_plan_verify_apply_in_order_on_one_run( +def test_confirmed_service_sync_calls_plan_verify_apply_in_order_on_one_run( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: run_id = "run-service-sync" @@ -1066,7 +1066,7 @@ async def update_deployment( # noqa: PLR6301 - protocol fake. @pytest.mark.asyncio -async def test_prefect_extras_deployment_converges_the_managed_catalogue_offline() -> None: +async def test_prefect_extras_deployment_converges_the_service_catalogue_offline() -> None: client = _DeploymentClient() report = await apply_deployments(CATALOGUE, work_pool_name="service-pool", client=client) diff --git a/tests/service/test_http_api.py b/tests/service/test_http_api.py index c794716f..94ad8189 100644 --- a/tests/service/test_http_api.py +++ b/tests/service/test_http_api.py @@ -953,7 +953,7 @@ def test_run_resource_exposes_liveness_without_private_worker_or_receipt_ids( assert summary["terminal_outcome"] is None -def test_public_run_resource_is_a_standalone_projection_of_the_product_run_contract( +def test_public_run_resource_is_a_self_contained_projection_of_the_product_run_contract( service_api: tuple[TestClient, ProductProjection, _FakeOrchestration], ) -> None: """The server copies the store record into a neutral wire model.""" diff --git a/tests/service/test_storage.py b/tests/service/test_storage.py index 8aa3e0da..7b27c83f 100644 --- a/tests/service/test_storage.py +++ b/tests/service/test_storage.py @@ -196,7 +196,7 @@ def get_object(self, **_kwargs: object) -> object: assert error.value.__cause__ is None -def test_managed_storage_factory_validates_settings_and_hides_startup_details() -> None: +def test_service_storage_factory_validates_settings_and_hides_startup_details() -> None: """The factory has one value-free environment contract and startup failure.""" from infrahub_sync.service import storage @@ -248,7 +248,7 @@ def unavailable() -> NoReturn: assert error.value.__cause__ is None -def test_managed_storage_settings_refuse_absence_and_normalize_the_prefix_deterministically() -> None: +def test_service_storage_settings_refuse_absence_and_normalize_the_prefix_deterministically() -> None: """Every setting refuses absence or emptiness, and no refusal reflects its value.""" from infrahub_sync.service import storage @@ -310,7 +310,7 @@ def collect_prefix(**kwargs: object) -> ProductProjection: "postgresql://db/sync?unknown-option=database-secret-canary", ], ) -def test_managed_storage_rejects_non_postgresql_conninfo_before_any_construction(database_url: str) -> None: +def test_service_storage_rejects_non_postgresql_conninfo_before_any_construction(database_url: str) -> None: """Database URL acceptance is exactly Psycopg's non-empty conninfo domain.""" from infrahub_sync.service import storage @@ -337,7 +337,7 @@ def constructed(name: str) -> NoReturn: assert calls == [] -def test_managed_storage_contains_sdk_client_construction_failures() -> None: +def test_service_storage_contains_sdk_client_construction_failures() -> None: """SDK construction details become the fixed unchained startup refusal.""" from infrahub_sync.service import storage @@ -381,7 +381,7 @@ def projection_builder(**_kwargs: object) -> ProductProjection: "https://user:secret-canary@s3.example.test", ], ) -def test_managed_storage_endpoint_rejects_non_urls_and_userinfo_before_construction(endpoint: str) -> None: +def test_service_storage_endpoint_rejects_non_urls_and_userinfo_before_construction(endpoint: str) -> None: """A rejected endpoint never reaches a builder and never reflects its own value.""" from infrahub_sync.service import storage @@ -429,7 +429,7 @@ def projection_builder(**_kwargs: object) -> ProductProjection: "HTTP://s3.example.test:9000/path%20with%20encoding?query=@value#fragment", ], ) -def test_managed_storage_endpoint_accepts_valid_authorities(endpoint: str) -> None: +def test_service_storage_endpoint_accepts_valid_authorities(endpoint: str) -> None: """An accepted endpoint reaches Boto3 as the operator's own unmodified string.""" from infrahub_sync.service import storage diff --git a/tests/service/test_storage_import_boundary.py b/tests/service/test_storage_import_boundary.py index df0e16ee..228b6adf 100644 --- a/tests/service/test_storage_import_boundary.py +++ b/tests/service/test_storage_import_boundary.py @@ -26,7 +26,7 @@ def _local_projection_references(path: Path) -> tuple[str, ...]: return tuple(references) -def test_deployed_managed_runtime_cannot_import_or_reference_the_local_projection() -> None: +def test_deployed_service_runtime_cannot_import_or_reference_the_local_projection() -> None: """API and worker runtime modules must stay on the service storage factory.""" offenders = { str(path.relative_to(SERVICE_PACKAGE)): references @@ -37,7 +37,7 @@ def test_deployed_managed_runtime_cannot_import_or_reference_the_local_projectio assert offenders == {} -def test_deployed_runtime_defaults_bind_the_managed_projection_call_boundary() -> None: +def test_deployed_runtime_defaults_bind_the_service_projection_call_boundary() -> None: """API and worker defaults call the service factory while retaining explicit injection.""" pytest.importorskip("boto3") pytest.importorskip("prefect") diff --git a/tests/test_linter_tasks.py b/tests/test_linter_tasks.py index f550bfa8..a7822637 100644 --- a/tests/test_linter_tasks.py +++ b/tests/test_linter_tasks.py @@ -1,24 +1,24 @@ from tasks import linter -def test_ty_check_command_excludes_managed_on_python_310() -> None: +def test_ty_check_command_excludes_service_on_python_310() -> None: assert linter._ty_check_command(3, 10) == ( "uv run ty check --exclude infrahub_sync/service --exclude tests/service ." ) -def test_ty_check_command_checks_managed_on_supported_python() -> None: +def test_ty_check_command_checks_service_on_supported_python() -> None: assert linter._ty_check_command(3, 11) == "uv run ty check ." assert linter._ty_check_command(3, 13) == "uv run ty check ." -def test_pylint_command_excludes_managed_on_python_310() -> None: +def test_pylint_command_excludes_service_on_python_310() -> None: assert linter._pylint_command(3, 10) == ( "pylint --output-format=json2 --ignore-paths='^infrahub_sync/service/' infrahub_sync/" ) -def test_pylint_command_checks_managed_on_supported_python() -> None: +def test_pylint_command_checks_service_on_supported_python() -> None: assert linter._pylint_command(3, 11) == "pylint --output-format=json2 infrahub_sync/" assert linter._pylint_command(3, 13) == "pylint --output-format=json2 infrahub_sync/" diff --git a/tests/test_service_storage_docs.py b/tests/test_service_storage_docs.py index d7ea4a38..2a1acf01 100644 --- a/tests/test_service_storage_docs.py +++ b/tests/test_service_storage_docs.py @@ -17,7 +17,7 @@ @pytest.mark.parametrize("name", ["durable-product-records.mdx", "sync-http-api.mdx"]) -def test_managed_storage_operator_references_state_the_complete_deployed_contract(name: str) -> None: +def test_service_storage_operator_references_state_the_complete_deployed_contract(name: str) -> None: """Every service-storage reference names one PostgreSQL/S3 deployment shape.""" text = (REFERENCE_ROOT / name).read_text(encoding="utf-8") From 76c6c05b5ed3bef53f0e54e214c99fad76e8a36a Mon Sep 17 00:00:00 2001 From: Blake Ellis Date: Tue, 1 Sep 2026 15:16:12 -0400 Subject: [PATCH 09/12] Point the local vendoring notes at the renamed service extra Seat: lead-developer Assignment: single-product-reconciliation Co-Authored-By: Claude Opus 5 --- opsmill_prefect_extras/VENDORED.md | 4 ++-- tests/vendored_prefect_extras/conftest.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/opsmill_prefect_extras/VENDORED.md b/opsmill_prefect_extras/VENDORED.md index d4a9df1b..7a444c6a 100644 --- a/opsmill_prefect_extras/VENDORED.md +++ b/opsmill_prefect_extras/VENDORED.md @@ -35,7 +35,7 @@ Local lint/format tooling must exclude this directory rather than rewrite it. When upstream `opsmill/prefect-extras` is merged and published, delete this directory and `tests/vendored_prefect_extras/`, remove `"opsmill_prefect_extras"` from `[tool.hatch.build.targets.wheel] packages`, -restore the dependency in the `managed` extra, and drop the vendoring entries +restore the dependency in the `service` extra, and drop the vendoring entries from the Ruff exclude list, `[tool.ty.src]` exclude, the isort `known-third-party` pin, and `.github/file-filters.yml`. `tests/test_vendoring_consistency.py` fails on any half-executed re-adoption. @@ -43,7 +43,7 @@ from the Ruff exclude list, `[tool.ty.src]` exclude, the isort ### CI authentication on re-adoption Restoring the dependency reintroduces a problem vendoring removed: `opsmill/prefect-extras` -is a private repository, so CI can no longer install the `managed` extra with the default +is a private repository, so CI can no longer install the `service` extra with the default job token. Two approaches to this were written before vendoring was chosen, and both were dropped diff --git a/tests/vendored_prefect_extras/conftest.py b/tests/vendored_prefect_extras/conftest.py index 06ae8a80..158e4bac 100644 --- a/tests/vendored_prefect_extras/conftest.py +++ b/tests/vendored_prefect_extras/conftest.py @@ -3,7 +3,7 @@ Three adaptations, all confined to this file so no vendored test changes: 1. Skip the whole directory when the prefect extra is absent — the vendored - package imports prefect at module scope, matching `tests/managed/` guards. + package imports prefect at module scope, matching `tests/service/` guards. 2. Alias the `tests.workflows` package to the vendored location in `sys.modules`. Upstream fixture definitions reference their flow modules by the dotted paths `tests.workflows.flows` / `tests.workflows.sentinel` and From 2fe33762321279401731c53dd09d3a059e610f50 Mon Sep 17 00:00:00 2001 From: Blake Ellis Date: Tue, 1 Sep 2026 15:35:14 -0400 Subject: [PATCH 10/12] Clear the retired spec path from live provenance notes and the API title Seat: lead-developer Assignment: single-product-reconciliation Co-Authored-By: Claude Opus 5 --- dev/guidelines/secret-redaction.md | 2 +- dev/guidelines/testing.md | 2 +- dev/knowledge/execution-surface.md | 2 +- dev/knowledge/orchestration-prefect.md | 2 +- dev/knowledge/quality-gates.md | 2 +- infrahub_sync/service/app.py | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/dev/guidelines/secret-redaction.md b/dev/guidelines/secret-redaction.md index 5c113bc3..dd76fb4b 100644 --- a/dev/guidelines/secret-redaction.md +++ b/dev/guidelines/secret-redaction.md @@ -2,7 +2,7 @@ > Part of: `dev/guidelines/` | Related: [The shared execution surface](../knowledge/execution-surface.md), [Writing an adapter](writing-an-adapter.md) - + Rules for any code path that renders a failure across a process boundary — a served deployment, an API response, a queued job. The adapter rule ("never log a secret") is about diff --git a/dev/guidelines/testing.md b/dev/guidelines/testing.md index a252a1e0..7685926c 100644 --- a/dev/guidelines/testing.md +++ b/dev/guidelines/testing.md @@ -2,7 +2,7 @@ > Part of: `dev/guidelines/` | Related: [Testing adapters](testing-adapters.md), [Quality gates](../knowledge/quality-gates.md) - + Repository-wide rules for tests. [Testing adapters](testing-adapters.md) covers what an adapter must ship; this document covers what makes any test worth having. diff --git a/dev/knowledge/execution-surface.md b/dev/knowledge/execution-surface.md index 7d66a415..99af74f3 100644 --- a/dev/knowledge/execution-surface.md +++ b/dev/knowledge/execution-surface.md @@ -2,7 +2,7 @@ > Part of: `dev/knowledge/` | Related: [Sync architecture](sync-architecture.md), [Prefect orchestration](orchestration-prefect.md) - + `infrahub_sync/execution.py` is the typed Python entry point to a single sync run. It exists because the plan and serial-sync lifecycles need more than one caller: the CLI drives them diff --git a/dev/knowledge/orchestration-prefect.md b/dev/knowledge/orchestration-prefect.md index a58c855a..29cb0976 100644 --- a/dev/knowledge/orchestration-prefect.md +++ b/dev/knowledge/orchestration-prefect.md @@ -2,7 +2,7 @@ > Part of: `dev/knowledge/` | Related: [The shared execution surface](execution-surface.md) - + `infrahub_sync/orchestration/` is the direct Prefect integration: a flow that runs one plan or one confirmed sync, and a serve entrypoint that exposes it as a locally served diff --git a/dev/knowledge/quality-gates.md b/dev/knowledge/quality-gates.md index 7eb73705..5fb74ae9 100644 --- a/dev/knowledge/quality-gates.md +++ b/dev/knowledge/quality-gates.md @@ -2,7 +2,7 @@ > Part of: `dev/knowledge/` | Related: [Testing](../guidelines/testing.md) - + What `invoke format` and `invoke lint` actually run, in what order, and what a passing result does and does not mean. Both aggregates are executable gates on a clean checkout; this page diff --git a/infrahub_sync/service/app.py b/infrahub_sync/service/app.py index 40760008..70196530 100644 --- a/infrahub_sync/service/app.py +++ b/infrahub_sync/service/app.py @@ -73,7 +73,7 @@ async def reconcile_loop() -> None: with suppress(CancelledError): await task - application = FastAPI(title="Infrahub Sync Sync API", version=installed_server_version(), lifespan=lifespan) + application = FastAPI(title="Infrahub Sync API", version=installed_server_version(), lifespan=lifespan) bearer_auth = HTTPBearer(auto_error=False, scheme_name="BearerAuth") def authenticate( From 9124ef02e6fd6c392af51d49b60cec2eb667648e Mon Sep 17 00:00:00 2001 From: Blake Ellis Date: Tue, 1 Sep 2026 16:00:39 -0400 Subject: [PATCH 11/12] Declare the preview smoke order so the surface test runs after the Sync API smoke Seat: lead-developer Assignment: single-product-reconciliation Co-Authored-By: Claude Opus 5 --- tests/preview/conftest.py | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/tests/preview/conftest.py b/tests/preview/conftest.py index c94a5c1a..0b668190 100644 --- a/tests/preview/conftest.py +++ b/tests/preview/conftest.py @@ -10,13 +10,37 @@ from __future__ import annotations import json -from typing import Any +from pathlib import Path +from typing import TYPE_CHECKING, Any import httpx import pytest from tasks.preview import REPO_ROOT, PreviewError, load_preview_env, preview_urls +if TYPE_CHECKING: + from _pytest.nodes import Item + +_HERE = Path(__file__).parent +# The Prefect surface smoke asserts on the flow runs the Sync API smoke creates, so it +# has to run after it. Filename collation is not a dependency this suite may rest on: +# renaming either module silently reverses them and the surface smoke then polls a +# deployment that nothing has submitted to yet. +_RUN_CREATOR = _HERE / "test_service_api.py" +_RUN_OBSERVER = _HERE / "test_prefect_surface.py" + + +def pytest_collection_modifyitems(items: list[Item]) -> None: + """Run the Sync API smoke before the Prefect surface smoke that observes it.""" + observers = [item for item in items if item.path == _RUN_OBSERVER] + creators = [item for item in items if item.path == _RUN_CREATOR] + if not observers or not creators or items.index(creators[-1]) < items.index(observers[0]): + return + for observer in observers: + items.remove(observer) + resume_at = items.index(creators[-1]) + 1 + items[resume_at:resume_at] = observers + @pytest.fixture(scope="session") def preview_settings() -> dict[str, Any]: From 66f7f66b81e5a8ee4e9c19451bdd9eb47849efc7 Mon Sep 17 00:00:00 2001 From: Blake Ellis Date: Tue, 1 Sep 2026 16:27:58 -0400 Subject: [PATCH 12/12] Document the injected projection and clear the active host name in tests Seat: lead-developer Assignment: single-product-reconciliation Co-Authored-By: Claude Opus 5 --- infrahub_sync/product_store/configs.py | 24 +++++++++++++++++------- tests/service/test_environment_names.py | 8 +++++++- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/infrahub_sync/product_store/configs.py b/infrahub_sync/product_store/configs.py index e3d79122..70adf12b 100644 --- a/infrahub_sync/product_store/configs.py +++ b/infrahub_sync/product_store/configs.py @@ -722,7 +722,8 @@ def register( ``package`` is declared JSON-native content; a prebuilt package instance is refused (:func:`_parse`). Validation happens inside the store, before anything is persisted, so an invalid package raises and is never registered. The findings surface is - :func:`validate`. + :func:`validate`. The caller supplies the durable-store :class:`ProductProjection` + through ``projection``. """ parsed = _parse(package) try: @@ -748,7 +749,8 @@ def create_version( """Add one version to an existing configuration, or return the identical stored one. ``package`` is declared JSON-native content; a prebuilt package instance is refused - (:func:`_parse`). + (:func:`_parse`). The caller supplies the durable-store :class:`ProductProjection` + through ``projection``. """ _require_argument_type(config_id, name="config_id", expected=str) parsed = _parse(package) @@ -769,7 +771,8 @@ def list_configs(*, projection: ProductProjection) -> tuple[ConfigurationSummary The order is the store's own ``ORDER BY created_at, config_id`` — deterministic and total, never a re-sort in this layer. An empty registry is a real answer here, unlike the scoped - reads: there is no identifier whose absence could make it a not-found. + reads: there is no identifier whose absence could make it a not-found. The caller + supplies the durable-store :class:`ProductProjection` through ``projection``. """ return projection.list_configurations() @@ -780,7 +783,10 @@ def get_config( config_id: str, projection: ProductProjection, ) -> ConfigurationSummary: - """Return one registered configuration's summary, refusing absence rather than guessing.""" + """Return one registered configuration's summary, refusing absence rather than guessing. + + The caller supplies the durable-store :class:`ProductProjection` through ``projection``. + """ _require_argument_type(config_id, name="config_id", expected=str) return _require_configuration(projection, config_id) @@ -795,7 +801,8 @@ def list_versions( A missing configuration refuses — never a silent empty tuple, which would be indistinguishable from a real answer about a registered configuration. The order is the - store's own ``ORDER BY registry_version``, not a re-sort in this layer. + store's own ``ORDER BY registry_version``, not a re-sort in this layer. The caller + supplies the durable-store :class:`ProductProjection` through ``projection``. """ _require_argument_type(config_id, name="config_id", expected=str) _require_configuration(projection, config_id) @@ -815,7 +822,9 @@ def get_version( refuses with :data:`CONFIGURATION_NOT_FOUND_REASON` — then the version, whose absence on an existing configuration refuses with :data:`CONFIGURATION_VERSION_NOT_FOUND_REASON`. Deletion does not exist, so the two steps - cannot race. The store's blended single-lookup reason is untouched; ``validate`` keeps it. + cannot race. The store's blended single-lookup reason is untouched; ``validate`` keeps + it. The caller supplies the durable-store :class:`ProductProjection` through + ``projection``. """ _require_argument_type(config_id, name="config_id", expected=str) _require_registry_version(registry_version) @@ -845,7 +854,8 @@ def validate( ``None`` — the default — keeps the declared-content-only behavior byte-identical, with zero schema reads and zero network I/O. An explicit options object adds the destination schema checks, merges their findings under the same ``sort_findings`` contract, and - records the judged snapshot's fingerprint. + records the judged snapshot's fingerprint. The caller supplies the durable-store + :class:`ProductProjection` through ``projection``. """ _require_argument_type(config_id, name="config_id", expected=str) _require_registry_version(registry_version) diff --git a/tests/service/test_environment_names.py b/tests/service/test_environment_names.py index 1c7f41d0..3e807e83 100644 --- a/tests/service/test_environment_names.py +++ b/tests/service/test_environment_names.py @@ -27,7 +27,13 @@ @pytest.fixture(autouse=True) def _clear_environment(monkeypatch: pytest.MonkeyPatch) -> None: - for name in (*RETIRED_NAMES, auth.PRINCIPALS_ENV, deploy.WORK_POOL_ENV, deploy.FLOW_WORKING_DIRECTORY_ENV): + for name in ( + *RETIRED_NAMES, + auth.PRINCIPALS_ENV, + deploy.WORK_POOL_ENV, + deploy.FLOW_WORKING_DIRECTORY_ENV, + "INFRAHUB_SYNC_SERVICE_HOST", + ): monkeypatch.delenv(name, raising=False)