From 99da8145ff11e376daa9f8f2b79ac85071859fb6 Mon Sep 17 00:00:00 2001 From: Chris Busillo Date: Sat, 13 Jun 2026 18:55:14 -0400 Subject: [PATCH] Fail closed when Launchplane override payloads are required --- docker/scripts/odoo_website_bootstrap.py | 39 ++++++++++- docker/scripts/run_odoo_data_workflows.py | 12 ++++ docker/scripts/run_odoo_startup.py | 2 + docs/tooling/workspace-cli.md | 7 ++ odoo_devkit/local_runtime.py | 6 ++ tests/test_docker_script_override_guards.py | 5 ++ tests/test_odoo_data_workflows.py | 39 ++++++++++- tests/test_odoo_website_bootstrap.py | 75 +++++++++++++++++++++ tests/test_runtime.py | 4 ++ 9 files changed, 187 insertions(+), 2 deletions(-) diff --git a/docker/scripts/odoo_website_bootstrap.py b/docker/scripts/odoo_website_bootstrap.py index 23fa76d..74d0c1a 100644 --- a/docker/scripts/odoo_website_bootstrap.py +++ b/docker/scripts/odoo_website_bootstrap.py @@ -7,6 +7,17 @@ from urllib.parse import urlparse 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" + + +def _env_flag_enabled(name: str) -> bool: + raw_value = os.environ.get(name, "").strip().lower() + if raw_value in {"", "0", "false", "no", "off"}: + return False + if raw_value in {"1", "true", "yes", "on"}: + return True + raise RuntimeError(f"Environment flag {name} must be one of true/false, 1/0, yes/no, or on/off.") def load_instance_override_payload() -> dict[str, object] | None: @@ -29,6 +40,28 @@ def payload_has_launchplane_settings(parsed_payload: dict[str, object] | None) - return bool(parsed_payload.get("config_parameters") or parsed_payload.get("addon_settings")) +def payload_has_website_bootstrap(parsed_payload: dict[str, object] | None) -> bool: + if not parsed_payload: + return False + website_payload = parsed_payload.get("website_bootstrap") + if website_payload is None: + return False + if not isinstance(website_payload, dict): + raise RuntimeError("Odoo instance override payload field 'website_bootstrap' must be an object.") + return bool(website_payload) + + +def require_launchplane_payloads_if_configured(parsed_payload: dict[str, object] | None) -> None: + website_bootstrap_present = payload_has_website_bootstrap(parsed_payload) + if _env_flag_enabled(LAUNCHPLANE_INSTANCE_OVERRIDES_REQUIRED_ENV_KEY): + if not parsed_payload: + raise RuntimeError("Launchplane instance overrides are required, but ODOO_INSTANCE_OVERRIDES_PAYLOAD_B64 is missing.") + if not payload_has_launchplane_settings(parsed_payload): + raise RuntimeError("Launchplane instance overrides are required, but the override payload has no managed settings.") + if _env_flag_enabled(LAUNCHPLANE_WEBSITE_BOOTSTRAP_REQUIRED_ENV_KEY) and not website_bootstrap_present: + raise RuntimeError("Launchplane website bootstrap is required, but the override payload has no website_bootstrap object.") + + def _normalize_scalar_override_value(raw_value: object) -> str: if isinstance(raw_value, bool): return "True" if raw_value else "False" @@ -378,7 +411,11 @@ def apply_website_bootstrap(env: Any, parsed_payload: dict[str, object] | None) if not parsed_payload: return website_payload = parsed_payload.get("website_bootstrap") - if not isinstance(website_payload, dict) or not website_payload: + if website_payload is None: + return + if not isinstance(website_payload, dict): + raise RuntimeError("Odoo instance override payload field 'website_bootstrap' must be an object.") + if not website_payload: return if "website" not in env.registry: raise RuntimeError("Website bootstrap supplied, but the website module is not installed.") diff --git a/docker/scripts/run_odoo_data_workflows.py b/docker/scripts/run_odoo_data_workflows.py index bc5709c..ab04f13 100644 --- a/docker/scripts/run_odoo_data_workflows.py +++ b/docker/scripts/run_odoo_data_workflows.py @@ -16,8 +16,10 @@ from dataclasses import dataclass from enum import Enum, IntEnum from pathlib import Path +from unittest.mock import patch import psycopg2 +from odoo_website_bootstrap import load_instance_override_payload, require_launchplane_payloads_if_configured from passlib.context import CryptContext from psycopg2 import sql from psycopg2.extensions import connection @@ -138,6 +140,7 @@ def main(argv: Sequence[str] | None = None) -> int: workflow_runner.acquire_data_workflow_lock() lock_acquired = True if update_only: + workflow_runner.require_environment_override_payloads_if_configured() workflow_runner.update_addons(reason="post-deploy upgrade") _logger.info("Addon update completed successfully.") return ExitCode.SUCCESS @@ -417,6 +420,13 @@ def __init__( self._odoo_shell_preflight_checked = False self._data_workflow_lock_path: Path | None = None + def require_environment_override_payloads_if_configured(self) -> None: + try: + with patch.dict(os.environ, self.os_env, clear=True): + require_launchplane_payloads_if_configured(load_instance_override_payload()) + except RuntimeError as error: + raise OdooDatabaseUpdateError("Failed Launchplane payload requirement check.") from error + def acquire_data_workflow_lock(self) -> None: lock_path = self.local.data_workflow_lock_file lock_parent = lock_path.parent @@ -1148,6 +1158,7 @@ def apply_environment_overrides(self) -> None: apply_website_bootstrap, load_instance_override_payload, payload_has_launchplane_settings, + require_launchplane_payloads_if_configured, ) payload = json.loads('__PAYLOAD__') @@ -1157,6 +1168,7 @@ def apply_environment_overrides(self) -> None: env = api.Environment(cr, SUPERUSER_ID, {}) instance_override_payload = load_instance_override_payload() typed_override_payload_present = instance_override_payload is not None + require_launchplane_payloads_if_configured(instance_override_payload) if 'launchplane.settings' in env.registry: env['launchplane.settings'].sudo().apply_from_env() cr.commit() diff --git a/docker/scripts/run_odoo_startup.py b/docker/scripts/run_odoo_startup.py index 8f1cc33..7099d4f 100644 --- a/docker/scripts/run_odoo_startup.py +++ b/docker/scripts/run_odoo_startup.py @@ -439,10 +439,12 @@ def _apply_environment_overrides_if_available(settings: StartupSettings) -> None apply_website_bootstrap, load_instance_override_payload, payload_has_launchplane_settings, + require_launchplane_payloads_if_configured, ) instance_override_payload = load_instance_override_payload() typed_override_payload_present = instance_override_payload is not None +require_launchplane_payloads_if_configured(instance_override_payload) if 'launchplane.settings' in env.registry: env['launchplane.settings'].sudo().apply_from_env() elif payload_has_launchplane_settings(instance_override_payload): diff --git a/docs/tooling/workspace-cli.md b/docs/tooling/workspace-cli.md index 3cdc423..fa53178 100644 --- a/docs/tooling/workspace-cli.md +++ b/docs/tooling/workspace-cli.md @@ -192,6 +192,13 @@ Notes their website-specific views when available, and route readback markers to the selected website so post-deploy proof can distinguish payload rendering from public website identity persistence. +- Non-local Launchplane-managed runtimes can set + `LAUNCHPLANE_INSTANCE_OVERRIDES_REQUIRED=true` to require a valid typed + override payload with managed settings before startup or data workflows + continue. `LAUNCHPLANE_WEBSITE_BOOTSTRAP_REQUIRED=true` additionally requires + a non-empty `website_bootstrap` object in that payload. These flags are + runtime assertions supplied by Launchplane-managed records or operator input; + local/dev runtimes remain optional unless a caller explicitly sets them. - Legacy setting-shaped inputs such as `ENV_OVERRIDE_CONFIG_PARAM__*`, `ENV_OVERRIDE_AUTHENTIK__*`, and `ENV_OVERRIDE_SHOPIFY__*` are still accepted as a compatibility input and converted into the same typed payload, but they diff --git a/odoo_devkit/local_runtime.py b/odoo_devkit/local_runtime.py index c6ea068..5c99bfb 100644 --- a/odoo_devkit/local_runtime.py +++ b/odoo_devkit/local_runtime.py @@ -33,6 +33,8 @@ SOURCE_GITHUB_TOKEN_ENV_KEYS = ("ODOO_DEVKIT_SOURCE_GITHUB_TOKEN", "ODOO_SOURCE_GITHUB_TOKEN") RUNTIME_ENVIRONMENT_PAYLOAD_ENV_VAR = "ODOO_DEVKIT_RUNTIME_ENVIRONMENT_JSON" 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" LEGACY_CONFIG_PARAM_PREFIX = "ENV_OVERRIDE_CONFIG_PARAM__" LEGACY_AUTHENTIK_PREFIX = "ENV_OVERRIDE_AUTHENTIK__" LEGACY_SHOPIFY_PREFIX = "ENV_OVERRIDE_SHOPIFY__" @@ -131,6 +133,8 @@ "ODOO_BASE_RUNTIME_IMAGE", "ODOO_BASE_DEVTOOLS_IMAGE", "DOCKER_IMAGE_REFERENCE", + LAUNCHPLANE_INSTANCE_OVERRIDES_REQUIRED_ENV_KEY, + LAUNCHPLANE_WEBSITE_BOOTSTRAP_REQUIRED_ENV_KEY, ) DATA_WORKFLOW_SCRIPT = "/volumes/scripts/run_odoo_data_workflows.py" @@ -159,6 +163,8 @@ "ODOO_ADMIN_LOGIN", "ODOO_ADMIN_PASSWORD", ODOO_INSTANCE_OVERRIDES_PAYLOAD_ENV_KEY, + LAUNCHPLANE_INSTANCE_OVERRIDES_REQUIRED_ENV_KEY, + LAUNCHPLANE_WEBSITE_BOOTSTRAP_REQUIRED_ENV_KEY, "ODOO_DATA_WORKFLOW_LOCK_FILE", "ODOO_UPSTREAM_HOST", "ODOO_UPSTREAM_USER", diff --git a/tests/test_docker_script_override_guards.py b/tests/test_docker_script_override_guards.py index d904f04..643111e 100644 --- a/tests/test_docker_script_override_guards.py +++ b/tests/test_docker_script_override_guards.py @@ -10,6 +10,7 @@ def test_data_workflow_fails_when_typed_override_payload_has_no_consumer(self) - self.assertIn("typed_override_payload_present", script) self.assertIn("payload_has_launchplane_settings", script) + self.assertIn("require_launchplane_payloads_if_configured", script) self.assertIn("ODOO_INSTANCE_OVERRIDES_PAYLOAD_B64", script) self.assertIn("launchplane.settings", script) self.assertIn("but launchplane.settings is not installed", script) @@ -23,6 +24,7 @@ def test_startup_fails_when_typed_override_payload_has_no_consumer(self) -> None self.assertIn("typed_override_payload_present", script) self.assertIn("payload_has_launchplane_settings", script) + self.assertIn("require_launchplane_payloads_if_configured", script) self.assertIn("ODOO_INSTANCE_OVERRIDES_PAYLOAD_B64", script) self.assertIn("launchplane.settings", script) self.assertIn("but launchplane.settings is not installed", script) @@ -37,6 +39,9 @@ def test_website_bootstrap_helper_is_part_of_docker_payload(self) -> None: self.assertIn("COPY /docker/scripts /payload/volumes/scripts", dockerfile) self.assertIn("def apply_website_bootstrap", helper) + self.assertIn("LAUNCHPLANE_INSTANCE_OVERRIDES_REQUIRED", helper) + self.assertIn("LAUNCHPLANE_WEBSITE_BOOTSTRAP_REQUIRED", helper) + self.assertIn("def require_launchplane_payloads_if_configured", helper) self.assertIn("website_bootstrap_applied=true", helper) diff --git a/tests/test_odoo_data_workflows.py b/tests/test_odoo_data_workflows.py index ae126f0..a6ea892 100644 --- a/tests/test_odoo_data_workflows.py +++ b/tests/test_odoo_data_workflows.py @@ -16,6 +16,7 @@ def _load_data_workflows_module() -> types.ModuleType: raise RuntimeError(f"Unable to load module from {module_path}") module = importlib.util.module_from_spec(spec) sys.modules[spec.name] = module + original_sys_path = list(sys.path) psycopg2_module = types.ModuleType("psycopg2") psycopg2_module.sql = types.SimpleNamespace(SQL=lambda value: value, Identifier=lambda value: value) @@ -29,7 +30,11 @@ def _load_data_workflows_module() -> types.ModuleType: "psycopg2.extensions": psycopg2_extensions_module, }, ): - spec.loader.exec_module(module) + try: + sys.path.insert(0, str(module_path.parent)) + spec.loader.exec_module(module) + finally: + sys.path[:] = original_sys_path return module @@ -117,6 +122,38 @@ def test_update_only_and_post_deploy_maintenance_are_mutually_exclusive(self) -> self.assertEqual(result, odoo_data_workflows.ExitCode.INVALID_ARGS) + def test_update_only_requires_configured_launchplane_payload_before_addon_update(self) -> None: + with ( + patch.dict( + os.environ, + { + "ODOO_DB_HOST": "database", + "ODOO_DB_USER": "odoo", + "ODOO_DB_PASSWORD": "database-password", + "ODOO_DB_NAME": "cm", + "ODOO_FILESTORE_PATH": "/volumes/data/filestore/cm", + "LAUNCHPLANE_INSTANCE_OVERRIDES_REQUIRED": "true", + }, + clear=True, + ), + patch.object( + odoo_data_workflows.OdooDataWorkflowRunner, + "acquire_data_workflow_lock", + ), + patch.object( + odoo_data_workflows.OdooDataWorkflowRunner, + "release_data_workflow_lock", + ), + patch.object( + odoo_data_workflows.OdooDataWorkflowRunner, + "update_addons", + ) as update_addons, + ): + result = odoo_data_workflows.main(["--update-only"]) + + self.assertEqual(result, odoo_data_workflows.ExitCode.BOOTSTRAP_FAILED) + update_addons.assert_not_called() + if __name__ == "__main__": unittest.main() diff --git a/tests/test_odoo_website_bootstrap.py b/tests/test_odoo_website_bootstrap.py index 5d0fe70..88d15e8 100644 --- a/tests/test_odoo_website_bootstrap.py +++ b/tests/test_odoo_website_bootstrap.py @@ -2,12 +2,14 @@ import importlib.util import io +import os import sys import types import unittest from contextlib import redirect_stdout from pathlib import Path from typing import Any +from unittest.mock import patch def _load_bootstrap_module() -> types.ModuleType: @@ -139,6 +141,79 @@ def ref(self, xmlid: str, *unused_args: object, **unused_kwargs: object) -> Fake class WebsiteBootstrapHelperTests(unittest.TestCase): + def test_required_instance_overrides_fail_without_payload(self) -> None: + with patch.dict( + os.environ, + {website_bootstrap.LAUNCHPLANE_INSTANCE_OVERRIDES_REQUIRED_ENV_KEY: "true"}, + clear=True, + ): + with self.assertRaisesRegex(RuntimeError, "instance overrides are required"): + website_bootstrap.require_launchplane_payloads_if_configured(None) + + def test_required_instance_overrides_fail_without_managed_settings(self) -> None: + with patch.dict( + os.environ, + {website_bootstrap.LAUNCHPLANE_INSTANCE_OVERRIDES_REQUIRED_ENV_KEY: "true"}, + clear=True, + ): + with self.assertRaisesRegex(RuntimeError, "has no managed settings"): + website_bootstrap.require_launchplane_payloads_if_configured({"website_bootstrap": {"name": "Cell Mechanic"}}) + + def test_required_instance_overrides_pass_with_config_parameters(self) -> None: + with patch.dict( + os.environ, + {website_bootstrap.LAUNCHPLANE_INSTANCE_OVERRIDES_REQUIRED_ENV_KEY: "1"}, + clear=True, + ): + website_bootstrap.require_launchplane_payloads_if_configured({"config_parameters": [{"key": "web.base.url"}]}) + + def test_required_website_bootstrap_fails_without_website_payload(self) -> None: + with patch.dict( + os.environ, + {website_bootstrap.LAUNCHPLANE_WEBSITE_BOOTSTRAP_REQUIRED_ENV_KEY: "true"}, + clear=True, + ): + with self.assertRaisesRegex(RuntimeError, "website bootstrap is required"): + website_bootstrap.require_launchplane_payloads_if_configured({"config_parameters": [{"key": "web.base.url"}]}) + + def test_required_website_bootstrap_fails_with_empty_website_payload(self) -> None: + with patch.dict( + os.environ, + {website_bootstrap.LAUNCHPLANE_WEBSITE_BOOTSTRAP_REQUIRED_ENV_KEY: "yes"}, + clear=True, + ): + with self.assertRaisesRegex(RuntimeError, "website bootstrap is required"): + website_bootstrap.require_launchplane_payloads_if_configured({"website_bootstrap": {}}) + + def test_required_website_bootstrap_passes_with_website_payload(self) -> None: + with patch.dict( + os.environ, + {website_bootstrap.LAUNCHPLANE_WEBSITE_BOOTSTRAP_REQUIRED_ENV_KEY: "on"}, + clear=True, + ): + website_bootstrap.require_launchplane_payloads_if_configured({"website_bootstrap": {"name": "Cell Mechanic"}}) + + def test_optional_launchplane_payloads_allow_missing_payload(self) -> None: + with patch.dict(os.environ, {}, clear=True): + website_bootstrap.require_launchplane_payloads_if_configured(None) + + def test_invalid_required_flag_value_fails(self) -> None: + with patch.dict( + os.environ, + {website_bootstrap.LAUNCHPLANE_INSTANCE_OVERRIDES_REQUIRED_ENV_KEY: "maybe"}, + clear=True, + ): + with self.assertRaisesRegex(RuntimeError, "Environment flag"): + website_bootstrap.require_launchplane_payloads_if_configured({"config_parameters": []}) + + def test_malformed_website_bootstrap_payload_fails(self) -> None: + payload = {"website_bootstrap": []} + + with self.assertRaisesRegex(RuntimeError, "website_bootstrap.*object"): + website_bootstrap.require_launchplane_payloads_if_configured(payload) + with self.assertRaisesRegex(RuntimeError, "website_bootstrap.*object"): + website_bootstrap.apply_website_bootstrap(FakeEnv(), payload) + def test_controller_homepage_route_persists_homepage_url_and_clears_stale_page_homepage(self) -> None: env = FakeEnv() payload = { diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 276c239..421a213 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -281,6 +281,8 @@ def test_typed_odoo_instance_override_payload_includes_website_bootstrap(self) - def test_data_workflow_script_environment_keeps_typed_payload(self) -> None: environment = { local_runtime.ODOO_INSTANCE_OVERRIDES_PAYLOAD_ENV_KEY: "encoded-payload", + local_runtime.LAUNCHPLANE_INSTANCE_OVERRIDES_REQUIRED_ENV_KEY: "true", + local_runtime.LAUNCHPLANE_WEBSITE_BOOTSTRAP_REQUIRED_ENV_KEY: "true", "ODOO_DB_NAME": "opw", "UNRELATED": "value", } @@ -288,6 +290,8 @@ def test_data_workflow_script_environment_keeps_typed_payload(self) -> None: filtered_environment = local_runtime.data_workflow_script_environment(environment) self.assertEqual(filtered_environment[local_runtime.ODOO_INSTANCE_OVERRIDES_PAYLOAD_ENV_KEY], "encoded-payload") + self.assertEqual(filtered_environment[local_runtime.LAUNCHPLANE_INSTANCE_OVERRIDES_REQUIRED_ENV_KEY], "true") + self.assertEqual(filtered_environment[local_runtime.LAUNCHPLANE_WEBSITE_BOOTSTRAP_REQUIRED_ENV_KEY], "true") self.assertEqual(filtered_environment["ODOO_DB_NAME"], "opw") self.assertNotIn("UNRELATED", filtered_environment)