From 05e1df204c15d5ecabe0add46373215809490c0b Mon Sep 17 00:00:00 2001 From: Angel Parra <607418+aparragithub@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:59:37 -0500 Subject: [PATCH 1/2] feat(remote): compose VPS deployment handoff --- src/odoo_forge_cli/_composition.py | 103 ++++++++++++++- .../cli/test_remote_deployment_composition.py | 120 ++++++++++++++++++ 2 files changed, 222 insertions(+), 1 deletion(-) create mode 100644 tests/cli/test_remote_deployment_composition.py diff --git a/src/odoo_forge_cli/_composition.py b/src/odoo_forge_cli/_composition.py index 01f8f69..a75317e 100644 --- a/src/odoo_forge_cli/_composition.py +++ b/src/odoo_forge_cli/_composition.py @@ -12,22 +12,45 @@ from pathlib import Path from odoo_forge.anonymization.apply import MaskTransform +from odoo_forge.backend.plan import BackendPlan from odoo_forge.credentials.errors import CredentialUnavailableError -from odoo_forge.credentials.types import CredentialHandle, CredentialInjectionDescriptor +from odoo_forge.credentials.types import ( + CredentialHandle, + CredentialInjectionDescriptor, + CredentialResolver, +) from odoo_forge.data_artifacts.coordinator import DataArtifactCopyCoordinator from odoo_forge.data_artifacts.staging import StagedArtifactStore +from odoo_forge.deployment_spec.types import DeploymentSpec +from odoo_forge.durable_operations.types import DurableOperationIdentity from odoo_forge.manifest.schema import Manifest from odoo_forge.ports.backend_provider import BackendProvider from odoo_forge.ports.database_provider import DatabaseProvider +from odoo_forge.ports.durable_operation_store import DurableOperationStore from odoo_forge.ports.pipeline_provider import PipelineProvider from odoo_forge.ports.published_artifact_resolver import PublishedArtifactResolver from odoo_forge.ports.source_provider import SourceProvider from odoo_forge.ports.workspace_provider import WorkspaceProvider from odoo_forge.project_catalog.interfaces import CatalogIndex +from odoo_forge.remote_deployment import ( + Recorder, + RemoteDeploymentCoordinator, + RemoteDeploymentRequest, + RemoteTargetFingerprint, +) +from odoo_forge.resource_ownership.types import OwnershipRecord +from odoo_forge.tenancy.types import ProjectScope from odoo_forge_catalog import YamlCatalogIndex from odoo_forge_docker.credential_injection import SopsCommandResolver, SopsEnvFileInjector from odoo_forge_docker.ownership import BackendOwnershipCustody from odoo_forge_docker.provider import DockerBackendProvider +from odoo_forge_docker.vps.provider import ( + VpsBackendProvider, + VpsMechanics, + VpsOperationBinding, + VpsTargetIdentity, + bind_vps_operation, +) from odoo_forge_git.git_provider import GitSourceProvider from odoo_forge_pipeline_github.provider import GitHubActionsPipelineProvider from odoo_forge_pipeline_github.transport import GitHubActionsRestTransport @@ -139,6 +162,84 @@ def _make_backend_provider( ) +def _make_remote_deployment_request( + *, + deployment: DeploymentSpec, + plan: BackendPlan, + target: VpsTargetIdentity, + runtime_operation: DurableOperationIdentity, + exposure_operation: DurableOperationIdentity | None = None, + runtime_ownership: tuple[OwnershipRecord, ...] = (), + exposure_credential_handles: tuple[CredentialHandle, ...] = (), +) -> RemoteDeploymentRequest: + """Compose the provider target into the core's provider-neutral request value.""" + return RemoteDeploymentRequest( + deployment=deployment, + plan=plan, + target=RemoteTargetFingerprint( + host=target.host, + user=target.user, + port=target.port, + host_key=target.host_key, + ), + runtime_operation=runtime_operation, + exposure_operation=exposure_operation, + runtime_ownership=runtime_ownership, + exposure_credential_handles=exposure_credential_handles, + ) + + +def _make_remote_deployment_coordinator( + *, + scope: ProjectScope, + target: VpsTargetIdentity, + runtime_operation: DurableOperationIdentity, + runtime_credential_handles: tuple[CredentialHandle, ...], + operation_store: DurableOperationStore, + mechanics: VpsMechanics, + credentials: CredentialResolver, + recorder: Recorder, + runtime_ownership: tuple[OwnershipRecord, ...] = (), + exposure_operation: DurableOperationIdentity | None = None, + exposure_credential_handles: tuple[CredentialHandle, ...] = (), +) -> RemoteDeploymentCoordinator: + """Compose the fixed VPS runtime and optional exposure adapter operations.""" + runtime_provider: VpsBackendProvider = bind_vps_operation( + VpsOperationBinding( + scope=scope, + operation=runtime_operation, + verb="run", + ownership=runtime_ownership, + target=target, + credential_handles=runtime_credential_handles, + ), + store=operation_store, + mechanics=mechanics, + credentials=credentials, + ) + exposure_provider: VpsBackendProvider | None = None + if exposure_operation is not None: + exposure_provider = bind_vps_operation( + VpsOperationBinding( + scope=scope, + operation=exposure_operation, + verb="reconcile", + ownership=(), + target=target, + credential_handles=exposure_credential_handles, + ), + store=operation_store, + mechanics=mechanics, + credentials=credentials, + ) + return RemoteDeploymentCoordinator( + runtime_provider=runtime_provider, + operation_store=operation_store, + recorder=recorder, + exposure_provider=exposure_provider, + ) + + def _make_mask_transform(store: StagedArtifactStore) -> MaskTransform: """Composition root: the ONE place the concrete `MaskTransform` is built. diff --git a/tests/cli/test_remote_deployment_composition.py b/tests/cli/test_remote_deployment_composition.py new file mode 100644 index 0000000..31e790e --- /dev/null +++ b/tests/cli/test_remote_deployment_composition.py @@ -0,0 +1,120 @@ +from typing import Any, cast + +from odoo_forge.backend.plan import BackendPlan +from odoo_forge.credentials.types import CredentialHandle, CredentialResolver +from odoo_forge.deployment_spec.types import DeploymentSpec +from odoo_forge.durable_operations.types import DurableOperationIdentity +from odoo_forge.ports.durable_operation_store import DurableOperationStore +from odoo_forge.remote_deployment import ( + RemoteDeploymentCoordinator, + RemoteTargetFingerprint, +) +from odoo_forge.resource_ownership.types import OwnershipRecord +from odoo_forge.tenancy.types import ProjectScope, TenantId +from odoo_forge_cli import _composition +from odoo_forge_docker.vps.provider import VpsMechanics, VpsOperationBinding, VpsTargetIdentity + +SCOPE = ProjectScope(tenant=TenantId(value="tenant-1"), project_id="project-1") +TARGET = VpsTargetIdentity( + host="vps.example.test", user="deploy", port=22, host_key="ssh-ed25519 AAAA" +) +RUNTIME = DurableOperationIdentity(operation_id="run-1", request_digest="run-digest") +EXPOSURE = DurableOperationIdentity(operation_id="exposure-1", request_digest="exposure-digest") + + +def test_request_translation_keeps_provider_identity_out_of_core() -> None: + request = _composition._make_remote_deployment_request( + deployment=cast(DeploymentSpec, object()), + plan=cast(BackendPlan, object()), + target=TARGET, + runtime_operation=RUNTIME, + exposure_operation=EXPOSURE, + ) + + assert request.target == RemoteTargetFingerprint( + host=TARGET.host, user=TARGET.user, port=TARGET.port, host_key=TARGET.host_key + ) + assert request.target != cast(Any, "vps") + + +def test_coordinator_binds_run_and_reconcile_with_separate_operations( + monkeypatch: Any, +) -> None: + bindings: list[VpsOperationBinding] = [] + providers: list[object] = [] + store = cast(DurableOperationStore, object()) + mechanics = cast(VpsMechanics, object()) + resolver = cast(CredentialResolver, lambda _handle: "private-key") + runtime_ownership = (cast(OwnershipRecord, object()),) + + def bind( + binding: VpsOperationBinding, + *, + store: DurableOperationStore, + mechanics: VpsMechanics | None = None, + credentials: CredentialResolver | None = None, + ) -> object: + bindings.append(binding) + assert store is not None and mechanics is not None and credentials is not None + provider = object() + providers.append(provider) + return provider + + monkeypatch.setattr(_composition, "bind_vps_operation", bind) + + coordinator = _composition._make_remote_deployment_coordinator( + scope=SCOPE, + target=TARGET, + runtime_operation=RUNTIME, + runtime_credential_handles=(CredentialHandle("runtime"),), + exposure_operation=EXPOSURE, + exposure_credential_handles=(CredentialHandle("exposure"),), + runtime_ownership=runtime_ownership, + operation_store=store, + mechanics=mechanics, + credentials=resolver, + recorder=lambda _receipt: None, + ) + + assert isinstance(coordinator, RemoteDeploymentCoordinator) + assert len(bindings) == 2 + runtime_binding, exposure_binding = bindings + assert runtime_binding.operation == RUNTIME + assert runtime_binding.verb == "run" + assert runtime_binding.ownership == runtime_ownership + assert runtime_binding.target is TARGET + assert runtime_binding.credential_handles == (CredentialHandle("runtime"),) + assert exposure_binding.operation == EXPOSURE + assert exposure_binding.verb == "reconcile" + assert exposure_binding.ownership == () + assert exposure_binding.target is TARGET + assert exposure_binding.credential_handles == (CredentialHandle("exposure"),) + assert coordinator._runtime_provider is providers[0] + assert coordinator._exposure_provider is providers[1] + + +def test_coordinator_omits_reconcile_binding_when_exposure_is_absent( + monkeypatch: Any, +) -> None: + bindings: list[VpsOperationBinding] = [] + + def bind(binding: VpsOperationBinding, **_kwargs: Any) -> object: + bindings.append(binding) + return object() + + monkeypatch.setattr(_composition, "bind_vps_operation", bind) + + coordinator = _composition._make_remote_deployment_coordinator( + scope=SCOPE, + target=TARGET, + runtime_operation=RUNTIME, + runtime_credential_handles=(), + operation_store=cast(DurableOperationStore, object()), + mechanics=cast(VpsMechanics, object()), + credentials=cast(CredentialResolver, lambda _handle: "private-key"), + recorder=lambda _receipt: None, + ) + + assert len(bindings) == 1 + assert bindings[0].verb == "run" + assert coordinator._exposure_provider is None From 221705d51afbb847015db6b8039d63b17e7ff531 Mon Sep 17 00:00:00 2001 From: Angel Parra <607418+aparragithub@users.noreply.github.com> Date: Thu, 13 Aug 2026 17:08:53 -0500 Subject: [PATCH 2/2] test(remote): verify observable composition contract --- .../cli/test_remote_deployment_composition.py | 47 ++++++++++++++----- 1 file changed, 34 insertions(+), 13 deletions(-) diff --git a/tests/cli/test_remote_deployment_composition.py b/tests/cli/test_remote_deployment_composition.py index 31e790e..db17fd6 100644 --- a/tests/cli/test_remote_deployment_composition.py +++ b/tests/cli/test_remote_deployment_composition.py @@ -41,7 +41,9 @@ def test_coordinator_binds_run_and_reconcile_with_separate_operations( monkeypatch: Any, ) -> None: bindings: list[VpsOperationBinding] = [] - providers: list[object] = [] + dependencies: list[ + tuple[DurableOperationStore, VpsMechanics | None, CredentialResolver | None] + ] = [] store = cast(DurableOperationStore, object()) mechanics = cast(VpsMechanics, object()) resolver = cast(CredentialResolver, lambda _handle: "private-key") @@ -55,10 +57,8 @@ def bind( credentials: CredentialResolver | None = None, ) -> object: bindings.append(binding) - assert store is not None and mechanics is not None and credentials is not None - provider = object() - providers.append(provider) - return provider + dependencies.append((store, mechanics, credentials)) + return object() monkeypatch.setattr(_composition, "bind_vps_operation", bind) @@ -89,32 +89,53 @@ def bind( assert exposure_binding.ownership == () assert exposure_binding.target is TARGET assert exposure_binding.credential_handles == (CredentialHandle("exposure"),) - assert coordinator._runtime_provider is providers[0] - assert coordinator._exposure_provider is providers[1] + runtime_dependencies, exposure_dependencies = dependencies + assert runtime_dependencies[0] is store + assert runtime_dependencies[1] is mechanics + assert runtime_dependencies[2] is resolver + assert exposure_dependencies[0] is store + assert exposure_dependencies[1] is mechanics + assert exposure_dependencies[2] is resolver def test_coordinator_omits_reconcile_binding_when_exposure_is_absent( monkeypatch: Any, ) -> None: bindings: list[VpsOperationBinding] = [] + dependencies: list[ + tuple[DurableOperationStore, VpsMechanics | None, CredentialResolver | None] + ] = [] + store = cast(DurableOperationStore, object()) + mechanics = cast(VpsMechanics, object()) + resolver = cast(CredentialResolver, lambda _handle: "private-key") - def bind(binding: VpsOperationBinding, **_kwargs: Any) -> object: + def bind( + binding: VpsOperationBinding, + *, + store: DurableOperationStore, + mechanics: VpsMechanics | None = None, + credentials: CredentialResolver | None = None, + ) -> object: bindings.append(binding) + dependencies.append((store, mechanics, credentials)) return object() monkeypatch.setattr(_composition, "bind_vps_operation", bind) - coordinator = _composition._make_remote_deployment_coordinator( + _composition._make_remote_deployment_coordinator( scope=SCOPE, target=TARGET, runtime_operation=RUNTIME, runtime_credential_handles=(), - operation_store=cast(DurableOperationStore, object()), - mechanics=cast(VpsMechanics, object()), - credentials=cast(CredentialResolver, lambda _handle: "private-key"), + operation_store=store, + mechanics=mechanics, + credentials=resolver, recorder=lambda _receipt: None, ) assert len(bindings) == 1 assert bindings[0].verb == "run" - assert coordinator._exposure_provider is None + assert len(dependencies) == 1 + assert dependencies[0][0] is store + assert dependencies[0][1] is mechanics + assert dependencies[0][2] is resolver