From 50843cee2b524ce921279bcd580b652a5a30dc2f Mon Sep 17 00:00:00 2001 From: Chris Busillo Date: Sat, 30 May 2026 23:04:44 -0400 Subject: [PATCH] Harden startup shell runtime defaults --- README.md | 8 ++++++++ docker/scripts/run_odoo_startup.py | 26 ++++++++++++++++++------ tests/test_odoo_startup.py | 32 ++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index e7a3666..278f1c9 100644 --- a/README.md +++ b/README.md @@ -110,8 +110,16 @@ Current runtime ownership is intentionally narrow and explicit: `base,web,launchplane_runtime_health` as server-wide modules by default. Keep that addon root in the rendered `ODOO_ADDONS_PATH` so startup scripts, generated Odoo config, and wrapper-normalized server commands agree. + Startup shell phases normalize `/opt/launchplane/addons` into their generated + config before running database updates so server-wide runtime health stays + loadable even when downstream image layers override `ODOO_ADDONS_PATH`. `/web/health` remains the local container liveness check; Launchplane runtime identity evidence is exposed by the base image at `/launchplane/health`. +- Public runtimes require `ODOO_ADMIN_PASSWORD`, but startup skips admin + hardening when the configured `ODOO_ADMIN_LOGIN` is absent in a restored + tenant database. This preserves boot for tenant databases that renamed or + removed the default `admin` login while still checking active default admin + passwords when matching users exist. - A Postgres major-version bump is not a routine dependency refresh on this surface. Treat it as explicit migration work with a documented upgrade path for existing tenant data volumes. diff --git a/docker/scripts/run_odoo_startup.py b/docker/scripts/run_odoo_startup.py index 3960d0d..1af5759 100644 --- a/docker/scripts/run_odoo_startup.py +++ b/docker/scripts/run_odoo_startup.py @@ -38,6 +38,7 @@ UNSAFE_MASTER_PASSWORDS = {"admin"} LOCAL_INSTANCE_NAMES = {"", "local", "dev", "development"} RUNTIME_SCRIPTS_PATH = "/volumes/scripts" +LAUNCHPLANE_ADDONS_PATH = "/opt/launchplane/addons" @dataclass(frozen=True) @@ -75,6 +76,16 @@ def _split_modules(raw_modules: str) -> tuple[str, ...]: return tuple(parsed_modules) +def _normalize_comma_list_with_first_item(first_item: str, raw_list: str) -> str: + normalized_items = [first_item] + for raw_item in raw_list.split(","): + item = raw_item.strip() + if not item or item in normalized_items: + continue + normalized_items.append(item) + return ",".join(normalized_items) + + def _parse_arguments() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Bootstrap Odoo database and launch web server") parser.add_argument("-c", "--config", dest="config_path", required=True) @@ -106,7 +117,10 @@ def _load_settings(argument_namespace: argparse.Namespace) -> StartupSettings: master_password=master_password, admin_login=os.environ.get("ODOO_ADMIN_LOGIN", "").strip() or "admin", admin_password=os.environ.get("ODOO_ADMIN_PASSWORD", "").strip(), - addons_path=os.environ.get("ODOO_ADDONS_PATH", "").strip(), + addons_path=_normalize_comma_list_with_first_item( + LAUNCHPLANE_ADDONS_PATH, + os.environ.get("ODOO_ADDONS_PATH", "").strip(), + ), data_dir=os.environ.get("ODOO_DATA_DIR", "/volumes/data").strip() or "/volumes/data", list_db=os.environ.get("ODOO_LIST_DB", "False").strip() or "False", install_modules=install_modules, @@ -401,11 +415,11 @@ def _apply_admin_password_if_configured(settings: StartupSettings) -> None: limit=1, ) if not admin_user: - raise ValueError(f"Configured admin user not found: {payload['login']}") - -admin_user.with_context(no_reset_password=True).sudo().write({'password': payload['password']}) -env.cr.commit() -print('admin_password_updated=true') + print(f"configured_admin_user_found=false login={payload['login']}") +else: + admin_user.with_context(no_reset_password=True).sudo().write({'password': payload['password']}) + env.cr.commit() + print('admin_password_updated=true') """.replace("__PAYLOAD__", json.dumps(payload)) _run_odoo_shell(settings, script, label="admin hardening") diff --git a/tests/test_odoo_startup.py b/tests/test_odoo_startup.py index 24e0b27..b843767 100644 --- a/tests/test_odoo_startup.py +++ b/tests/test_odoo_startup.py @@ -81,6 +81,27 @@ def test_load_settings_reads_platform_instance(self) -> None: settings = odoo_startup._load_settings(argparse.Namespace(config_path="/tmp/generated.conf")) self.assertEqual(settings.platform_instance, "local") + self.assertEqual(settings.addons_path, "/opt/launchplane/addons,/odoo/addons") + + def test_load_settings_preserves_launchplane_addon_path_first(self) -> None: + environment = { + "PLATFORM_INSTANCE": "local", + "ODOO_DB_NAME": "opw", + "ODOO_DB_HOST": "database", + "ODOO_DB_PORT": "5432", + "ODOO_DB_USER": "odoo", + "ODOO_DB_PASSWORD": "database-password", + "ODOO_MASTER_PASSWORD": "master-password", + "ODOO_ADDONS_PATH": "/opt/project/addons,/opt/launchplane/addons,/odoo/addons", + } + + with patch.dict(os.environ, environment, clear=True): + settings = odoo_startup._load_settings(argparse.Namespace(config_path="/tmp/generated.conf")) + + self.assertEqual( + settings.addons_path, + "/opt/launchplane/addons,/opt/project/addons,/odoo/addons", + ) @staticmethod def test_sync_python_dependencies_runs_for_local_dev_runtime() -> None: @@ -159,6 +180,17 @@ def test_odoo_shell_subprocess_prepends_runtime_scripts_to_pythonpath(self) -> N environment = run_mock.call_args.kwargs["env"] self.assertEqual(environment["PYTHONPATH"], "/volumes/scripts:/opt/custom") + def test_admin_hardening_skips_missing_configured_admin(self) -> None: + settings = self._settings(platform_instance="testing", admin_password="safe-admin-password") + + with patch.object(odoo_startup, "_run_odoo_shell") as run_shell: + odoo_startup._apply_admin_password_if_configured(settings) + + run_shell.assert_called_once() + script_text = run_shell.call_args.args[1] + self.assertIn("configured_admin_user_found=false", script_text) + self.assertNotIn("Configured admin user not found", script_text) + if __name__ == "__main__": unittest.main()