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
103 changes: 102 additions & 1 deletion src/odoo_forge_cli/_composition.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down
141 changes: 141 additions & 0 deletions tests/cli/test_remote_deployment_composition.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
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] = []
dependencies: list[
tuple[DurableOperationStore, VpsMechanics | None, CredentialResolver | None]
] = []
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)
dependencies.append((store, mechanics, credentials))
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=(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"),)
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,
*,
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)

_composition._make_remote_deployment_coordinator(
scope=SCOPE,
target=TARGET,
runtime_operation=RUNTIME,
runtime_credential_handles=(),
operation_store=store,
mechanics=mechanics,
credentials=resolver,
recorder=lambda _receipt: None,
)

assert len(bindings) == 1
assert bindings[0].verb == "run"
assert len(dependencies) == 1
assert dependencies[0][0] is store
assert dependencies[0][1] is mechanics
assert dependencies[0][2] is resolver