-
Notifications
You must be signed in to change notification settings - Fork 0
feat(remote): add deployment coordinator #218
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
0101995
feat(remote): add deployment coordinator
aparragithub b6bda1f
fix(remote): preserve core adapter boundary
aparragithub b7bf74b
fix(remote): satisfy strict typing gate
aparragithub cb5682a
fix(remote): satisfy CodeQL exception body
aparragithub File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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") | ||
| 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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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()) |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.