From 010199558cbcef78d22e3c2ffaf544fa233f36e7 Mon Sep 17 00:00:00 2001 From: Angel Parra <607418+aparragithub@users.noreply.github.com> Date: Thu, 13 Aug 2026 14:14:52 -0500 Subject: [PATCH 1/4] feat(remote): add deployment coordinator --- src/odoo_forge/remote_deployment.py | 177 +++++++++++++++++++ tests/remote_deployment/test_handoff.py | 220 ++++++++++++++++++++++++ 2 files changed, 397 insertions(+) create mode 100644 src/odoo_forge/remote_deployment.py create mode 100644 tests/remote_deployment/test_handoff.py diff --git a/src/odoo_forge/remote_deployment.py b/src/odoo_forge/remote_deployment.py new file mode 100644 index 0000000..21342a6 --- /dev/null +++ b/src/odoo_forge/remote_deployment.py @@ -0,0 +1,177 @@ +"""Runtime-owned aggregate handoff for the canonical VPS deployment.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from odoo_forge.backend.plan import BackendPlan +from odoo_forge.backend.status import InstanceRef +from odoo_forge.deployment_spec.types import DeploymentSpec +from odoo_forge.durable_operations.types import DurableOperationIdentity, LifecycleState +from odoo_forge.exposure.types import ExposureOutcome, ExposureRequest, ExposureResult +from odoo_forge.ports.durable_operation_store import DurableOperationRecord, DurableOperationStore +from odoo_forge.resource_ownership.types import OwnershipRecord + +if TYPE_CHECKING: + from odoo_forge_docker.vps.provider import VpsTargetIdentity + + +class RemoteDeploymentIncompleteError(RuntimeError): + pass + + +@dataclass(frozen=True) +class RemoteDeploymentRequest: + deployment: DeploymentSpec + plan: BackendPlan + target: VpsTargetIdentity + runtime_operation: DurableOperationIdentity + exposure_operation: DurableOperationIdentity | None = None + runtime_ownership: tuple[OwnershipRecord, ...] = () + exposure_credential_handles: tuple[object, ...] = () + + +@dataclass(frozen=True) +class RemoteDeploymentReceipt: + deployment: DeploymentSpec + provider: str + target: VpsTargetIdentity + runtime_ref: InstanceRef | None + runtime_operation: DurableOperationIdentity + runtime_ownership: tuple[OwnershipRecord, ...] + runtime_terminal_record: DurableOperationRecord + exposure_operation: DurableOperationIdentity | None + exposure_result: ExposureResult | None + exposure_ownership: tuple[OwnershipRecord, ...] + exposure_terminal_record: DurableOperationRecord | None + outcome: LifecycleState + + +Recorder = Callable[[RemoteDeploymentReceipt], None] + + +def _valid_terminal(record: DurableOperationRecord, operation: DurableOperationIdentity) -> bool: + commit = record.terminal_commit + return ( + record.identity == operation + and record.lifecycle in (LifecycleState.SUCCEEDED, LifecycleState.FAILED) + and commit is not None + and commit.outcome is record.lifecycle + and not commit.residual_cleanup + and bool(commit.evidence) + ) + + +class RemoteDeploymentCoordinator: + """Compose existing VPS operations without owning adapter lifecycle.""" + + def __init__( + self, + *, + runtime_provider: object, + operation_store: DurableOperationStore, + recorder: Recorder, + exposure_provider: object | None = None, + ) -> None: + self._runtime_provider = runtime_provider + self._exposure_provider = exposure_provider + self._operation_store = operation_store + self._recorder = recorder + + def deploy(self, request: RemoteDeploymentRequest) -> RemoteDeploymentReceipt: + if not all( + record.receipt is None or record.receipt.operation == request.runtime_operation + for record in request.runtime_ownership + ): + raise RemoteDeploymentIncompleteError("runtime ownership is inconsistent") + if request.deployment.exposure is None and request.exposure_operation is not None: + raise RemoteDeploymentIncompleteError("exposure operation has no exposure intent") + try: + runtime_ref = self._runtime_provider.run(request.plan) # type: ignore[attr-defined] + except Exception: + self._record_validated_failure(request) + raise + + if ( + runtime_ref.project != request.deployment.pointer.scope.project_id + or runtime_ref.instance != request.deployment.pointer.instance_id.value + or runtime_ref.network != request.deployment.resource.identifier + ): + raise RemoteDeploymentIncompleteError("runtime identity is inconsistent") + + runtime_record = self._operation_store.create_or_load(request.runtime_operation) + if not _valid_terminal(runtime_record, request.runtime_operation): + raise RemoteDeploymentIncompleteError("runtime evidence is incomplete") + if runtime_record.lifecycle is not LifecycleState.SUCCEEDED: + raise RemoteDeploymentIncompleteError("runtime operation did not succeed") + + exposure_result: ExposureResult | None = None + exposure_record: DurableOperationRecord | None = None + exposure_ownership: tuple[OwnershipRecord, ...] = () + if request.deployment.exposure is not None: + if self._exposure_provider is None or request.exposure_operation is None: + raise RemoteDeploymentIncompleteError("exposure reconciliation is not composed") + exposure_request = ExposureRequest( + instance=runtime_ref, + deployment=request.deployment, + scope=request.deployment.pointer.scope, + operation=request.exposure_operation, + ownership=(), + credential_handles=request.exposure_credential_handles, + ) + exposure_result = self._exposure_provider.reconcile(exposure_request) # type: ignore[attr-defined] + exposure_record = self._operation_store.create_or_load(request.exposure_operation) + if ( + not _valid_terminal(exposure_record, request.exposure_operation) + or exposure_record.lifecycle is not LifecycleState.SUCCEEDED + or exposure_result.operation != request.exposure_operation + or exposure_result.outcome is not ExposureOutcome.READY + or not exposure_result.ready + ): + raise RemoteDeploymentIncompleteError("exposure evidence is incomplete") + exposure_ownership = exposure_result.ownership + + receipt = RemoteDeploymentReceipt( + deployment=request.deployment, + provider="vps", + target=request.target, + runtime_ref=runtime_ref, + runtime_operation=request.runtime_operation, + runtime_ownership=request.runtime_ownership, + runtime_terminal_record=runtime_record, + exposure_operation=request.exposure_operation, + exposure_result=exposure_result, + exposure_ownership=exposure_ownership, + exposure_terminal_record=exposure_record, + outcome=LifecycleState.SUCCEEDED, + ) + self._recorder(receipt) + return receipt + + def _record_validated_failure(self, request: RemoteDeploymentRequest) -> None: + try: + record = self._operation_store.create_or_load(request.runtime_operation) + if not _valid_terminal(record, request.runtime_operation): + return + if record.lifecycle is not LifecycleState.FAILED: + return + self._recorder( + RemoteDeploymentReceipt( + deployment=request.deployment, + provider="vps", + target=request.target, + runtime_ref=None, + runtime_operation=request.runtime_operation, + runtime_ownership=request.runtime_ownership, + runtime_terminal_record=record, + exposure_operation=None, + exposure_result=None, + exposure_ownership=(), + exposure_terminal_record=None, + outcome=LifecycleState.FAILED, + ) + ) + except Exception: + return diff --git a/tests/remote_deployment/test_handoff.py b/tests/remote_deployment/test_handoff.py new file mode 100644 index 0000000..ce7486c --- /dev/null +++ b/tests/remote_deployment/test_handoff.py @@ -0,0 +1,220 @@ +import pytest + +from odoo_forge.backend.plan import BackendPlan, ContainerSpec, NetworkSpec +from odoo_forge.backend.status import InstanceRef +from odoo_forge.deployment_spec.types import ( + DeploymentSpec, + ExposureIntent, + OdooRuntimeIntent, + RequirementPolicy, + RouteProtocol, +) +from odoo_forge.durable_operations.service import build_terminal_commit, save_checkpoint +from odoo_forge.durable_operations.types import ( + DurableOperationIdentity, + LifecycleState, + OperationRevision, + RedactedEvidence, +) +from odoo_forge.exposure.types import ExposureCheckStatus, ExposureOutcome, ExposureResult +from odoo_forge.instance_registry.types import InstanceId, InstancePointer +from odoo_forge.ports.durable_operation_store import DurableOperationRecord +from odoo_forge.remote_deployment import ( + RemoteDeploymentCoordinator, + RemoteDeploymentIncompleteError, + RemoteDeploymentRequest, +) +from odoo_forge.resource_ownership.types import ( + OwnershipReceipt, + OwnershipRecord, + ResourceOwnership, + ResourceRef, +) +from odoo_forge.tenancy.types import ProjectScope, TenantId +from odoo_forge_docker.vps.provider import VpsTargetIdentity + +SCOPE = ProjectScope(tenant=TenantId(value="tenant-1"), project_id="project-1") +POINTER = InstancePointer(scope=SCOPE, instance_id=InstanceId(value="one")) +TARGET = VpsTargetIdentity(host="vps.example", user="deploy", port=22, host_key="ssh-ed25519") +RUN = DurableOperationIdentity(operation_id="run-1", request_digest="run-digest") +EXPOSURE = DurableOperationIdentity(operation_id="exposure-1", request_digest="exposure-digest") + + +def owner(operation: DurableOperationIdentity, identifier: str) -> OwnershipRecord: + return OwnershipRecord( + ref=ResourceRef( + identifier=identifier, resource_kind="container", ownership=ResourceOwnership.CREATED + ), + receipt=OwnershipReceipt(operation=operation, owned_resource_ids=(identifier,)), + ) + + +def deployment(exposed: bool = False) -> DeploymentSpec: + return DeploymentSpec( + pointer=POINTER, + resource=ResourceRef( + identifier="odoo-forge-project-1-one", + resource_kind="network", + ownership=ResourceOwnership.CREATED, + ), + runtime=OdooRuntimeIntent(odoo_version="18.0"), + exposure=ExposureIntent( + hostname="one.example", + protocol=RouteProtocol.HTTP, + dns=RequirementPolicy.REQUIRED, + tls=RequirementPolicy.DISABLED, + ) + if exposed + else None, + ) + + +def plan() -> BackendPlan: + network = NetworkSpec(name="odoo-forge-project-1-one", labels={"managed": "true"}) + db = ContainerSpec( + name="db-one", image="postgres:16", role="postgres", network=network.name, env={}, labels={} + ) + odoo = ContainerSpec( + name="odoo-one", + image="odoo-forge-odoo:18.0", + role="odoo", + network=network.name, + env={}, + labels={}, + ) + return BackendPlan(network=network, volumes=[], postgres=db, odoo=odoo) + + +def request(exposed: bool = False) -> RemoteDeploymentRequest: + return RemoteDeploymentRequest( + deployment=deployment(exposed), + plan=plan(), + target=TARGET, + runtime_operation=RUN, + exposure_operation=EXPOSURE if exposed else None, + runtime_ownership=(owner(RUN, "odoo-one"),), + ) + + +def record(operation: DurableOperationIdentity, outcome: LifecycleState) -> DurableOperationRecord: + evidence = RedactedEvidence(event="terminal", summary="operation reached terminal state") + checkpoint = save_checkpoint(OperationRevision(value=0), "ready", evidence) + terminal = build_terminal_commit(OperationRevision(value=1), outcome, (evidence,), ()) + return DurableOperationRecord( + identity=operation, + revision=OperationRevision(value=2), + lifecycle=outcome, + checkpoint=checkpoint, + terminal_commit=terminal, + recovery_evidence=(evidence,), + ) + + +class Store: + def __init__(self, records): + self.records = records + + def create_or_load(self, operation): + return self.records[operation.operation_id] + + +class Runtime: + def __init__(self, error=None): + self.error, self.ownership, self.calls = error, (owner(RUN, "odoo-one"),), [] + + def run(self, value): + self.calls.append(value) + if self.error: + raise self.error + return InstanceRef( + project="project-1", + instance="one", + network="odoo-forge-project-1-one", + postgres_container="db-one", + odoo_container="odoo-one", + ) + + +class Exposure: + def __init__(self, result): + self.result, self.requests = result, [] + + def reconcile(self, request): + self.requests.append(request) + return self.result + + +def coordinator(runtime, store, recorded=None, exposure=None): + sink = recorded if recorded is not None else [] + return RemoteDeploymentCoordinator( + runtime_provider=runtime, + operation_store=store, + recorder=sink.append, + exposure_provider=exposure, + ) + + +def test_success_receipt_preserves_target_label_and_runtime_ownership(): + runtime, store, recorded = ( + Runtime(), + Store({"run-1": record(RUN, LifecycleState.SUCCEEDED)}), + [], + ) + receipt = coordinator(runtime, store, recorded).deploy(request()) + assert (receipt.provider, receipt.target, receipt.runtime_operation) == ("vps", TARGET, RUN) + assert receipt.runtime_ownership == runtime.ownership and receipt.exposure_ownership == () + assert receipt.outcome is LifecycleState.SUCCEEDED and recorded == [receipt] + + +def test_exposure_uses_empty_input_and_operation_matched_ownership(): + route = owner(EXPOSURE, "route-one") + result = ExposureResult( + operation=EXPOSURE, + outcome=ExposureOutcome.READY, + routing_status=ExposureCheckStatus.VERIFIED, + dns_status=ExposureCheckStatus.VERIFIED, + ready=True, + ownership=(route,), + ) + store = Store( + { + "run-1": record(RUN, LifecycleState.SUCCEEDED), + "exposure-1": record(EXPOSURE, LifecycleState.SUCCEEDED), + } + ) + exposure, req = Exposure(result), request(True) + receipt = coordinator(Runtime(), store, exposure=exposure).deploy(req) + assert exposure.requests[0].ownership == () and exposure.requests[0].operation == EXPOSURE + assert receipt.runtime_ownership != receipt.exposure_ownership == (route,) + + +def test_in_progress_exposure_fails_closed_without_receipt(): + result = ExposureResult(operation=EXPOSURE, outcome=ExposureOutcome.IN_PROGRESS) + pending = DurableOperationRecord( + identity=EXPOSURE, revision=OperationRevision(value=1), lifecycle=LifecycleState.IN_PROGRESS + ) + store = Store({"run-1": record(RUN, LifecycleState.SUCCEEDED), "exposure-1": pending}) + recorded = [] + with pytest.raises(RemoteDeploymentIncompleteError): + coordinator(Runtime(), store, recorded, Exposure(result)).deploy(request(True)) + assert recorded == [] + + +def test_adapter_failure_records_failed_evidence_and_reraises_same_exception(): + error, failed = RuntimeError("adapter failure"), record(RUN, LifecycleState.FAILED) + recorded = [] + with pytest.raises(RuntimeError) as raised: + coordinator(Runtime(error), Store({"run-1": failed}), recorded).deploy(request()) + assert ( + raised.value is error + and len(recorded) == 1 + and recorded[0].outcome is LifecycleState.FAILED + ) + + +def test_missing_terminal_commit_fails_closed(): + incomplete = DurableOperationRecord( + identity=RUN, revision=OperationRevision(value=1), lifecycle=LifecycleState.SUCCEEDED + ) + with pytest.raises(RemoteDeploymentIncompleteError): + coordinator(Runtime(), Store({"run-1": incomplete})).deploy(request()) From b6bda1f206c158da1b2342515705d6c07794b87d Mon Sep 17 00:00:00 2001 From: Angel Parra <607418+aparragithub@users.noreply.github.com> Date: Thu, 13 Aug 2026 14:43:51 -0500 Subject: [PATCH 2/4] fix(remote): preserve core adapter boundary --- src/odoo_forge/remote_deployment.py | 26 +++++++++--------- tests/remote_deployment/test_handoff.py | 35 ++++++++++++++----------- 2 files changed, 32 insertions(+), 29 deletions(-) diff --git a/src/odoo_forge/remote_deployment.py b/src/odoo_forge/remote_deployment.py index 21342a6..598f7f8 100644 --- a/src/odoo_forge/remote_deployment.py +++ b/src/odoo_forge/remote_deployment.py @@ -1,10 +1,7 @@ -"""Runtime-owned aggregate handoff for the canonical VPS deployment.""" - from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from typing import TYPE_CHECKING from odoo_forge.backend.plan import BackendPlan from odoo_forge.backend.status import InstanceRef @@ -14,19 +11,24 @@ from odoo_forge.ports.durable_operation_store import DurableOperationRecord, DurableOperationStore from odoo_forge.resource_ownership.types import OwnershipRecord -if TYPE_CHECKING: - from odoo_forge_docker.vps.provider import VpsTargetIdentity - class RemoteDeploymentIncompleteError(RuntimeError): pass +@dataclass(frozen=True) +class RemoteTargetFingerprint: + host: str + user: str + port: int + host_key: str + + @dataclass(frozen=True) class RemoteDeploymentRequest: deployment: DeploymentSpec plan: BackendPlan - target: VpsTargetIdentity + target: RemoteTargetFingerprint runtime_operation: DurableOperationIdentity exposure_operation: DurableOperationIdentity | None = None runtime_ownership: tuple[OwnershipRecord, ...] = () @@ -37,7 +39,7 @@ class RemoteDeploymentRequest: class RemoteDeploymentReceipt: deployment: DeploymentSpec provider: str - target: VpsTargetIdentity + target: RemoteTargetFingerprint runtime_ref: InstanceRef | None runtime_operation: DurableOperationIdentity runtime_ownership: tuple[OwnershipRecord, ...] @@ -65,8 +67,6 @@ def _valid_terminal(record: DurableOperationRecord, operation: DurableOperationI class RemoteDeploymentCoordinator: - """Compose existing VPS operations without owning adapter lifecycle.""" - def __init__( self, *, @@ -88,25 +88,24 @@ def deploy(self, request: RemoteDeploymentRequest) -> RemoteDeploymentReceipt: raise RemoteDeploymentIncompleteError("runtime ownership is inconsistent") if request.deployment.exposure is None and request.exposure_operation is not None: raise RemoteDeploymentIncompleteError("exposure operation has no exposure intent") + if request.deployment.exposure and request.exposure_operation == request.runtime_operation: + raise RemoteDeploymentIncompleteError("runtime and exposure operations must differ") try: runtime_ref = self._runtime_provider.run(request.plan) # type: ignore[attr-defined] except Exception: self._record_validated_failure(request) raise - if ( runtime_ref.project != request.deployment.pointer.scope.project_id or runtime_ref.instance != request.deployment.pointer.instance_id.value or runtime_ref.network != request.deployment.resource.identifier ): raise RemoteDeploymentIncompleteError("runtime identity is inconsistent") - runtime_record = self._operation_store.create_or_load(request.runtime_operation) if not _valid_terminal(runtime_record, request.runtime_operation): raise RemoteDeploymentIncompleteError("runtime evidence is incomplete") if runtime_record.lifecycle is not LifecycleState.SUCCEEDED: raise RemoteDeploymentIncompleteError("runtime operation did not succeed") - exposure_result: ExposureResult | None = None exposure_record: DurableOperationRecord | None = None exposure_ownership: tuple[OwnershipRecord, ...] = () @@ -132,7 +131,6 @@ def deploy(self, request: RemoteDeploymentRequest) -> RemoteDeploymentReceipt: ): raise RemoteDeploymentIncompleteError("exposure evidence is incomplete") exposure_ownership = exposure_result.ownership - receipt = RemoteDeploymentReceipt( deployment=request.deployment, provider="vps", diff --git a/tests/remote_deployment/test_handoff.py b/tests/remote_deployment/test_handoff.py index ce7486c..7f4bd30 100644 --- a/tests/remote_deployment/test_handoff.py +++ b/tests/remote_deployment/test_handoff.py @@ -23,6 +23,7 @@ RemoteDeploymentCoordinator, RemoteDeploymentIncompleteError, RemoteDeploymentRequest, + RemoteTargetFingerprint, ) from odoo_forge.resource_ownership.types import ( OwnershipReceipt, @@ -31,21 +32,20 @@ ResourceRef, ) from odoo_forge.tenancy.types import ProjectScope, TenantId -from odoo_forge_docker.vps.provider import VpsTargetIdentity SCOPE = ProjectScope(tenant=TenantId(value="tenant-1"), project_id="project-1") POINTER = InstancePointer(scope=SCOPE, instance_id=InstanceId(value="one")) -TARGET = VpsTargetIdentity(host="vps.example", user="deploy", port=22, host_key="ssh-ed25519") +TARGET = RemoteTargetFingerprint(host="vps.example", user="deploy", port=22, host_key="ssh-ed25519") RUN = DurableOperationIdentity(operation_id="run-1", request_digest="run-digest") EXPOSURE = DurableOperationIdentity(operation_id="exposure-1", request_digest="exposure-digest") def owner(operation: DurableOperationIdentity, identifier: str) -> OwnershipRecord: + ref = ResourceRef( + identifier=identifier, resource_kind="container", ownership=ResourceOwnership.CREATED + ) return OwnershipRecord( - ref=ResourceRef( - identifier=identifier, resource_kind="container", ownership=ResourceOwnership.CREATED - ), - receipt=OwnershipReceipt(operation=operation, owned_resource_ids=(identifier,)), + ref=ref, receipt=OwnershipReceipt(operation=operation, owned_resource_ids=(identifier,)) ) @@ -85,13 +85,13 @@ def plan() -> BackendPlan: return BackendPlan(network=network, volumes=[], postgres=db, odoo=odoo) -def request(exposed: bool = False) -> RemoteDeploymentRequest: +def request(exposed=False, exposure_operation=EXPOSURE): return RemoteDeploymentRequest( deployment=deployment(exposed), plan=plan(), target=TARGET, runtime_operation=RUN, - exposure_operation=EXPOSURE if exposed else None, + exposure_operation=exposure_operation if exposed else None, runtime_ownership=(owner(RUN, "odoo-one"),), ) @@ -110,17 +110,14 @@ def record(operation: DurableOperationIdentity, outcome: LifecycleState) -> Dura ) -class Store: - def __init__(self, records): - self.records = records - +class Store(dict): def create_or_load(self, operation): - return self.records[operation.operation_id] + return self[operation.operation_id] class Runtime: def __init__(self, error=None): - self.error, self.ownership, self.calls = error, (owner(RUN, "odoo-one"),), [] + self.error, self.calls = error, [] def run(self, value): self.calls.append(value) @@ -162,7 +159,9 @@ def test_success_receipt_preserves_target_label_and_runtime_ownership(): ) receipt = coordinator(runtime, store, recorded).deploy(request()) assert (receipt.provider, receipt.target, receipt.runtime_operation) == ("vps", TARGET, RUN) - assert receipt.runtime_ownership == runtime.ownership and receipt.exposure_ownership == () + assert ( + receipt.runtime_ownership == (owner(RUN, "odoo-one"),) and receipt.exposure_ownership == () + ) assert receipt.outcome is LifecycleState.SUCCEEDED and recorded == [receipt] @@ -200,6 +199,12 @@ def test_in_progress_exposure_fails_closed_without_receipt(): assert recorded == [] +def test_same_runtime_and_exposure_operation_is_rejected_before_provider_call(): + with pytest.raises(RemoteDeploymentIncompleteError): + coordinator((runtime := Runtime()), Store({})).deploy(request(True, exposure_operation=RUN)) + assert runtime.calls == [] + + def test_adapter_failure_records_failed_evidence_and_reraises_same_exception(): error, failed = RuntimeError("adapter failure"), record(RUN, LifecycleState.FAILED) recorded = [] From b7bf74bf6e8403de7d8e581f02f7ca1ede54634e Mon Sep 17 00:00:00 2001 From: Angel Parra <607418+aparragithub@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:40:16 -0500 Subject: [PATCH 3/4] fix(remote): satisfy strict typing gate --- src/odoo_forge/remote_deployment.py | 8 +- tests/remote_deployment/test_handoff.py | 151 ++++++++++++------------ 2 files changed, 78 insertions(+), 81 deletions(-) diff --git a/src/odoo_forge/remote_deployment.py b/src/odoo_forge/remote_deployment.py index 598f7f8..8c97f98 100644 --- a/src/odoo_forge/remote_deployment.py +++ b/src/odoo_forge/remote_deployment.py @@ -1,10 +1,9 @@ -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from odoo_forge.backend.plan import BackendPlan from odoo_forge.backend.status import InstanceRef +from odoo_forge.credentials.types import CredentialHandle from odoo_forge.deployment_spec.types import DeploymentSpec from odoo_forge.durable_operations.types import DurableOperationIdentity, LifecycleState from odoo_forge.exposure.types import ExposureOutcome, ExposureRequest, ExposureResult @@ -12,8 +11,7 @@ from odoo_forge.resource_ownership.types import OwnershipRecord -class RemoteDeploymentIncompleteError(RuntimeError): - pass +class RemoteDeploymentIncompleteError(RuntimeError): ... @dataclass(frozen=True) @@ -32,7 +30,7 @@ class RemoteDeploymentRequest: runtime_operation: DurableOperationIdentity exposure_operation: DurableOperationIdentity | None = None runtime_ownership: tuple[OwnershipRecord, ...] = () - exposure_credential_handles: tuple[object, ...] = () + exposure_credential_handles: tuple[CredentialHandle, ...] = () @dataclass(frozen=True) diff --git a/tests/remote_deployment/test_handoff.py b/tests/remote_deployment/test_handoff.py index 7f4bd30..2b8d5ce 100644 --- a/tests/remote_deployment/test_handoff.py +++ b/tests/remote_deployment/test_handoff.py @@ -1,14 +1,11 @@ +from typing import cast + import pytest +import odoo_forge.deployment_spec.types as deployment_types +import odoo_forge.exposure.types as exposure_types from odoo_forge.backend.plan import BackendPlan, ContainerSpec, NetworkSpec from odoo_forge.backend.status import InstanceRef -from odoo_forge.deployment_spec.types import ( - DeploymentSpec, - ExposureIntent, - OdooRuntimeIntent, - RequirementPolicy, - RouteProtocol, -) from odoo_forge.durable_operations.service import build_terminal_commit, save_checkpoint from odoo_forge.durable_operations.types import ( DurableOperationIdentity, @@ -16,12 +13,12 @@ OperationRevision, RedactedEvidence, ) -from odoo_forge.exposure.types import ExposureCheckStatus, ExposureOutcome, ExposureResult from odoo_forge.instance_registry.types import InstanceId, InstancePointer -from odoo_forge.ports.durable_operation_store import DurableOperationRecord +from odoo_forge.ports.durable_operation_store import DurableOperationRecord, DurableOperationStore from odoo_forge.remote_deployment import ( RemoteDeploymentCoordinator, RemoteDeploymentIncompleteError, + RemoteDeploymentReceipt, RemoteDeploymentRequest, RemoteTargetFingerprint, ) @@ -38,31 +35,32 @@ TARGET = RemoteTargetFingerprint(host="vps.example", user="deploy", port=22, host_key="ssh-ed25519") RUN = DurableOperationIdentity(operation_id="run-1", request_digest="run-digest") EXPOSURE = DurableOperationIdentity(operation_id="exposure-1", request_digest="exposure-digest") +REVISION = OperationRevision(value=1) def owner(operation: DurableOperationIdentity, identifier: str) -> OwnershipRecord: - ref = ResourceRef( - identifier=identifier, resource_kind="container", ownership=ResourceOwnership.CREATED - ) return OwnershipRecord( - ref=ref, receipt=OwnershipReceipt(operation=operation, owned_resource_ids=(identifier,)) + ref=ResourceRef( + identifier=identifier, resource_kind="container", ownership=ResourceOwnership.CREATED + ), + receipt=OwnershipReceipt(operation=operation, owned_resource_ids=(identifier,)), ) -def deployment(exposed: bool = False) -> DeploymentSpec: - return DeploymentSpec( +def deployment(exposed: bool = False) -> deployment_types.DeploymentSpec: + return deployment_types.DeploymentSpec( pointer=POINTER, resource=ResourceRef( identifier="odoo-forge-project-1-one", resource_kind="network", ownership=ResourceOwnership.CREATED, ), - runtime=OdooRuntimeIntent(odoo_version="18.0"), - exposure=ExposureIntent( + runtime=deployment_types.OdooRuntimeIntent(odoo_version="18.0"), + exposure=deployment_types.ExposureIntent( hostname="one.example", - protocol=RouteProtocol.HTTP, - dns=RequirementPolicy.REQUIRED, - tls=RequirementPolicy.DISABLED, + protocol=deployment_types.RouteProtocol.HTTP, + dns=deployment_types.RequirementPolicy.REQUIRED, + tls=deployment_types.RequirementPolicy.DISABLED, ) if exposed else None, @@ -71,21 +69,22 @@ def deployment(exposed: bool = False) -> DeploymentSpec: def plan() -> BackendPlan: network = NetworkSpec(name="odoo-forge-project-1-one", labels={"managed": "true"}) - db = ContainerSpec( - name="db-one", image="postgres:16", role="postgres", network=network.name, env={}, labels={} - ) - odoo = ContainerSpec( - name="odoo-one", - image="odoo-forge-odoo:18.0", - role="odoo", - network=network.name, - env={}, - labels={}, + common = {"network": network.name, "env": {}, "labels": {}} + db, odoo = ( + ContainerSpec.model_validate( + {"name": "db-one", "image": "postgres:16", "role": "postgres", **common} + ), + ContainerSpec.model_validate( + {"name": "odoo-one", "image": "odoo-forge-odoo:18.0", "role": "odoo", **common} + ), ) return BackendPlan(network=network, volumes=[], postgres=db, odoo=odoo) -def request(exposed=False, exposure_operation=EXPOSURE): +def request( + exposed: bool = False, + exposure_operation: DurableOperationIdentity = EXPOSURE, +) -> RemoteDeploymentRequest: return RemoteDeploymentRequest( deployment=deployment(exposed), plan=plan(), @@ -98,28 +97,27 @@ def request(exposed=False, exposure_operation=EXPOSURE): def record(operation: DurableOperationIdentity, outcome: LifecycleState) -> DurableOperationRecord: evidence = RedactedEvidence(event="terminal", summary="operation reached terminal state") - checkpoint = save_checkpoint(OperationRevision(value=0), "ready", evidence) - terminal = build_terminal_commit(OperationRevision(value=1), outcome, (evidence,), ()) return DurableOperationRecord( - identity=operation, - revision=OperationRevision(value=2), - lifecycle=outcome, - checkpoint=checkpoint, - terminal_commit=terminal, - recovery_evidence=(evidence,), + operation, + OperationRevision(value=2), + outcome, + save_checkpoint(OperationRevision(value=0), "ready", evidence), + build_terminal_commit(OperationRevision(value=1), outcome, (evidence,), ()), + (evidence,), ) -class Store(dict): - def create_or_load(self, operation): +class Store(dict[str, DurableOperationRecord]): + def create_or_load(self, operation: DurableOperationIdentity) -> DurableOperationRecord: return self[operation.operation_id] class Runtime: - def __init__(self, error=None): - self.error, self.calls = error, [] + def __init__(self, error: Exception | None = None) -> None: + self.error = error + self.calls: list[BackendPlan] = [] - def run(self, value): + def run(self, value: BackendPlan) -> InstanceRef: self.calls.append(value) if self.error: raise self.error @@ -133,45 +131,48 @@ def run(self, value): class Exposure: - def __init__(self, result): - self.result, self.requests = result, [] + def __init__(self, result: exposure_types.ExposureResult) -> None: + self.result = result + self.requests: list[exposure_types.ExposureRequest] = [] - def reconcile(self, request): + def reconcile(self, request: exposure_types.ExposureRequest) -> exposure_types.ExposureResult: self.requests.append(request) return self.result -def coordinator(runtime, store, recorded=None, exposure=None): - sink = recorded if recorded is not None else [] +def coordinator( + runtime: Runtime, + store: Store, + recorded: list[RemoteDeploymentReceipt] | None = None, + exposure: Exposure | None = None, +) -> RemoteDeploymentCoordinator: return RemoteDeploymentCoordinator( runtime_provider=runtime, - operation_store=store, - recorder=sink.append, + operation_store=cast(DurableOperationStore, store), + recorder=(recorded if recorded is not None else []).append, exposure_provider=exposure, ) -def test_success_receipt_preserves_target_label_and_runtime_ownership(): - runtime, store, recorded = ( - Runtime(), - Store({"run-1": record(RUN, LifecycleState.SUCCEEDED)}), - [], - ) +def test_success_receipt_preserves_target_label_and_runtime_ownership() -> None: + recorded: list[RemoteDeploymentReceipt] = [] + runtime, store = Runtime(), Store({"run-1": record(RUN, LifecycleState.SUCCEEDED)}) receipt = coordinator(runtime, store, recorded).deploy(request()) assert (receipt.provider, receipt.target, receipt.runtime_operation) == ("vps", TARGET, RUN) - assert ( - receipt.runtime_ownership == (owner(RUN, "odoo-one"),) and receipt.exposure_ownership == () + assert (receipt.runtime_ownership, receipt.exposure_ownership) == ( + (owner(RUN, "odoo-one"),), + (), ) assert receipt.outcome is LifecycleState.SUCCEEDED and recorded == [receipt] -def test_exposure_uses_empty_input_and_operation_matched_ownership(): +def test_exposure_uses_empty_input_and_operation_matched_ownership() -> None: route = owner(EXPOSURE, "route-one") - result = ExposureResult( + result = exposure_types.ExposureResult( operation=EXPOSURE, - outcome=ExposureOutcome.READY, - routing_status=ExposureCheckStatus.VERIFIED, - dns_status=ExposureCheckStatus.VERIFIED, + outcome=exposure_types.ExposureOutcome.READY, + routing_status=exposure_types.ExposureCheckStatus.VERIFIED, + dns_status=exposure_types.ExposureCheckStatus.VERIFIED, ready=True, ownership=(route,), ) @@ -187,27 +188,27 @@ def test_exposure_uses_empty_input_and_operation_matched_ownership(): assert receipt.runtime_ownership != receipt.exposure_ownership == (route,) -def test_in_progress_exposure_fails_closed_without_receipt(): - result = ExposureResult(operation=EXPOSURE, outcome=ExposureOutcome.IN_PROGRESS) - pending = DurableOperationRecord( - identity=EXPOSURE, revision=OperationRevision(value=1), lifecycle=LifecycleState.IN_PROGRESS +def test_in_progress_exposure_fails_closed_without_receipt() -> None: + result = exposure_types.ExposureResult( + operation=EXPOSURE, outcome=exposure_types.ExposureOutcome.IN_PROGRESS ) + pending = DurableOperationRecord(EXPOSURE, REVISION, LifecycleState.IN_PROGRESS) store = Store({"run-1": record(RUN, LifecycleState.SUCCEEDED), "exposure-1": pending}) - recorded = [] + recorded: list[RemoteDeploymentReceipt] = [] with pytest.raises(RemoteDeploymentIncompleteError): coordinator(Runtime(), store, recorded, Exposure(result)).deploy(request(True)) assert recorded == [] -def test_same_runtime_and_exposure_operation_is_rejected_before_provider_call(): +def test_same_runtime_and_exposure_operation_is_rejected_before_provider_call() -> None: with pytest.raises(RemoteDeploymentIncompleteError): coordinator((runtime := Runtime()), Store({})).deploy(request(True, exposure_operation=RUN)) assert runtime.calls == [] -def test_adapter_failure_records_failed_evidence_and_reraises_same_exception(): +def test_adapter_failure_records_failed_evidence_and_reraises_same_exception() -> None: error, failed = RuntimeError("adapter failure"), record(RUN, LifecycleState.FAILED) - recorded = [] + recorded: list[RemoteDeploymentReceipt] = [] with pytest.raises(RuntimeError) as raised: coordinator(Runtime(error), Store({"run-1": failed}), recorded).deploy(request()) assert ( @@ -217,9 +218,7 @@ def test_adapter_failure_records_failed_evidence_and_reraises_same_exception(): ) -def test_missing_terminal_commit_fails_closed(): - incomplete = DurableOperationRecord( - identity=RUN, revision=OperationRevision(value=1), lifecycle=LifecycleState.SUCCEEDED - ) +def test_missing_terminal_commit_fails_closed() -> None: + incomplete = DurableOperationRecord(RUN, REVISION, LifecycleState.SUCCEEDED) with pytest.raises(RemoteDeploymentIncompleteError): coordinator(Runtime(), Store({"run-1": incomplete})).deploy(request()) From cb5682afc00843627cdf1c8bd04f5d2d16e6f45c Mon Sep 17 00:00:00 2001 From: Angel Parra <607418+aparragithub@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:45:44 -0500 Subject: [PATCH 4/4] fix(remote): satisfy CodeQL exception body --- src/odoo_forge/remote_deployment.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/odoo_forge/remote_deployment.py b/src/odoo_forge/remote_deployment.py index 8c97f98..e8289a1 100644 --- a/src/odoo_forge/remote_deployment.py +++ b/src/odoo_forge/remote_deployment.py @@ -11,7 +11,8 @@ from odoo_forge.resource_ownership.types import OwnershipRecord -class RemoteDeploymentIncompleteError(RuntimeError): ... +class RemoteDeploymentIncompleteError(RuntimeError): + pass @dataclass(frozen=True)