From 51a042d326c41dc7b100e45d3fc200f186291ef9 Mon Sep 17 00:00:00 2001 From: Chris Busillo Date: Mon, 25 May 2026 22:23:07 -0400 Subject: [PATCH] Add Odoo preview apply inputs route --- control_plane/drivers/registry.py | 13 +- control_plane/service.py | 80 +++- .../workflows/generic_web_preview.py | 17 +- .../workflows/odoo_preview_runtime.py | 400 +++++++++++++++++- docs/driver-descriptors.md | 12 + docs/preview-workflow-contract.md | 11 +- docs/service-boundary.md | 16 + tests/test_driver_descriptors.py | 5 + tests/test_product_environment_read_model.py | 4 + tests/test_service.py | 191 +++++++++ 10 files changed, 726 insertions(+), 23 deletions(-) diff --git a/control_plane/drivers/registry.py b/control_plane/drivers/registry.py index fc77e317..ac9c6716 100644 --- a/control_plane/drivers/registry.py +++ b/control_plane/drivers/registry.py @@ -532,14 +532,14 @@ def _route_alias( "Create, refresh, inventory, and destroy Odoo PR previews through the " "generic-web preview lifecycle." ), - actions=("preview_apply", "preview_verification"), + actions=("preview_apply_inputs", "preview_apply", "preview_verification"), panels=("preview_inventory", "deployment_evidence", "audit"), ), DriverCapabilityDescriptor( capability_id="preview_inventory_managed", label="Preview inventory managed", description="Read provider inventory and reconcile current Odoo preview state.", - actions=("preview_apply",), + actions=("preview_apply_inputs", "preview_apply"), panels=("preview_inventory", "deployment_evidence", "audit"), ), ), @@ -603,6 +603,15 @@ def _route_alias( authz_action="odoo_stable_bootstrap.execute", writes_records=("deployment", "inventory"), ), + _action( + "preview_apply_inputs", + "Resolve preview apply inputs", + "Resolve Launchplane-owned Odoo preview runtime and provider dry-run inputs.", + safety="read", + scope="preview", + route_path="/v1/drivers/odoo/preview-apply-inputs", + authz_action="odoo_preview_apply_inputs.read", + ), _action( "preview_apply", "Apply isolated preview", diff --git a/control_plane/service.py b/control_plane/service.py index b2f689b7..45096ebb 100644 --- a/control_plane/service.py +++ b/control_plane/service.py @@ -13,7 +13,7 @@ from socketserver import ThreadingMixIn import uuid from pathlib import Path -from typing import BinaryIO, Callable, Generic, Iterable, Literal, Protocol, TypeVar, cast +from typing import Any, BinaryIO, Callable, Generic, Iterable, Literal, Protocol, TypeVar, cast from urllib.parse import parse_qs from wsgiref.simple_server import WSGIServer, make_server from wsgiref.types import WSGIApplication @@ -314,7 +314,9 @@ ) from control_plane.workflows.odoo_preview_runtime import ( ODOO_PREVIEW_REQUIRED_ENV_KEYS, + OdooPreviewApplyInputsRequest, OdooPreviewDokployApplyRequest, + build_odoo_preview_apply_inputs, execute_odoo_preview_dokploy_apply, ) from control_plane.workflows.product_onboarding import apply_product_onboarding_manifest @@ -1233,6 +1235,18 @@ def _validate_alignment(self) -> "OdooPreviewApplyEnvelope": return self +class OdooPreviewApplyInputsEnvelope(_ProductRouteEnvelope): + schema_version: int = Field(default=1, ge=1) + inputs: OdooPreviewApplyInputsRequest + + @model_validator(mode="after") + def _validate_alignment(self) -> "OdooPreviewApplyInputsEnvelope": + _validate_driver_envelope_product(self.product, label="Odoo preview apply inputs") + if self.product.strip() != self.inputs.product.strip(): + raise ValueError("Odoo preview apply inputs require matching product values.") + return self + + class OdooPreviewApplyConfigError(click.ClickException): def __init__(self, *, context: str, instance: str, missing_keys: tuple[str, ...]) -> None: super().__init__("Odoo preview apply runtime environment is incomplete.") @@ -1300,6 +1314,24 @@ def _odoo_preview_identifier(value: str, *, suffix: str) -> str: return f"{normalized}_{suffix_identifier}" if suffix_identifier else normalized +def _odoo_preview_apply_inputs_response_result( + *, + control_plane_root: Path, + record_store: object, + profile: LaunchplaneProductProfileRecord, + request: OdooPreviewApplyInputsRequest, + database_url: str | None, +) -> dict[str, object]: + driver_result = build_odoo_preview_apply_inputs( + control_plane_root=control_plane_root, + record_store=cast(Any, record_store), + profile=profile, + request=request, + database_url=database_url, + ) + return driver_result.model_dump(mode="json") + + _ODOO_PREVIEW_APPLY_ROUTE = _DriverRouteExecutionMetadata( route_path="/v1/drivers/odoo/preview-apply", envelope_model=OdooPreviewApplyEnvelope, @@ -1307,6 +1339,13 @@ def _odoo_preview_identifier(value: str, *, suffix: str) -> str: ) +_ODOO_PREVIEW_APPLY_INPUTS_ROUTE = _DriverRouteExecutionMetadata( + route_path="/v1/drivers/odoo/preview-apply-inputs", + envelope_model=OdooPreviewApplyInputsEnvelope, + denial_message="Workflow cannot read Odoo preview apply inputs for the requested product/context.", +) + + _PREVIEW_DESIRED_STATE_ROUTE_PATHS = frozenset( { _GENERIC_WEB_PREVIEW_DESIRED_STATE_ROUTE.route_path, @@ -1899,7 +1938,9 @@ def _validate_alignment(self) -> "OdooPreviewVerificationEnvelope": } ) _GENERIC_WEB_BASE_DRIVER_PREVIEW_ROUTE_PATHS = frozenset( - _GENERIC_WEB_BASE_DRIVER_PREVIEW_ROUTE_PATHS | _PREVIEW_VERIFICATION_ROUTE_PATHS + _GENERIC_WEB_BASE_DRIVER_PREVIEW_ROUTE_PATHS + | _PREVIEW_VERIFICATION_ROUTE_PATHS + | {_ODOO_PREVIEW_APPLY_INPUTS_ROUTE.route_path} ) _GENERIC_WEB_BASE_DRIVER_ROUTE_PATHS = frozenset( _GENERIC_WEB_BASE_DRIVER_SHARED_ROUTE_PATHS | _GENERIC_WEB_BASE_DRIVER_PREVIEW_ROUTE_PATHS @@ -13168,6 +13209,41 @@ def product_action_allowed( database_url=database_url, ) result = driver_result.model_dump(mode="json") + elif path == _ODOO_PREVIEW_APPLY_INPUTS_ROUTE.route_path: + odoo_preview_inputs_request = ( + _ODOO_PREVIEW_APPLY_INPUTS_ROUTE.envelope_model.model_validate(payload) + ) + resolved_driver_context = _resolve_descriptor_product_driver_context( + record_store=record_store, + route_path=path, + product=odoo_preview_inputs_request.product, + require_profile=True, + ) + if resolved_driver_context.profile is None: + raise ProductDriverMismatchError( + "Odoo preview apply inputs require a product profile." + ) + authorization_context = resolved_driver_context.profile.preview.context + authorization_response = _driver_route_authorization_response( + authz_policy=authz_policy, + identity=identity, + route_path=path, + product=odoo_preview_inputs_request.product, + context=authorization_context, + denial_message=_ODOO_PREVIEW_APPLY_INPUTS_ROUTE.denial_message, + start_response=start_response, + trace_id=request_trace_id, + ) + if authorization_response is not None: + return authorization_response + driver_result = _odoo_preview_apply_inputs_response_result( + control_plane_root=resolved_root, + record_store=record_store, + profile=resolved_driver_context.profile, + request=odoo_preview_inputs_request.inputs, + database_url=database_url, + ) + result = driver_result elif path == _ODOO_STABLE_VERIFICATION_ROUTE.route_path: odoo_stable_verification_request = ( _ODOO_STABLE_VERIFICATION_ROUTE.envelope_model.model_validate(payload) diff --git a/control_plane/workflows/generic_web_preview.py b/control_plane/workflows/generic_web_preview.py index 89010112..0a36a7a4 100644 --- a/control_plane/workflows/generic_web_preview.py +++ b/control_plane/workflows/generic_web_preview.py @@ -5,7 +5,7 @@ from pathlib import Path from typing import Iterator, Literal, Protocol, runtime_checkable from urllib.error import HTTPError, URLError -from urllib.parse import urlparse, urlunparse +from urllib.parse import urlparse from urllib.request import Request, urlopen import click @@ -739,15 +739,10 @@ def _preview_url_from_base_url(*, preview_slug: str, preview_base_url: str) -> s raise click.ClickException( f"{_PREVIEW_BASE_URL_ENV_KEY} must be a root URL without path, query, or fragment." ) - return urlunparse( - parsed._replace( - netloc=f"{preview_slug.strip()}.{parsed.hostname}", - path="", - params="", - query="", - fragment="", - ) - ) + host = f"{preview_slug.strip()}.{parsed.hostname}" + if parsed.port: + host = f"{host}:{parsed.port}" + return f"{parsed.scheme}://{host}" def resolve_generic_web_preview_url( @@ -755,6 +750,7 @@ def resolve_generic_web_preview_url( control_plane_root: Path, profile: LaunchplaneProductProfileRecord, request: GenericWebPreviewRefreshRequest, + database_url: str | None = None, ) -> str: explicit_preview_url = request.preview_url.strip() if explicit_preview_url: @@ -762,6 +758,7 @@ def resolve_generic_web_preview_url( context_values = control_plane_runtime_environments.resolve_runtime_context_values( control_plane_root=control_plane_root, context_name=profile.preview.context, + database_url=database_url, ) preview_base_url = str(context_values.get(_PREVIEW_BASE_URL_ENV_KEY) or "").strip() if not preview_base_url: diff --git a/control_plane/workflows/odoo_preview_runtime.py b/control_plane/workflows/odoo_preview_runtime.py index 4e853628..22d69c48 100644 --- a/control_plane/workflows/odoo_preview_runtime.py +++ b/control_plane/workflows/odoo_preview_runtime.py @@ -2,7 +2,7 @@ import time from pathlib import Path -from typing import Literal +from typing import Literal, Protocol from urllib.error import HTTPError, URLError from urllib.parse import urlparse from urllib.request import Request, urlopen @@ -11,11 +11,27 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator from control_plane import dokploy as control_plane_dokploy +from control_plane import runtime_environments as control_plane_runtime_environments from control_plane.dokploy import JsonObject from control_plane.contracts.odoo_preview_runtime_plan import ( + OdooPreviewProviderCapabilities, + OdooPreviewRuntimeBindingEvidence, + OdooPreviewRuntimeBlocker, OdooPreviewRuntimeOperation, OdooPreviewRuntimePlan, + OdooPreviewRuntimePlanRequest, OdooPreviewRuntimePlanStatus, + plan_odoo_preview_runtime, +) +from control_plane.contracts.product_profile_record import LaunchplaneProductProfileRecord +from control_plane.contracts.runtime_environment_record import RuntimeEnvironmentRecord +from control_plane.contracts.secret_record import SecretBinding +from control_plane.secrets import RUNTIME_ENVIRONMENT_SECRET_INTEGRATION +from control_plane.workflows.generic_web_preview import ( + GenericWebPreviewRefreshRequest, + effective_preview_app_name_prefix, + preview_application_name, + resolve_generic_web_preview_url, ) @@ -52,6 +68,380 @@ ) +class OdooPreviewApplyInputsStore(Protocol): + def list_runtime_environment_records( + self, *, context_name: str = "", instance_name: str = "" + ) -> tuple[RuntimeEnvironmentRecord, ...]: ... + + def list_secret_bindings( + self, + *, + integration: str = "", + context_name: str = "", + instance_name: str = "", + limit: int | None = None, + ) -> tuple[SecretBinding, ...]: ... + + +class OdooPreviewApplyInputsRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + schema_version: int = Field(default=1, ge=1) + product: str + operation: OdooPreviewRuntimeOperation = "refresh" + pr_number: int = Field(ge=1) + preview_slug: str = "" + preview_url: str = "" + image_reference: str = "" + source_git_ref: str = "" + source: str = "odoo-preview-apply-inputs" + timeout_seconds: int = Field(default=300, ge=1) + no_cache: bool = False + + @model_validator(mode="after") + def _normalize_request(self) -> "OdooPreviewApplyInputsRequest": + self.product = _required_text(self.product, "Odoo preview apply inputs requires product") + if self.preview_slug.strip(): + self.preview_slug = self.preview_slug.strip() + self.preview_url = self.preview_url.strip() + self.image_reference = self.image_reference.strip() + self.source_git_ref = self.source_git_ref.strip() + self.source = _required_text(self.source, "Odoo preview apply inputs requires source") + return self + + +class OdooPreviewApplyInputsResult(BaseModel): + model_config = ConfigDict(extra="forbid") + + status: Literal["ready", "blocked"] + product: str + context: str + template_instance: str + operation: OdooPreviewRuntimeOperation + preview_slug: str + preview_url: str + repository: str + runtime_plan: OdooPreviewRuntimePlan + dry_run_plan: OdooPreviewDokployDryRunPlan + source: str + error_message: str = "" + + @model_validator(mode="after") + def _normalize_result(self) -> "OdooPreviewApplyInputsResult": + self.product = _required_text( + self.product, "Odoo preview apply inputs result requires product" + ) + self.context = _required_text( + self.context, "Odoo preview apply inputs result requires context" + ) + self.template_instance = _required_text( + self.template_instance, "Odoo preview apply inputs result requires template_instance" + ) + self.preview_slug = _required_text( + self.preview_slug, "Odoo preview apply inputs result requires preview_slug" + ) + self.repository = _required_text( + self.repository, "Odoo preview apply inputs result requires repository" + ) + self.error_message = self.error_message.strip() + return self + + +def build_odoo_preview_apply_inputs( + *, + control_plane_root: Path, + record_store: OdooPreviewApplyInputsStore, + profile: LaunchplaneProductProfileRecord, + request: OdooPreviewApplyInputsRequest, + database_url: str | None = None, +) -> OdooPreviewApplyInputsResult: + preview_profile = profile.preview + if not preview_profile.enabled or not preview_profile.context.strip(): + raise click.ClickException( + f"Product {profile.product!r} does not have Odoo previews enabled." + ) + if request.operation != "refresh": + return _blocked_apply_inputs_result( + profile=profile, + request=request, + preview_slug=_preview_slug(profile=profile, request=request), + preview_url=request.preview_url, + runtime_plan=None, + dry_run_plan=None, + message="Launchplane-owned Odoo preview apply inputs currently support refresh only.", + ) + + preview_slug = _preview_slug(profile=profile, request=request) + preview_refresh_request = GenericWebPreviewRefreshRequest( + product=profile.product, + preview_slug=preview_slug, + preview_url=request.preview_url, + image_reference=request.image_reference, + anchor_pr_number=request.pr_number, + anchor_head_sha=request.source_git_ref, + source=request.source, + timeout_seconds=request.timeout_seconds, + no_cache=request.no_cache, + ) + preview_url = resolve_generic_web_preview_url( + control_plane_root=control_plane_root, + profile=profile, + request=preview_refresh_request, + database_url=database_url, + ) + template_instance = preview_profile.template_instance.strip() + runtime_bindings = _preview_runtime_bindings( + record_store=record_store, + context_name=preview_profile.context, + instance_name=template_instance, + ) + runtime_plan = plan_odoo_preview_runtime( + request=OdooPreviewRuntimePlanRequest( + operation=request.operation, + product=profile.product, + repository=profile.repository, + pr_number=request.pr_number, + preview_slug=preview_slug, + preview_url=preview_url, + strategy="isolated_dokploy_compose", + image_reference=request.image_reference, + source_git_ref=request.source_git_ref, + provider_capabilities=_odoo_preview_provider_capabilities(), + runtime_bindings=runtime_bindings, + required_runtime_keys=ODOO_PREVIEW_REQUIRED_ENV_KEYS, + ) + ) + template_compose_id = _preview_template_compose_id( + control_plane_root=control_plane_root, + context_name=preview_profile.context, + instance_name=template_instance, + ) + environment_id = _preview_environment_id( + control_plane_root=control_plane_root, + context_name=preview_profile.context, + instance_name=template_instance, + database_url=database_url, + ) + dry_run_plan = build_odoo_preview_dokploy_dry_run( + request=OdooPreviewDokployDryRunRequest( + runtime_plan=runtime_plan, + no_cache=request.no_cache, + delete_volumes=True, + runtime_port=profile.runtime_port, + compose_name=preview_application_name( + app_name_prefix=effective_preview_app_name_prefix(profile=profile), + preview_slug=preview_slug, + ), + environment_id=environment_id, + template_compose_id=template_compose_id, + ) + ) + status: Literal["ready", "blocked"] = ( + "ready" if runtime_plan.status == "ready" and dry_run_plan.status == "ready" else "blocked" + ) + return OdooPreviewApplyInputsResult( + status=status, + product=profile.product, + context=preview_profile.context, + template_instance=template_instance, + operation=request.operation, + preview_slug=preview_slug, + preview_url=preview_url, + repository=profile.repository, + runtime_plan=runtime_plan, + dry_run_plan=dry_run_plan, + source=request.source, + error_message="" if status == "ready" else _blocked_inputs_message(dry_run_plan), + ) + + +def _preview_slug( + *, profile: LaunchplaneProductProfileRecord, request: OdooPreviewApplyInputsRequest +) -> str: + if request.preview_slug.strip(): + return request.preview_slug.strip() + return profile.preview.slug_template.strip().replace("{number}", str(request.pr_number)) + + +def _preview_runtime_bindings( + *, record_store: OdooPreviewApplyInputsStore, context_name: str, instance_name: str +) -> tuple[OdooPreviewRuntimeBindingEvidence, ...]: + bindings: list[OdooPreviewRuntimeBindingEvidence] = [] + definition = ( + control_plane_runtime_environments.load_optional_runtime_environment_definition_from_store( + record_store=record_store + ) + ) + if definition is not None: + merged_values = _preview_merged_runtime_environment_keys( + definition=definition, + context_name=context_name, + instance_name=instance_name, + ) + bindings.extend( + OdooPreviewRuntimeBindingEvidence(key=key, source="runtime_environment") + for key in sorted(merged_values) + ) + secret_bindings = record_store.list_secret_bindings( + integration=RUNTIME_ENVIRONMENT_SECRET_INTEGRATION, + context_name=context_name, + instance_name=instance_name, + limit=None, + ) + bindings.extend( + OdooPreviewRuntimeBindingEvidence(key=binding.binding_key, source="managed_secret") + for binding in secret_bindings + if binding.status == "configured" + ) + bindings.extend( + OdooPreviewRuntimeBindingEvidence(key=key, source="generated") + for key in ( + "ODOO_DB_NAME", + "ODOO_DATA_VOLUME", + "ODOO_LOG_VOLUME", + "ODOO_DB_VOLUME", + ) + ) + return tuple({binding.key: binding for binding in bindings}.values()) + + +def _preview_merged_runtime_environment_keys( + *, + definition: control_plane_runtime_environments.RuntimeEnvironmentDefinition, + context_name: str, + instance_name: str, +) -> dict[str, str]: + merged_values: dict[str, str] = { + key: str(value) for key, value in definition.shared_env.items() + } + context_definition = definition.contexts.get(context_name) + if context_definition is None: + return merged_values + merged_values.update({key: str(value) for key, value in context_definition.shared_env.items()}) + instance_definition = context_definition.instances.get(instance_name) + if instance_definition is not None: + merged_values.update({key: str(value) for key, value in instance_definition.env.items()}) + return {key: value for key, value in merged_values.items() if value.strip()} + + +def _odoo_preview_provider_capabilities() -> OdooPreviewProviderCapabilities: + return OdooPreviewProviderCapabilities( + can_create_compose=True, + can_update_compose_env=True, + can_deploy_compose=True, + can_bind_domain=True, + can_delete_compose=True, + can_delete_domain=True, + ) + + +def _preview_template_compose_id( + *, control_plane_root: Path, context_name: str, instance_name: str +) -> str: + target_definition = _preview_template_target_definition( + control_plane_root=control_plane_root, + context_name=context_name, + instance_name=instance_name, + ) + if target_definition is None or target_definition.target_type != "compose": + return "" + return target_definition.target_id.strip() + + +def _preview_environment_id( + *, + control_plane_root: Path, + context_name: str, + instance_name: str, + database_url: str | None, +) -> str: + environment_values = control_plane_runtime_environments.resolve_runtime_environment_values( + control_plane_root=control_plane_root, + context_name=context_name, + instance_name=instance_name, + database_url=database_url, + ) + return environment_values.get("DOKPLOY_ENVIRONMENT_ID", "").strip() + + +def _preview_template_target_definition( + *, control_plane_root: Path, context_name: str, instance_name: str +) -> control_plane_dokploy.DokployTargetDefinition | None: + try: + source_of_truth = control_plane_dokploy.read_control_plane_dokploy_source_of_truth( + control_plane_root=control_plane_root, + allow_incomplete_target_ids=True, + allowed_incomplete_target_routes=((context_name, instance_name),), + ) + except click.ClickException: + return None + return control_plane_dokploy.find_dokploy_target_definition( + source_of_truth, + context_name=context_name, + instance_name=instance_name, + ) + + +def _blocked_inputs_message(dry_run_plan: OdooPreviewDokployDryRunPlan) -> str: + messages = [blocker.message for blocker in dry_run_plan.blockers] + return "; ".join(messages) or "Odoo preview apply inputs are blocked." + + +def _blocked_apply_inputs_result( + *, + profile: LaunchplaneProductProfileRecord, + request: OdooPreviewApplyInputsRequest, + preview_slug: str, + preview_url: str, + runtime_plan: OdooPreviewRuntimePlan | None, + dry_run_plan: OdooPreviewDokployDryRunPlan | None, + message: str, +) -> OdooPreviewApplyInputsResult: + resolved_runtime_plan = runtime_plan or OdooPreviewRuntimePlan( + status="blocked", + operation=request.operation, + product=profile.product, + repository=profile.repository, + pr_number=request.pr_number, + preview_slug=preview_slug, + preview_url=preview_url, + strategy="unknown", + blockers=( + OdooPreviewRuntimeBlocker(code="runtime_strategy_not_isolated", message=message), + ), + summary="Odoo preview runtime plan is blocked", + ) + resolved_dry_run_plan = dry_run_plan or OdooPreviewDokployDryRunPlan( + status="blocked", + operation=request.operation, + product=profile.product, + repository=profile.repository, + preview_slug=preview_slug, + preview_url=preview_url, + compose_ref=f"{profile.product}-{preview_slug}", + compose_name=preview_application_name( + app_name_prefix=effective_preview_app_name_prefix(profile=profile), + preview_slug=preview_slug, + ), + blockers=(OdooPreviewDokployDryRunBlocker(code="runtime_plan_not_ready", message=message),), + summary="Odoo preview Dokploy dry-run plan is blocked", + ) + return OdooPreviewApplyInputsResult( + status="blocked", + product=profile.product, + context=profile.preview.context, + template_instance=profile.preview.template_instance, + operation=request.operation, + preview_slug=preview_slug, + preview_url=preview_url, + repository=profile.repository, + runtime_plan=resolved_runtime_plan, + dry_run_plan=resolved_dry_run_plan, + source=request.source, + error_message=message, + ) + + class OdooPreviewDokployEndpointSpec(BaseModel): model_config = ConfigDict(extra="forbid") @@ -421,9 +811,7 @@ def _execute_refresh( steps: list[OdooPreviewDokployApplyStep] = [] try: if creating_compose and not plan.template_compose_id: - raise click.ClickException( - "Odoo preview compose create requires template_compose_id." - ) + raise click.ClickException("Odoo preview compose create requires template_compose_id.") source_compose_id = plan.template_compose_id if creating_compose else plan.compose_ref target_payload = control_plane_dokploy.fetch_dokploy_target_payload( host=host, @@ -715,9 +1103,7 @@ def _wait_for_smoke_check(*, preview_url: str, health_path: str, timeout_seconds time.sleep(sleep_seconds) deadline -= sleep_seconds if last_http_status is not None: - raise click.ClickException( - f"Odoo preview smoke check returned HTTP {last_http_status}." - ) + raise click.ClickException(f"Odoo preview smoke check returned HTTP {last_http_status}.") raise click.ClickException(f"Timed out waiting for Odoo preview smoke check {smoke_url}.") diff --git a/docs/driver-descriptors.md b/docs/driver-descriptors.md index da0ae4a1..275004fa 100644 --- a/docs/driver-descriptors.md +++ b/docs/driver-descriptors.md @@ -206,6 +206,18 @@ checks. The route resolves runtime env values from Launchplane-owned runtime-environment records and managed secret overlays, derives preview-specific database and volume names inside the service, returns the adapter's redacted step evidence, and keeps secret runtime values inside the service boundary. The +companion `POST /v1/drivers/odoo/preview-apply-inputs` route is the thin-workflow +entry point for planning that provider apply. Callers provide product, PR, +image, and source facts only; Launchplane derives the preview slug, public URL, +runtime binding evidence, template compose id, Dokploy environment id, Odoo +runtime plan, and redacted provider dry-run plan from product profiles, +runtime-environment records, managed secrets, and tracked Dokploy target records. +The route is read-only, returns no plaintext runtime or secret values, and is +refresh-only until Launchplane has inventory-backed discovery for existing Odoo +preview targets. Tenant workflows should call this route before +`preview-apply` instead of assembling Odoo runtime or Dokploy plan payloads in +the tenant repo. +The standard refresh/destroy routes use the generic-web preview request schema, live URL derivation, and record writer so Odoo PR previews land in the same Launchplane preview and preview-generation records as generic-web previews. diff --git a/docs/preview-workflow-contract.md b/docs/preview-workflow-contract.md index b097bef9..434f2526 100644 --- a/docs/preview-workflow-contract.md +++ b/docs/preview-workflow-contract.md @@ -118,8 +118,15 @@ Preview refresh routes receive only product-local facts: - run URL - primitive smoke/readiness facts when the check is product-specific -Odoo CM is the exception where Launchplane now owns the stage-preview smoke -contract after refresh: `/web/health`, `/cm-website/health`, `/cell-mechanic`, +Odoo CM is the exception where Launchplane now owns both the isolated provider +apply planning inputs and the stage-preview smoke contract after refresh. Product +workflows should call `POST /v1/drivers/odoo/preview-apply-inputs` with PR, +image, and source facts, then pass a ready redacted dry-run plan to +`POST /v1/drivers/odoo/preview-apply` instead of assembling runtime bindings, +Dokploy environment ids, template compose ids, or Odoo database and volume names +inside the tenant repo. The route supports refresh only until Launchplane has +inventory-backed discovery for existing preview targets. After refresh, +Launchplane owns `/web/health`, `/cm-website/health`, `/cell-mechanic`, artifact/revision evidence, and module install/update evidence. Product workflows should treat the Odoo refresh route's `refresh_status="pass"` as the ready-to-comment signal instead of independently deciding readiness from raw diff --git a/docs/service-boundary.md b/docs/service-boundary.md index 55d8c3f2..7ab0818a 100644 --- a/docs/service-boundary.md +++ b/docs/service-boundary.md @@ -106,6 +106,7 @@ VeriReel product paths: - `POST /v1/drivers/odoo/website-bootstrap-override` - `POST /v1/drivers/odoo/target-replacement-plan` - `POST /v1/drivers/odoo/target-replacement-apply` + - `POST /v1/drivers/odoo/preview-apply-inputs` - `POST /v1/drivers/odoo/preview-apply` - `POST /v1/drivers/odoo/stable-verification` - `POST /v1/drivers/odoo/prod-backup-gate` @@ -1113,6 +1114,8 @@ retries do not collide. The regular cleanup workflow uses `generic-web-preview-destroy::` - Odoo preview refresh driver: `odoo-preview-refresh:::` +- Odoo isolated preview apply-inputs driver: + `odoo-preview-apply-inputs:::` - Odoo isolated preview apply driver: `odoo-preview-apply::::` - Odoo preview destroy driver: @@ -1136,6 +1139,19 @@ Launchplane still derives the live preview URL from runtime-environment records and writes the shared preview and preview-generation records from DB-backed product profile preview configuration. +`POST /v1/drivers/odoo/preview-apply-inputs` is the Launchplane-owned handoff +between thin tenant preview workflows and isolated Odoo provider apply. The +caller supplies only product, PR number, image reference, source git ref, and +optional preview slug or URL override. Launchplane derives the generic preview +URL, runtime binding evidence, template compose id, Dokploy environment id, +Odoo runtime plan, and redacted Dokploy dry-run plan from DB-backed product +profile, runtime-environment, managed secret, and target records. Ready +responses can be posted to `POST /v1/drivers/odoo/preview-apply`; blocked +responses include planner blockers but never plaintext runtime values or secret +material. The route currently supports refresh only. Destroy and existing-target +update planning remain blocked until Launchplane can discover Odoo preview +targets from inventory rather than relying on tenant-supplied provider ids. + The CM tenant preview workflow uses two product scopes deliberately. Artifact publish input and publish evidence requests use product `odoo` for context `cm`, because the publish handoff is an Odoo driver contract. Preview refresh and diff --git a/tests/test_driver_descriptors.py b/tests/test_driver_descriptors.py index 0da1b1f2..a35af54c 100644 --- a/tests/test_driver_descriptors.py +++ b/tests/test_driver_descriptors.py @@ -526,6 +526,11 @@ def test_odoo_preview_execution_metadata_matches_descriptors(self) -> None: control_plane_service.OdooPreviewApplyEnvelope, "apply Odoo preview", ), + "preview_apply_inputs": ( + control_plane_service._ODOO_PREVIEW_APPLY_INPUTS_ROUTE, + control_plane_service.OdooPreviewApplyInputsEnvelope, + "preview apply inputs", + ), }, ) diff --git a/tests/test_product_environment_read_model.py b/tests/test_product_environment_read_model.py index 3cb43263..1bdaf864 100644 --- a/tests/test_product_environment_read_model.py +++ b/tests/test_product_environment_read_model.py @@ -472,6 +472,10 @@ def test_odoo_product_site_overview_uses_inherited_generic_web_actions(self) -> actions["preview_refresh"].route_path, "/v1/drivers/generic-web/preview-refresh", ) + self.assertEqual( + actions["preview_apply_inputs"].route_path, + "/v1/drivers/odoo/preview-apply-inputs", + ) self.assertEqual(actions["preview_apply"].route_path, "/v1/drivers/odoo/preview-apply") self.assertNotIn("preview_verification", actions) diff --git a/tests/test_service.py b/tests/test_service.py index fc976112..2a0b3e0c 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -16914,6 +16914,19 @@ def test_odoo_preview_apply_route_resolves_runtime_environment_values(self) -> N LaunchplaneProductProfileRecord.model_validate(_odoo_preview_profile_payload()) ) _write_odoo_preview_template_runtime_environment(store=store) + store.write_runtime_environment_record( + RuntimeEnvironmentRecord( + scope="instance", + context="cm", + instance="testing", + env={ + "ODOO_DB_USER": "odoo", + "DOKPLOY_ENVIRONMENT_ID": "env-cm-preview", + }, + updated_at="2026-05-09T12:30:00Z", + source_label="test", + ) + ) policy = LaunchplaneAuthzPolicy.model_validate( { "github_actions": [ @@ -17051,6 +17064,184 @@ def test_odoo_preview_apply_route_resolves_runtime_environment_values(self) -> N "caller-secret-must-not-win", ) + def test_odoo_preview_apply_inputs_route_derives_runtime_and_dry_run_plans(self) -> None: + with TemporaryDirectory() as temporary_directory_name: + root = Path(temporary_directory_name) + database_url = _sqlite_database_url(root / "launchplane.sqlite3") + state_dir = root / "fallback-state" + store = PostgresRecordStore(database_url=database_url) + store.ensure_schema() + store.write_product_profile_record( + LaunchplaneProductProfileRecord.model_validate(_odoo_preview_profile_payload()) + ) + store.write_runtime_environment_record( + RuntimeEnvironmentRecord( + scope="context", + context="cm", + env={"LAUNCHPLANE_PREVIEW_BASE_URL": "https://cm-preview.example.test"}, + updated_at="2026-05-09T12:25:00Z", + source_label="test", + ) + ) + _write_odoo_preview_template_runtime_environment(store=store) + store.write_runtime_environment_record( + RuntimeEnvironmentRecord( + scope="instance", + context="cm", + instance="testing", + env={ + "ODOO_DB_USER": "odoo", + "DOKPLOY_ENVIRONMENT_ID": "env-cm-preview", + }, + updated_at="2026-05-09T12:30:00Z", + source_label="test", + ) + ) + policy = LaunchplaneAuthzPolicy.model_validate( + { + "github_actions": [ + { + "repository": "cbusillo/odoo-tenant-cm", + "workflow_refs": [ + "cbusillo/odoo-tenant-cm/.github/workflows/odoo-preview.yml@refs/heads/main" + ], + "event_names": ["workflow_dispatch"], + "products": ["odoo-tenant-cm"], + "contexts": ["cm"], + "actions": ["odoo_preview_apply_inputs.read"], + } + ] + } + ) + app = create_launchplane_service_app( + state_dir=state_dir, + verifier=_StubVerifier( + _identity( + repository="cbusillo/odoo-tenant-cm", + workflow_ref=( + "cbusillo/odoo-tenant-cm/.github/workflows/odoo-preview.yml@refs/heads/main" + ), + event_name="workflow_dispatch", + ) + ), + authz_policy=policy, + control_plane_root_path=root, + database_url=database_url, + ) + + with ( + patch.dict( + os.environ, + { + control_plane_secrets.LAUNCHPLANE_SECRET_MASTER_KEY_ENV_VAR: "test-master-key" + }, + clear=True, + ), + patch( + "control_plane.workflows.odoo_preview_runtime.control_plane_dokploy.read_control_plane_dokploy_source_of_truth", + return_value=DokploySourceOfTruth( + schema_version=1, + targets=( + DokployTargetDefinition( + context="cm", + instance="testing", + target_type="compose", + target_id="compose-cm-testing", + target_name="cm-testing", + ), + ), + ), + ), + ): + status_code, payload = _invoke_app( + app, + method="POST", + path="/v1/drivers/odoo/preview-apply-inputs", + payload={ + "schema_version": 1, + "product": "odoo-tenant-cm", + "inputs": { + "product": "odoo-tenant-cm", + "pr_number": 42, + "image_reference": "ghcr.io/cbusillo/odoo-tenant-cm@sha256:abc123", + "source_git_ref": "abc123", + }, + }, + ) + + self.assertEqual(status_code, 202) + result = payload["result"] + self.assertEqual(result["status"], "ready") + self.assertEqual(result["preview_slug"], "pr-42") + self.assertEqual(result["preview_url"], "https://pr-42.cm-preview.example.test") + self.assertEqual(result["runtime_plan"]["status"], "ready") + self.assertEqual(result["dry_run_plan"]["status"], "ready") + self.assertEqual(result["dry_run_plan"]["environment_id"], "env-cm-preview") + self.assertEqual(result["dry_run_plan"]["template_compose_id"], "compose-cm-testing") + self.assertNotIn("template-db-secret", json.dumps(payload)) + + def test_odoo_preview_apply_inputs_route_blocks_destroy_until_target_discovery_exists( + self, + ) -> None: + with TemporaryDirectory() as temporary_directory_name: + root = Path(temporary_directory_name) + database_url = _sqlite_database_url(root / "launchplane.sqlite3") + store = PostgresRecordStore(database_url=database_url) + store.ensure_schema() + store.write_product_profile_record( + LaunchplaneProductProfileRecord.model_validate(_odoo_preview_profile_payload()) + ) + policy = LaunchplaneAuthzPolicy.model_validate( + { + "github_actions": [ + { + "repository": "cbusillo/odoo-tenant-cm", + "workflow_refs": [ + "cbusillo/odoo-tenant-cm/.github/workflows/odoo-preview.yml@refs/heads/main" + ], + "event_names": ["workflow_dispatch"], + "products": ["odoo-tenant-cm"], + "contexts": ["cm"], + "actions": ["odoo_preview_apply_inputs.read"], + } + ] + } + ) + app = create_launchplane_service_app( + state_dir=root / "state", + verifier=_StubVerifier( + _identity( + repository="cbusillo/odoo-tenant-cm", + workflow_ref=( + "cbusillo/odoo-tenant-cm/.github/workflows/odoo-preview.yml@refs/heads/main" + ), + event_name="workflow_dispatch", + ) + ), + authz_policy=policy, + control_plane_root_path=root, + database_url=database_url, + ) + + status_code, payload = _invoke_app( + app, + method="POST", + path="/v1/drivers/odoo/preview-apply-inputs", + payload={ + "schema_version": 1, + "product": "odoo-tenant-cm", + "inputs": { + "product": "odoo-tenant-cm", + "operation": "destroy", + "pr_number": 42, + }, + }, + ) + + self.assertEqual(status_code, 202) + self.assertEqual(payload["result"]["status"], "blocked") + self.assertIn("refresh only", payload["result"]["error_message"]) + def test_odoo_preview_apply_route_blocks_missing_service_runtime_environment( self, ) -> None: