diff --git a/src/odoo_forge/remote_deployment.py b/src/odoo_forge/remote_deployment.py new file mode 100644 index 0000000..e8289a1 --- /dev/null +++ b/src/odoo_forge/remote_deployment.py @@ -0,0 +1,174 @@ +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 +from odoo_forge.ports.durable_operation_store import DurableOperationRecord, DurableOperationStore +from odoo_forge.resource_ownership.types import OwnershipRecord + + +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: RemoteTargetFingerprint + runtime_operation: DurableOperationIdentity + exposure_operation: DurableOperationIdentity | None = None + runtime_ownership: tuple[OwnershipRecord, ...] = () + exposure_credential_handles: tuple[CredentialHandle, ...] = () + + +@dataclass(frozen=True) +class RemoteDeploymentReceipt: + deployment: DeploymentSpec + provider: str + target: RemoteTargetFingerprint + 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: + 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") + 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, ...] = () + 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..2b8d5ce --- /dev/null +++ b/tests/remote_deployment/test_handoff.py @@ -0,0 +1,224 @@ +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.durable_operations.service import build_terminal_commit, save_checkpoint +from odoo_forge.durable_operations.types import ( + DurableOperationIdentity, + LifecycleState, + OperationRevision, + RedactedEvidence, +) +from odoo_forge.instance_registry.types import InstanceId, InstancePointer +from odoo_forge.ports.durable_operation_store import DurableOperationRecord, DurableOperationStore +from odoo_forge.remote_deployment import ( + RemoteDeploymentCoordinator, + RemoteDeploymentIncompleteError, + RemoteDeploymentReceipt, + RemoteDeploymentRequest, + RemoteTargetFingerprint, +) +from odoo_forge.resource_ownership.types import ( + OwnershipReceipt, + OwnershipRecord, + ResourceOwnership, + ResourceRef, +) +from odoo_forge.tenancy.types import ProjectScope, TenantId + +SCOPE = ProjectScope(tenant=TenantId(value="tenant-1"), project_id="project-1") +POINTER = InstancePointer(scope=SCOPE, instance_id=InstanceId(value="one")) +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: + 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) -> deployment_types.DeploymentSpec: + return deployment_types.DeploymentSpec( + pointer=POINTER, + resource=ResourceRef( + identifier="odoo-forge-project-1-one", + resource_kind="network", + ownership=ResourceOwnership.CREATED, + ), + runtime=deployment_types.OdooRuntimeIntent(odoo_version="18.0"), + exposure=deployment_types.ExposureIntent( + hostname="one.example", + protocol=deployment_types.RouteProtocol.HTTP, + dns=deployment_types.RequirementPolicy.REQUIRED, + tls=deployment_types.RequirementPolicy.DISABLED, + ) + if exposed + else None, + ) + + +def plan() -> BackendPlan: + network = NetworkSpec(name="odoo-forge-project-1-one", labels={"managed": "true"}) + 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: bool = False, + exposure_operation: DurableOperationIdentity = EXPOSURE, +) -> RemoteDeploymentRequest: + return RemoteDeploymentRequest( + deployment=deployment(exposed), + plan=plan(), + target=TARGET, + runtime_operation=RUN, + exposure_operation=exposure_operation 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") + return DurableOperationRecord( + operation, + OperationRevision(value=2), + outcome, + save_checkpoint(OperationRevision(value=0), "ready", evidence), + build_terminal_commit(OperationRevision(value=1), outcome, (evidence,), ()), + (evidence,), + ) + + +class Store(dict[str, DurableOperationRecord]): + def create_or_load(self, operation: DurableOperationIdentity) -> DurableOperationRecord: + return self[operation.operation_id] + + +class Runtime: + def __init__(self, error: Exception | None = None) -> None: + self.error = error + self.calls: list[BackendPlan] = [] + + def run(self, value: BackendPlan) -> InstanceRef: + 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: exposure_types.ExposureResult) -> None: + self.result = result + self.requests: list[exposure_types.ExposureRequest] = [] + + def reconcile(self, request: exposure_types.ExposureRequest) -> exposure_types.ExposureResult: + self.requests.append(request) + return self.result + + +def coordinator( + runtime: Runtime, + store: Store, + recorded: list[RemoteDeploymentReceipt] | None = None, + exposure: Exposure | None = None, +) -> RemoteDeploymentCoordinator: + return RemoteDeploymentCoordinator( + runtime_provider=runtime, + 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() -> 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, 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() -> None: + route = owner(EXPOSURE, "route-one") + result = exposure_types.ExposureResult( + operation=EXPOSURE, + outcome=exposure_types.ExposureOutcome.READY, + routing_status=exposure_types.ExposureCheckStatus.VERIFIED, + dns_status=exposure_types.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() -> 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: 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() -> 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() -> None: + error, failed = RuntimeError("adapter failure"), record(RUN, LifecycleState.FAILED) + recorded: list[RemoteDeploymentReceipt] = [] + 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() -> None: + incomplete = DurableOperationRecord(RUN, REVISION, LifecycleState.SUCCEEDED) + with pytest.raises(RemoteDeploymentIncompleteError): + coordinator(Runtime(), Store({"run-1": incomplete})).deploy(request())