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
1 change: 1 addition & 0 deletions control_plane/contracts/artifact_identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
33 changes: 24 additions & 9 deletions control_plane/workflows/odoo_stable_target_replacement.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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("/")

Expand Down Expand Up @@ -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(
Expand Down
3 changes: 3 additions & 0 deletions docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 15 additions & 2 deletions tests/test_odoo_stable_target_replacement.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
)
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down