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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
26 changes: 20 additions & 6 deletions docker/scripts/run_odoo_startup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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")

Expand Down
32 changes: 32 additions & 0 deletions tests/test_odoo_startup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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()