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
39 changes: 38 additions & 1 deletion docker/scripts/odoo_website_bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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"
Expand Down Expand Up @@ -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.")
Expand Down
12 changes: 12 additions & 0 deletions docker/scripts/run_odoo_data_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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__')
Expand All @@ -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()
Expand Down
2 changes: 2 additions & 0 deletions docker/scripts/run_odoo_startup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
7 changes: 7 additions & 0 deletions docs/tooling/workspace-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions odoo_devkit/local_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions tests/test_docker_script_override_guards.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)


Expand Down
39 changes: 38 additions & 1 deletion tests/test_odoo_data_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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


Expand Down Expand Up @@ -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()
75 changes: 75 additions & 0 deletions tests/test_odoo_website_bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 = {
Expand Down
4 changes: 4 additions & 0 deletions tests/test_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -281,13 +281,17 @@ 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",
}

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)

Expand Down