Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 174 additions & 0 deletions src/odoo_forge/remote_deployment.py
Original file line number Diff line number Diff line change
@@ -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")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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
224 changes: 224 additions & 0 deletions tests/remote_deployment/test_handoff.py
Original file line number Diff line number Diff line change
@@ -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())