diff --git a/control_plane/dokploy.py b/control_plane/dokploy.py index e566eb59..c6e63dbb 100644 --- a/control_plane/dokploy.py +++ b/control_plane/dokploy.py @@ -52,6 +52,16 @@ "ODOO_FILESTORE_PATH", "ODOO_DATA_WORKFLOW_LOCK_FILE", } +ODOO_INSTANCE_OVERRIDES_PAYLOAD_ENV_KEY = "ODOO_INSTANCE_OVERRIDES_PAYLOAD_B64" +LAUNCHPLANE_INSTANCE_OVERRIDES_REQUIRED_ENV_KEY = "LAUNCHPLANE_INSTANCE_OVERRIDES_REQUIRED" +LAUNCHPLANE_WEBSITE_BOOTSTRAP_REQUIRED_ENV_KEY = "LAUNCHPLANE_WEBSITE_BOOTSTRAP_REQUIRED" +ODOO_RUNTIME_OVERRIDE_TARGET_ENV_KEYS = frozenset( + { + ODOO_INSTANCE_OVERRIDES_PAYLOAD_ENV_KEY, + LAUNCHPLANE_INSTANCE_OVERRIDES_REQUIRED_ENV_KEY, + LAUNCHPLANE_WEBSITE_BOOTSTRAP_REQUIRED_ENV_KEY, + } +) ODOO_UPSTREAM_RESTORE_WORKFLOW_ENV_KEYS = ( "ODOO_UPSTREAM_HOST", "ODOO_UPSTREAM_USER", @@ -214,6 +224,9 @@ def render_odoo_raw_compose_file( ODOO_DEV_MODE: ${{ODOO_DEV_MODE:-}} ODOO_INSTALL_MODULES: ${{ODOO_INSTALL_MODULES:-}} ODOO_UPDATE_MODULES: ${{ODOO_UPDATE_MODULES:-AUTO}} + ODOO_INSTANCE_OVERRIDES_PAYLOAD_B64: ${{ODOO_INSTANCE_OVERRIDES_PAYLOAD_B64:-}} + LAUNCHPLANE_INSTANCE_OVERRIDES_REQUIRED: ${{LAUNCHPLANE_INSTANCE_OVERRIDES_REQUIRED:-}} + LAUNCHPLANE_WEBSITE_BOOTSTRAP_REQUIRED: ${{LAUNCHPLANE_WEBSITE_BOOTSTRAP_REQUIRED:-}} ODOO_ADDONS_PATH: ${{ODOO_ADDONS_PATH:-/opt/project/addons,/opt/extra_addons,/opt/launchplane/addons,/opt/enterprise,/odoo/addons}} ODOO_SERVER_WIDE_MODULES: ${{ODOO_SERVER_WIDE_MODULES:-base,web,launchplane_runtime_health}} ODOO_DATA_WORKFLOW_LOCK_FILE: ${{ODOO_DATA_WORKFLOW_LOCK_FILE:-/volumes/data/.data_workflow_in_progress}} @@ -1661,6 +1674,14 @@ def run_compose_post_deploy_update( ) resolved_workflow_environment_overrides = dict(workflow_environment_overrides or {}) resolved_required_workflow_environment_keys = tuple(required_workflow_environment_keys) + runtime_override_target_environment = { + key: value + for key, value in resolved_workflow_environment_overrides.items() + if key in ODOO_RUNTIME_OVERRIDE_TARGET_ENV_KEYS + } + for key in ODOO_RUNTIME_OVERRIDE_TARGET_ENV_KEYS: + desired_env_map.pop(key, None) + desired_env_map.update(runtime_override_target_environment) if run_destructive_restore: upstream_restore_environment = _resolve_upstream_restore_workflow_environment( desired_env_map=desired_env_map, @@ -1707,6 +1728,23 @@ def run_compose_post_deploy_update( before_key=deployment_key(latest_compose_deployment), timeout_seconds=schedule_timeout_seconds, ) + target_payload = fetch_dokploy_target_payload( + host=host, + token=token, + target_type="compose", + target_id=compose_id, + ) + refreshed_env_map = parse_dokploy_env_text(str(target_payload.get("env") or "")) + missing_runtime_override_keys = sorted( + key + for key, value in runtime_override_target_environment.items() + if refreshed_env_map.get(key, "") != value + ) + if missing_runtime_override_keys: + raise click.ClickException( + "Compose post-deploy update did not persist runtime override key(s): " + + ", ".join(missing_runtime_override_keys) + ) database_name = desired_env_map.get("ODOO_DB_NAME", "").strip() if not database_name: diff --git a/control_plane/odoo_instance_overrides.py b/control_plane/odoo_instance_overrides.py index 9b090c73..2b29432f 100644 --- a/control_plane/odoo_instance_overrides.py +++ b/control_plane/odoo_instance_overrides.py @@ -13,6 +13,8 @@ from control_plane.contracts.runtime_environment_record import ScalarValue ODOO_INSTANCE_OVERRIDES_PAYLOAD_ENV_KEY = "ODOO_INSTANCE_OVERRIDES_PAYLOAD_B64" +LAUNCHPLANE_INSTANCE_OVERRIDES_REQUIRED_ENV_KEY = "LAUNCHPLANE_INSTANCE_OVERRIDES_REQUIRED" +LAUNCHPLANE_WEBSITE_BOOTSTRAP_REQUIRED_ENV_KEY = "LAUNCHPLANE_WEBSITE_BOOTSTRAP_REQUIRED" ODOO_OVERRIDE_SECRET_ENV_PREFIX = "ODOO_OVERRIDE_SECRET__" SHOPIFY_ADDON_NAME = "shopify" SHOPIFY_ACTION_SETTING = "action" @@ -274,6 +276,10 @@ def build_post_deploy_environment( inline_environment: dict[str, str] = { ODOO_INSTANCE_OVERRIDES_PAYLOAD_ENV_KEY: _encode_post_deploy_payload(payload), } + if payload.config_parameters or payload.addon_settings: + inline_environment[LAUNCHPLANE_INSTANCE_OVERRIDES_REQUIRED_ENV_KEY] = "true" + if payload.website_bootstrap_included: + inline_environment[LAUNCHPLANE_WEBSITE_BOOTSTRAP_REQUIRED_ENV_KEY] = "true" return PostDeployOverrideEnvironment( inline_environment=inline_environment, required_container_environment_keys=payload.required_container_environment_keys, diff --git a/control_plane/workflows/odoo_stable_target_replacement.py b/control_plane/workflows/odoo_stable_target_replacement.py index e83e9fd6..35b23e14 100644 --- a/control_plane/workflows/odoo_stable_target_replacement.py +++ b/control_plane/workflows/odoo_stable_target_replacement.py @@ -8,6 +8,7 @@ from pydantic import BaseModel, ConfigDict from control_plane import dokploy as control_plane_dokploy +from control_plane import odoo_instance_overrides as control_plane_odoo_instance_overrides from control_plane import live_target_runtime as control_plane_live_target_runtime from control_plane import release_tuples as control_plane_release_tuples from control_plane import runtime_environments as control_plane_runtime_environments @@ -1132,6 +1133,36 @@ def execute_odoo_stable_target_replacement_apply( and normalized_override_record is not odoo_override_record ): record_store.write_odoo_instance_override_record(normalized_override_record) + runtime_override_environment: dict[str, str] = {} + runtime_override_payload = None + if normalized_override_record is not None and "deploy" in normalized_override_record.apply_on: + runtime_override = control_plane_odoo_instance_overrides.build_post_deploy_environment( + normalized_override_record, + workflow_intent="deploy", + protected_shopify_store_keys=target_record.policies.shopify.protected_store_keys, + ) + runtime_override_environment = runtime_override.inline_environment + runtime_override_payload = runtime_override.payload + runtime_source.update( + { + "runtime_override_payload_rendered": "true", + "runtime_override_payload_sha256": runtime_override_payload.wire_sha256, + "runtime_override_count": str(runtime_override_payload.override_count), + "runtime_override_website_bootstrap_included": str( + runtime_override_payload.website_bootstrap_included + ).lower(), + "runtime_override_instance_required": runtime_override_environment.get( + control_plane_odoo_instance_overrides.LAUNCHPLANE_INSTANCE_OVERRIDES_REQUIRED_ENV_KEY, + "false", + ), + "runtime_override_website_bootstrap_required": runtime_override_environment.get( + control_plane_odoo_instance_overrides.LAUNCHPLANE_WEBSITE_BOOTSTRAP_REQUIRED_ENV_KEY, + "false", + ), + } + ) + else: + runtime_source["runtime_override_payload_rendered"] = "false" try: host, token = control_plane_dokploy.read_dokploy_config( @@ -1245,7 +1276,21 @@ def execute_odoo_stable_target_replacement_apply( str(target_payload.get("env") or "") ) 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.update(runtime_environment_values) + desired_env_map.update(runtime_override_environment) + if runtime_override_payload is not None: + missing_override_secret_keys = tuple( + key + for key in runtime_override_payload.required_container_environment_keys + if not desired_env_map.get(key, "").strip() + ) + if missing_override_secret_keys: + raise click.ClickException( + "Odoo target replacement requires override secret env key(s) before deployment: " + + ", ".join(missing_override_secret_keys) + ) if desired_env_map.get("ODOO_WEB_COMMAND", "").strip() == "/odoo/odoo-bin": desired_env_map.pop("ODOO_WEB_COMMAND", None) desired_env_map["PLATFORM_CONTEXT"] = plan.context diff --git a/docs/operations.md b/docs/operations.md index efa56fe5..2eafe699 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -840,7 +840,15 @@ context only, and `context_instance` has both context and instance. - `odoo-overrides mark-apply` updates the latest apply status metadata for a record, giving the future Odoo driver a tested result-write path. - Compose post-deploy updates consume deploy-phase overrides from these records - and pass them to the Odoo data-workflow runner as one typed payload env var. + and pass them to Odoo as one typed payload env var. Deploy-phase payloads are + persisted to the Dokploy compose target environment before the web container + is redeployed, and the same payload is passed to the Odoo data-workflow + runner for post-deploy maintenance. +- When a deploy-phase payload is expected, Launchplane also persists generic + runtime assertion flags that tell the Odoo runtime to fail closed if managed + instance overrides or website bootstrap data are missing. Launchplane re-reads + the provider target environment after writing it and fails the operation if + the typed payload or assertion flags did not persist. - Target replacement requests with `data_source_mode="upstream_restore"` use the guarded post-deploy schedule in destructive restore mode after image deploy so the devkit restore path performs restore sanitization, website bootstrap, diff --git a/tests/test_dokploy.py b/tests/test_dokploy.py index e2d856af..3352ca75 100644 --- a/tests/test_dokploy.py +++ b/tests/test_dokploy.py @@ -15,6 +15,8 @@ from control_plane import dokploy as control_plane_dokploy from control_plane.dokploy import JsonValue +from control_plane.odoo_instance_overrides import LAUNCHPLANE_INSTANCE_OVERRIDES_REQUIRED_ENV_KEY +from control_plane.odoo_instance_overrides import LAUNCHPLANE_WEBSITE_BOOTSTRAP_REQUIRED_ENV_KEY from control_plane.odoo_instance_overrides import ODOO_INSTANCE_OVERRIDES_PAYLOAD_ENV_KEY from control_plane import secrets as control_plane_secrets from control_plane.cli import main @@ -2432,6 +2434,12 @@ def test_run_compose_post_deploy_update_applies_explicit_env_file_without_contro updated_env_payloads: list[str] = [] schedule_payloads: list[dict[str, object]] = [] request_paths: list[str] = [] + override_payload_b64 = base64.b64encode( + json.dumps( + {"schema_version": 1, "context": "opw", "instance": "prod"}, + sort_keys=True, + ).encode("utf-8") + ).decode("ascii") def capture_schedule_payload(**kwargs: object) -> dict[str, str]: schedule_payloads.append(cast("dict[str, object]", kwargs["schedule_payload"])) @@ -2441,14 +2449,19 @@ def capture_request_path(**kwargs: object) -> dict[str, bool]: request_paths.append(str(kwargs["path"])) return {"ok": True} + def fetch_target_payload(**_: object) -> dict[str, str]: + return { + "env": updated_env_payloads[-1] + if updated_env_payloads + else "ODOO_DB_NAME=old_db\nODOO_FILESTORE_PATH=/volumes/data/filestore\n", + "appName": "opw-prod-app", + "serverId": "server-123", + } + with ( patch( "control_plane.dokploy.fetch_dokploy_target_payload", - return_value={ - "env": "ODOO_DB_NAME=old_db\nODOO_FILESTORE_PATH=/volumes/data/filestore\n", - "appName": "opw-prod-app", - "serverId": "server-123", - }, + side_effect=fetch_target_payload, ), patch( "control_plane.dokploy.update_dokploy_target_env", @@ -2494,19 +2507,174 @@ def capture_request_path(**kwargs: object) -> dict[str, bool]: token="secret-token", target_definition=target_definition, env_file=env_file, + workflow_environment_overrides={ + ODOO_INSTANCE_OVERRIDES_PAYLOAD_ENV_KEY: override_payload_b64, + LAUNCHPLANE_INSTANCE_OVERRIDES_REQUIRED_ENV_KEY: "true", + LAUNCHPLANE_WEBSITE_BOOTSTRAP_REQUIRED_ENV_KEY: "true", + "ONE_OFF_WORKFLOW_ONLY": "do-not-persist", + }, ) self.assertEqual(len(updated_env_payloads), 1) self.assertIn("ODOO_DB_NAME=opw_prod", updated_env_payloads[0]) self.assertIn("ODOO_FILESTORE_PATH=/volumes/data/custom-filestore", updated_env_payloads[0]) + self.assertIn( + f"{ODOO_INSTANCE_OVERRIDES_PAYLOAD_ENV_KEY}={override_payload_b64}", + updated_env_payloads[0], + ) + self.assertIn( + f"{LAUNCHPLANE_INSTANCE_OVERRIDES_REQUIRED_ENV_KEY}=true", updated_env_payloads[0] + ) + self.assertIn( + f"{LAUNCHPLANE_WEBSITE_BOOTSTRAP_REQUIRED_ENV_KEY}=true", updated_env_payloads[0] + ) + self.assertNotIn("ONE_OFF_WORKFLOW_ONLY=do-not-persist", updated_env_payloads[0]) self.assertNotIn("DOKPLOY_TOKEN=should-not-sync", updated_env_payloads[0]) self.assertEqual(len(schedule_payloads), 1) self.assertEqual(schedule_payloads[0]["command"], "control-plane post-deploy update") self.assertIn("--post-deploy-maintenance", str(schedule_payloads[0]["script"])) self.assertNotIn("--update-only", str(schedule_payloads[0]["script"])) + self.assertIn("ONE_OFF_WORKFLOW_ONLY", str(schedule_payloads[0]["script"])) self.assertIn("/api/compose.deploy", request_paths) self.assertIn("/api/schedule.runManually", request_paths) + def test_run_compose_post_deploy_update_fails_when_runtime_override_env_does_not_persist( + self, + ) -> None: + target_definition = control_plane_dokploy.DokployTargetDefinition( + context="opw", instance="prod", target_id="compose-123", target_name="opw-prod" + ) + override_payload_b64 = base64.b64encode( + json.dumps( + {"schema_version": 1, "context": "opw", "instance": "prod"}, + sort_keys=True, + ).encode("utf-8") + ).decode("ascii") + + with ( + patch( + "control_plane.dokploy.fetch_dokploy_target_payload", + return_value={ + "env": "ODOO_DB_NAME=opw_prod\nODOO_FILESTORE_PATH=/volumes/data/filestore\n", + "appName": "opw-prod-app", + "serverId": "server-123", + }, + ), + patch("control_plane.dokploy.update_dokploy_target_env"), + patch( + "control_plane.dokploy.latest_deployment_for_target", + return_value={"deploymentId": "deployment-before"}, + ), + patch( + "control_plane.dokploy.wait_for_target_deployment", + side_effect=lambda **_kwargs: None, + ), + patch("control_plane.dokploy.trigger_deployment"), + patch("control_plane.dokploy.upsert_dokploy_schedule") as upsert_schedule, + ): + with self.assertRaises(click.ClickException) as raised_error: + control_plane_dokploy.run_compose_post_deploy_update( + host="https://dokploy.example.com", + token="secret-token", + target_definition=target_definition, + env_file=None, + workflow_environment_overrides={ + ODOO_INSTANCE_OVERRIDES_PAYLOAD_ENV_KEY: override_payload_b64, + LAUNCHPLANE_INSTANCE_OVERRIDES_REQUIRED_ENV_KEY: "true", + }, + ) + + self.assertIn( + "Compose post-deploy update did not persist runtime override key(s)", + str(raised_error.exception), + ) + self.assertIn(ODOO_INSTANCE_OVERRIDES_PAYLOAD_ENV_KEY, str(raised_error.exception)) + self.assertNotIn(override_payload_b64, str(raised_error.exception)) + upsert_schedule.assert_not_called() + + def test_run_compose_post_deploy_update_removes_stale_runtime_override_target_env( + self, + ) -> None: + target_definition = control_plane_dokploy.DokployTargetDefinition( + context="opw", instance="prod", target_id="compose-123", target_name="opw-prod" + ) + updated_env_payloads: list[str] = [] + schedule_payloads: list[dict[str, object]] = [] + stale_payload_b64 = base64.b64encode(b'{"stale":true}').decode("ascii") + + def capture_schedule_payload(**kwargs: object) -> dict[str, str]: + schedule_payloads.append(cast("dict[str, object]", kwargs["schedule_payload"])) + return {"scheduleId": "schedule-123"} + + def fetch_target_payload(**_: object) -> dict[str, str]: + return { + "env": updated_env_payloads[-1] + if updated_env_payloads + else "\n".join( + ( + "ODOO_DB_NAME=opw_prod", + "ODOO_FILESTORE_PATH=/volumes/data/filestore", + f"{ODOO_INSTANCE_OVERRIDES_PAYLOAD_ENV_KEY}={stale_payload_b64}", + f"{LAUNCHPLANE_INSTANCE_OVERRIDES_REQUIRED_ENV_KEY}=true", + f"{LAUNCHPLANE_WEBSITE_BOOTSTRAP_REQUIRED_ENV_KEY}=true", + ) + ), + "appName": "opw-prod-app", + "serverId": "server-123", + } + + with ( + patch( + "control_plane.dokploy.fetch_dokploy_target_payload", + side_effect=fetch_target_payload, + ), + patch( + "control_plane.dokploy.update_dokploy_target_env", + side_effect=lambda **kwargs: updated_env_payloads.append(str(kwargs["env_text"])), + ), + patch( + "control_plane.dokploy.latest_deployment_for_target", + return_value={"deploymentId": "deployment-before"}, + ), + patch( + "control_plane.dokploy.wait_for_target_deployment", + side_effect=lambda **_kwargs: None, + ), + patch( + "control_plane.dokploy.find_matching_dokploy_schedule", + return_value=None, + ), + patch( + "control_plane.dokploy.upsert_dokploy_schedule", + side_effect=capture_schedule_payload, + ), + patch( + "control_plane.dokploy.latest_deployment_for_schedule", + return_value={"deploymentId": "schedule-before"}, + ), + patch( + "control_plane.dokploy.wait_for_dokploy_schedule_deployment", + return_value="deployment=schedule-after status=done", + ), + patch("control_plane.dokploy.fetch_dokploy_deployment_logs", return_value=()), + patch( + "control_plane.dokploy.dokploy_request", + side_effect=lambda **_kwargs: {"ok": True}, + ), + ): + control_plane_dokploy.run_compose_post_deploy_update( + host="https://dokploy.example.com", + token="secret-token", + target_definition=target_definition, + env_file=None, + ) + + self.assertEqual(len(updated_env_payloads), 1) + self.assertNotIn(ODOO_INSTANCE_OVERRIDES_PAYLOAD_ENV_KEY, updated_env_payloads[0]) + self.assertNotIn(LAUNCHPLANE_INSTANCE_OVERRIDES_REQUIRED_ENV_KEY, updated_env_payloads[0]) + self.assertNotIn(LAUNCHPLANE_WEBSITE_BOOTSTRAP_REQUIRED_ENV_KEY, updated_env_payloads[0]) + self.assertEqual(len(schedule_payloads), 1) + def test_run_compose_post_deploy_update_returns_readback_markers_from_schedule_logs( self, ) -> None: @@ -3138,6 +3306,18 @@ def test_render_odoo_raw_compose_file_pins_artifact_image_and_services(self) -> ) self.assertIn("PLATFORM_CONTEXT: ${PLATFORM_CONTEXT:-}", compose_file) self.assertIn("PLATFORM_INSTANCE: ${PLATFORM_INSTANCE:-}", compose_file) + self.assertIn( + "ODOO_INSTANCE_OVERRIDES_PAYLOAD_B64: ${ODOO_INSTANCE_OVERRIDES_PAYLOAD_B64:-}", + compose_file, + ) + self.assertIn( + "LAUNCHPLANE_INSTANCE_OVERRIDES_REQUIRED: ${LAUNCHPLANE_INSTANCE_OVERRIDES_REQUIRED:-}", + compose_file, + ) + self.assertIn( + "LAUNCHPLANE_WEBSITE_BOOTSTRAP_REQUIRED: ${LAUNCHPLANE_WEBSITE_BOOTSTRAP_REQUIRED:-}", + compose_file, + ) self.assertIn('- "${ODOO_WEB_HOST_PORT:-8069}:8069"', compose_file) self.assertIn('- "${ODOO_LONGPOLL_HOST_PORT:-8072}:8072"', compose_file) self.assertIn("\n database:", compose_file) diff --git a/tests/test_odoo_instance_override_rendering.py b/tests/test_odoo_instance_override_rendering.py index d905630e..ac093d03 100644 --- a/tests/test_odoo_instance_override_rendering.py +++ b/tests/test_odoo_instance_override_rendering.py @@ -6,6 +6,7 @@ import click from control_plane.odoo_instance_overrides import ( + LAUNCHPLANE_INSTANCE_OVERRIDES_REQUIRED_ENV_KEY, ODOO_INSTANCE_OVERRIDES_PAYLOAD_ENV_KEY, build_post_deploy_environment, render_post_deploy_payload, @@ -99,7 +100,15 @@ def test_build_post_deploy_environment_sets_base64_payload_env(self) -> None: self.assertEqual(decoded_payload, render_post_deploy_payload(record).to_wire_dict()) self.assertEqual( - set(environment.inline_environment), {ODOO_INSTANCE_OVERRIDES_PAYLOAD_ENV_KEY} + set(environment.inline_environment), + { + ODOO_INSTANCE_OVERRIDES_PAYLOAD_ENV_KEY, + LAUNCHPLANE_INSTANCE_OVERRIDES_REQUIRED_ENV_KEY, + }, + ) + self.assertEqual( + environment.inline_environment[LAUNCHPLANE_INSTANCE_OVERRIDES_REQUIRED_ENV_KEY], + "true", ) def test_render_post_deploy_payload_preserves_website_bootstrap(self) -> None: diff --git a/tests/test_odoo_instance_overrides.py b/tests/test_odoo_instance_overrides.py index 9fed5470..ba89bcfd 100644 --- a/tests/test_odoo_instance_overrides.py +++ b/tests/test_odoo_instance_overrides.py @@ -6,6 +6,8 @@ from unittest.mock import patch import click +from control_plane.odoo_instance_overrides import LAUNCHPLANE_INSTANCE_OVERRIDES_REQUIRED_ENV_KEY +from control_plane.odoo_instance_overrides import LAUNCHPLANE_WEBSITE_BOOTSTRAP_REQUIRED_ENV_KEY from control_plane.odoo_instance_overrides import ODOO_INSTANCE_OVERRIDES_PAYLOAD_ENV_KEY from control_plane.odoo_instance_overrides import build_post_deploy_environment from control_plane.odoo_instance_overrides import render_post_deploy_payload @@ -25,6 +27,7 @@ OdooInstanceOverrideRecord, OdooOverrideApplyResult, OdooOverrideValue, + OdooWebsiteBootstrapPayload, ) from control_plane.contracts.secret_record import SecretBinding, SecretRecord, SecretVersion from control_plane.contracts.ship_request import ShipRequest @@ -168,6 +171,43 @@ def test_build_post_deploy_environment_reuses_typed_payload_required_keys(self) ).decode("utf-8") ) self.assertEqual(decoded_payload, environment.payload.to_wire_dict()) + self.assertEqual( + environment.inline_environment[LAUNCHPLANE_INSTANCE_OVERRIDES_REQUIRED_ENV_KEY], + "true", + ) + self.assertNotIn( + LAUNCHPLANE_WEBSITE_BOOTSTRAP_REQUIRED_ENV_KEY, + environment.inline_environment, + ) + + def test_build_post_deploy_environment_sets_website_bootstrap_required_flag(self) -> None: + record = OdooInstanceOverrideRecord( + context="cm", + instance="testing", + website_bootstrap=OdooWebsiteBootstrapPayload( + tenant="cm", + name="Cell Mechanic", + canonical_url="https://cm-testing.example.com", + ), + updated_at="2026-06-13T18:00:00Z", + ) + + environment = build_post_deploy_environment(record) + + decoded_payload = json.loads( + base64.b64decode( + environment.inline_environment[ODOO_INSTANCE_OVERRIDES_PAYLOAD_ENV_KEY] + ).decode("utf-8") + ) + self.assertIn("website_bootstrap", decoded_payload) + self.assertEqual( + environment.inline_environment[LAUNCHPLANE_WEBSITE_BOOTSTRAP_REQUIRED_ENV_KEY], + "true", + ) + self.assertNotIn( + LAUNCHPLANE_INSTANCE_OVERRIDES_REQUIRED_ENV_KEY, + environment.inline_environment, + ) def test_cli_put_config_param_does_not_echo_plaintext_value(self) -> None: with TemporaryDirectory() as temporary_directory_name: diff --git a/tests/test_odoo_stable_target_replacement.py b/tests/test_odoo_stable_target_replacement.py index cfd8406e..d3c3be59 100644 --- a/tests/test_odoo_stable_target_replacement.py +++ b/tests/test_odoo_stable_target_replacement.py @@ -1,3 +1,4 @@ +import base64 import json import unittest from pathlib import Path @@ -7,6 +8,9 @@ import click from control_plane import dokploy as control_plane_dokploy +from control_plane.odoo_instance_overrides import LAUNCHPLANE_INSTANCE_OVERRIDES_REQUIRED_ENV_KEY +from control_plane.odoo_instance_overrides import LAUNCHPLANE_WEBSITE_BOOTSTRAP_REQUIRED_ENV_KEY +from control_plane.odoo_instance_overrides import ODOO_INSTANCE_OVERRIDES_PAYLOAD_ENV_KEY from control_plane.contracts.artifact_identity import ( ArtifactImageReference, ArtifactIdentityManifest, @@ -15,7 +19,9 @@ from control_plane.contracts.dokploy_target_id_record import DokployTargetIdRecord from control_plane.contracts.dokploy_target_record import DokployTargetRecord from control_plane.contracts.environment_inventory import EnvironmentInventory +from control_plane.contracts.odoo_instance_override_record import OdooAddonSettingOverride from control_plane.contracts.odoo_instance_override_record import OdooInstanceOverrideRecord +from control_plane.contracts.odoo_instance_override_record import OdooOverrideValue from control_plane.contracts.odoo_instance_override_record import OdooWebsiteBootstrapPayload from control_plane.contracts.product_profile_record import ( LaunchplaneProductProfileRecord, @@ -750,6 +756,7 @@ def test_apply_recreates_target_in_place_and_writes_breadcrumb_inventory(self) - persisted_compose_file = "services: {}" domain_records: list[JsonValue] = [] deployment_records: list[JsonValue] = [{"deploymentId": "deploy-123", "status": "success"}] + stale_payload_b64 = base64.b64encode(b'{"stale":true}').decode("ascii") route_name = control_plane_dokploy._traefik_route_name( domain_host="cm-testing.shinycomputers.com" ) @@ -773,6 +780,8 @@ 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", + f"{ODOO_INSTANCE_OVERRIDES_PAYLOAD_ENV_KEY}={stale_payload_b64}", + f"{LAUNCHPLANE_INSTANCE_OVERRIDES_REQUIRED_ENV_KEY}=true", ) ), }, @@ -1014,6 +1023,25 @@ def _sync_source(*, compose_file: str, **_: object) -> dict[str, str]: self.assertIn("PLATFORM_INSTANCE=testing", persisted_env) self.assertNotIn("ODOO_WEB_COMMAND=/odoo/odoo-bin", persisted_env) self.assertIn("ODOO_WORKERS=2", persisted_env) + persisted_env_map = control_plane_dokploy.parse_dokploy_env_text(persisted_env) + self.assertEqual(persisted_env_map[LAUNCHPLANE_WEBSITE_BOOTSTRAP_REQUIRED_ENV_KEY], "true") + self.assertNotIn( + LAUNCHPLANE_INSTANCE_OVERRIDES_REQUIRED_ENV_KEY, + persisted_env_map, + ) + persisted_override_payload = json.loads( + base64.b64decode(persisted_env_map[ODOO_INSTANCE_OVERRIDES_PAYLOAD_ENV_KEY]).decode( + "utf-8" + ) + ) + self.assertEqual( + persisted_override_payload["website_bootstrap"]["canonical_url"], + "https://cm-testing.shinycomputers.com", + ) + self.assertEqual( + persisted_override_payload["website_bootstrap"]["homepage_url"], + "/cell-mechanic", + ) self.assertGreaterEqual(len(store.deployment_records), 2) final_deployment = store.deployment_records[-1] self.assertEqual(final_deployment.deploy.status, "pass") @@ -1090,6 +1118,16 @@ def _sync_source(*, compose_file: str, **_: object) -> dict[str, str]: final_deployment.runtime_source["post_deploy_container_has_dokploy_network"], "true", ) + self.assertEqual( + final_deployment.runtime_source["runtime_override_payload_rendered"], "true" + ) + self.assertEqual( + final_deployment.runtime_source["runtime_override_website_bootstrap_required"], + "true", + ) + self.assertEqual( + final_deployment.runtime_source["runtime_override_instance_required"], "false" + ) self.assertEqual(result.runtime_source, final_deployment.runtime_source) assert final_deployment.runtime_identity is not None self.assertEqual(final_deployment.runtime_identity.product, "odoo-tenant-cm") @@ -1369,6 +1407,110 @@ def _wait_for_deploy(**_: object) -> None: "false", ) + def test_apply_fails_before_deploy_when_override_secret_env_is_missing(self) -> None: + store = _Store( + target_record=_target_record(), + target_id_record=_target_id_record(), + inventory=_inventory(), + odoo_instance_override_record=OdooInstanceOverrideRecord( + context="cm", + instance="testing", + addon_settings=( + OdooAddonSettingOverride( + addon="openai", + setting="api_key", + value=OdooOverrideValue( + source="secret_binding", + secret_binding_id="secret-openai-api-key", + ), + ), + ), + updated_at="2026-06-13T18:00:00Z", + ), + ) + rendered_compose_file = control_plane_dokploy.render_odoo_raw_compose_file( + image_reference="ghcr.io/cbusillo/odoo-tenant-cm@sha256:artifact", + domain_hosts=("cm-testing.shinycomputers.com",), + runtime_port=8069, + ) + + with ( + patch( + "control_plane.workflows.odoo_stable_target_replacement.control_plane_dokploy.read_dokploy_config", + return_value=("host", "token"), + ), + patch( + "control_plane.workflows.odoo_stable_target_replacement.control_plane_dokploy.fetch_dokploy_target_payload", + return_value={ + "name": "cm-testing", + "sourceType": "raw", + "composePath": "docker-compose.yml", + "composeFile": rendered_compose_file, + "env": "\n".join( + ( + "ODOO_DATA_VOLUME=cm_testing_odoo_data", + "ODOO_LOG_VOLUME=cm_testing_odoo_logs", + "ODOO_DB_VOLUME=cm_testing_odoo_db", + ) + ), + "appName": "cm-testing", + "serverId": "server-123", + "deployments": [{"deploymentId": "deploy-123", "status": "done"}], + }, + ), + patch( + "control_plane.workflows.odoo_stable_target_replacement.control_plane_dokploy.latest_deployment_for_target", + return_value={"deploymentId": "deploy-123", "status": "success"}, + ), + patch( + "control_plane.workflows.odoo_stable_target_replacement.control_plane_runtime_environments.resolve_runtime_environment_values", + return_value={}, + ), + patch( + "control_plane.workflows.odoo_stable_target_replacement.control_plane_dokploy.sync_dokploy_compose_raw_source", + return_value={"source_type": "raw", "changed": "true"}, + ) as sync_source, + patch( + "control_plane.workflows.odoo_stable_target_replacement.control_plane_dokploy.render_odoo_raw_compose_file", + return_value=rendered_compose_file, + ), + patch( + "control_plane.workflows.odoo_stable_target_replacement.control_plane_dokploy.ensure_compose_web_domain_route" + ), + patch( + "control_plane.workflows.odoo_stable_target_replacement.control_plane_dokploy.fetch_dokploy_converted_compose_file", + return_value=rendered_compose_file, + ), + patch( + "control_plane.workflows.odoo_stable_target_replacement.control_plane_dokploy.update_dokploy_target_env" + ) as update_env, + patch( + "control_plane.workflows.odoo_stable_target_replacement.control_plane_dokploy.trigger_deployment" + ) as trigger_deploy, + patch( + "control_plane.workflows.odoo_stable_target_replacement.execute_odoo_post_deploy" + ) as post_deploy, + ): + result = execute_odoo_stable_target_replacement_apply( + control_plane_root=Path("."), + record_store=store, + request=OdooStableTargetReplacementApplyRequest( + product="odoo-tenant-cm", instance="testing" + ), + dokploy_request=cast(DokployRequest, _request), + ) + + self.assertEqual(result.deploy_status, "fail") + self.assertIn( + "Odoo target replacement requires override secret env key(s) before deployment", + result.error_message, + ) + self.assertIn("ODOO_OVERRIDE_SECRET__ADDON__OPENAI__API_KEY", result.error_message) + sync_source.assert_called_once() + update_env.assert_not_called() + trigger_deploy.assert_not_called() + post_deploy.assert_not_called() + def test_apply_requests_destructive_restore_for_upstream_restore_mode(self) -> None: profile = _opw_profile_with_prelaunch_policy(enabled=True) manifest = _artifact_manifest(