From 731685736b8986b787935c4d93e69f4137673bda Mon Sep 17 00:00:00 2001 From: Chris Busillo Date: Sun, 14 Jun 2026 00:48:38 -0400 Subject: [PATCH] Consume artifact Odoo install modules --- control_plane/contracts/artifact_identity.py | 1 + .../odoo_stable_target_replacement.py | 33 ++++++++++++++----- docs/operations.md | 3 ++ tests/test_odoo_stable_target_replacement.py | 17 ++++++++-- 4 files changed, 43 insertions(+), 11 deletions(-) diff --git a/control_plane/contracts/artifact_identity.py b/control_plane/contracts/artifact_identity.py index 340d9f8b..b0c42be9 100644 --- a/control_plane/contracts/artifact_identity.py +++ b/control_plane/contracts/artifact_identity.py @@ -122,6 +122,7 @@ class ArtifactIdentityManifest(BaseModel): enterprise_base_digest: str addon_sources: tuple[ArtifactAddonSource, ...] = () addon_selectors: tuple[ArtifactAddonSelector, ...] = () + odoo_install_modules: tuple[str, ...] = () openupgrade_inputs: ArtifactOpenUpgradeInputs = Field(default_factory=ArtifactOpenUpgradeInputs) build_flags: ArtifactBuildFlags = Field(default_factory=ArtifactBuildFlags) build_provenance: ArtifactBuildProvenance = Field(default_factory=ArtifactBuildProvenance) diff --git a/control_plane/workflows/odoo_stable_target_replacement.py b/control_plane/workflows/odoo_stable_target_replacement.py index 0df3d9cb..3f084ff9 100644 --- a/control_plane/workflows/odoo_stable_target_replacement.py +++ b/control_plane/workflows/odoo_stable_target_replacement.py @@ -1,6 +1,6 @@ from __future__ import annotations -from collections.abc import Callable, Mapping +from collections.abc import Callable, Iterable, Mapping from pathlib import Path from typing import Literal, Protocol @@ -633,16 +633,25 @@ def _record_with_target_replacement_canonical( ) -def _merge_required_odoo_install_modules(raw_modules: str) -> str: +def _merge_odoo_install_modules(*module_groups: str | Iterable[str]) -> str: merged_modules: list[str] = [] - for module_name in (*LAUNCHPLANE_REQUIRED_ODOO_MODULES, *raw_modules.split(",")): - normalized_module_name = module_name.strip() - if not normalized_module_name or normalized_module_name in merged_modules: - continue - merged_modules.append(normalized_module_name) + for module_group in module_groups: + if isinstance(module_group, str): + raw_module_names: Iterable[str] = module_group.split(",") + else: + raw_module_names = module_group + for module_name in raw_module_names: + normalized_module_name = str(module_name).strip() + if not normalized_module_name or normalized_module_name in merged_modules: + continue + merged_modules.append(normalized_module_name) return ",".join(merged_modules) +def _merge_required_odoo_install_modules(raw_modules: str) -> str: + return _merge_odoo_install_modules(LAUNCHPLANE_REQUIRED_ODOO_MODULES, raw_modules) + + def _normalize_domain(raw_domain: str) -> str: return raw_domain.strip().lower().removeprefix("https://").removeprefix("http://").rstrip("/") @@ -1290,12 +1299,18 @@ def execute_odoo_stable_target_replacement_apply( desired_env_map = dict(current_env_map) for key in control_plane_dokploy.ODOO_RUNTIME_OVERRIDE_TARGET_ENV_KEYS: desired_env_map.pop(key, None) + desired_env_map.pop(ODOO_INSTALL_MODULES_ENV_KEY, None) desired_env_map.update(runtime_environment_values) desired_env_map.update(runtime_override_environment) - desired_env_map[ODOO_INSTALL_MODULES_ENV_KEY] = _merge_required_odoo_install_modules( - desired_env_map.get(ODOO_INSTALL_MODULES_ENV_KEY, "") + desired_env_map[ODOO_INSTALL_MODULES_ENV_KEY] = _merge_odoo_install_modules( + LAUNCHPLANE_REQUIRED_ODOO_MODULES, + artifact_manifest.odoo_install_modules, + desired_env_map.get(ODOO_INSTALL_MODULES_ENV_KEY, ""), ) runtime_source["required_odoo_modules"] = ",".join(LAUNCHPLANE_REQUIRED_ODOO_MODULES) + runtime_source["artifact_odoo_install_modules"] = ",".join( + artifact_manifest.odoo_install_modules + ) runtime_source["odoo_install_modules"] = desired_env_map[ODOO_INSTALL_MODULES_ENV_KEY] if runtime_override_payload is not None: missing_override_secret_keys = tuple( diff --git a/docs/operations.md b/docs/operations.md index b42581ea..cb6ad296 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -881,6 +881,9 @@ context only, and `context_instance` has both context and instance. - Confirm the target context has DB-backed artifact manifests, `testing` and `prod` release tuples, Dokploy target records, target-id records, and current prod inventory. +- For Odoo artifacts, the stored artifact manifest carries `odoo_install_modules`. + Stable target replacement merges that list into `ODOO_INSTALL_MODULES` with + Launchplane's required safety modules before deploying the target. - For the first harmless drill, call the Odoo prod rollback driver with no explicit artifact id. The driver selects the current `testing` release tuple for that context and fails closed if the tuple or artifact manifest is missing. diff --git a/tests/test_odoo_stable_target_replacement.py b/tests/test_odoo_stable_target_replacement.py index 0a0dd2b5..82dcc6cf 100644 --- a/tests/test_odoo_stable_target_replacement.py +++ b/tests/test_odoo_stable_target_replacement.py @@ -336,11 +336,13 @@ def _artifact_manifest( artifact_id: str = "artifact-cm-testing", source_commit: str = "abc1234", digest: str = "sha256:artifact", + odoo_install_modules: tuple[str, ...] = (), ) -> ArtifactIdentityManifest: return ArtifactIdentityManifest( artifact_id=artifact_id, source_commit=source_commit, enterprise_base_digest="sha256:enterprise", + odoo_install_modules=odoo_install_modules, image=ArtifactImageReference( repository="ghcr.io/cbusillo/odoo-tenant-cm", digest=digest, @@ -744,6 +746,7 @@ def test_apply_recreates_target_in_place_and_writes_breadcrumb_inventory(self) - target_record=_target_record(), target_id_record=_target_id_record(), inventory=_inventory(), + artifact_manifest=_artifact_manifest(odoo_install_modules=("cm_website",)), odoo_instance_override_record=OdooInstanceOverrideRecord( context="cm", instance="testing", @@ -787,6 +790,7 @@ def _fetch_target_payload(**_: object) -> JsonValue: "ODOO_DATA_VOLUME=cm_testing_odoo_data", "ODOO_LOG_VOLUME=cm_testing_odoo_logs", "ODOO_DB_VOLUME=cm_testing_odoo_db", + "ODOO_INSTALL_MODULES=stale_module,disable_odoo_online", f"{ODOO_INSTANCE_OVERRIDES_PAYLOAD_ENV_KEY}={stale_payload_b64}", f"{LAUNCHPLANE_INSTANCE_OVERRIDES_REQUIRED_ENV_KEY}=true", ) @@ -1033,7 +1037,7 @@ def _sync_source(*, compose_file: str, **_: object) -> dict[str, str]: persisted_env_map = control_plane_dokploy.parse_dokploy_env_text(persisted_env) self.assertEqual( persisted_env_map["ODOO_INSTALL_MODULES"], - "launchplane_settings,disable_odoo_online", + "launchplane_settings,disable_odoo_online,cm_website", ) self.assertEqual(persisted_env_map[LAUNCHPLANE_WEBSITE_BOOTSTRAP_REQUIRED_ENV_KEY], "true") self.assertNotIn( @@ -1143,9 +1147,13 @@ def _sync_source(*, compose_file: str, **_: object) -> dict[str, str]: final_deployment.runtime_source["required_odoo_modules"], "launchplane_settings,disable_odoo_online", ) + self.assertEqual( + final_deployment.runtime_source["artifact_odoo_install_modules"], + "cm_website", + ) self.assertEqual( final_deployment.runtime_source["odoo_install_modules"], - "launchplane_settings,disable_odoo_online", + "launchplane_settings,disable_odoo_online,cm_website", ) self.assertEqual(result.runtime_source, final_deployment.runtime_source) assert final_deployment.runtime_identity is not None @@ -1272,6 +1280,11 @@ def _update_env(*, env_text: str, **_: object) -> None: "ghcr.io/cbusillo/odoo-tenant-cm@sha256:fresh", ) self.assertIn("LAUNCHPLANE_ARTIFACT_ID=artifact-cm-fresh", persisted_env) + persisted_env_map = control_plane_dokploy.parse_dokploy_env_text(persisted_env) + self.assertEqual( + persisted_env_map["ODOO_INSTALL_MODULES"], + "launchplane_settings,disable_odoo_online", + ) final_deployment = store.deployment_records[-1] self.assertEqual(final_deployment.source_git_ref, "feed123") assert final_deployment.runtime_identity is not None