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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,10 @@ Current runtime ownership is intentionally narrow and explicit:
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 single-database runtimes pin both `db_name` and `dbfilter` to the
configured `ODOO_DB_NAME`, and keep database listing disabled. This keeps
normal website requests on the public hostname bound to the tenant database
instead of falling through to Odoo's database selector.
- 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
Expand Down
9 changes: 9 additions & 0 deletions docker/scripts/run_odoo_startup.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import configparser
import json
import os
import re
import subprocess
import sys
import time
Expand Down Expand Up @@ -139,6 +140,10 @@ def _is_public_runtime(settings: StartupSettings) -> bool:
return settings.platform_instance.strip().lower() not in LOCAL_INSTANCE_NAMES


def _database_filter_pattern(database_name: str) -> str:
return f"^{re.escape(database_name)}$"


def _enforce_public_credential_preflight(settings: StartupSettings) -> None:
if not _is_public_runtime(settings):
return
Expand All @@ -158,6 +163,8 @@ def _write_runtime_config(settings: StartupSettings) -> None:
options = config_parser["options"]
options["admin_passwd"] = settings.master_password
options["db_name"] = settings.database_name
if _is_public_runtime(settings):
options["dbfilter"] = _database_filter_pattern(settings.database_name)
options["db_user"] = settings.database_user
options["db_password"] = settings.database_password
options["db_host"] = settings.database_host
Expand Down Expand Up @@ -267,6 +274,8 @@ def _build_odoo_command(
f"--db_user={settings.database_user}",
f"--db_password={settings.database_password}",
]
if _is_public_runtime(settings):
command.append(f"--db-filter={_database_filter_pattern(settings.database_name)}")

if initialize_modules is not None:
normalized_modules = ["base", *initialize_modules]
Expand Down
67 changes: 67 additions & 0 deletions tests/test_odoo_startup.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import argparse
import configparser
import importlib.util
import os
import sys
Expand Down Expand Up @@ -149,6 +150,72 @@ def test_public_runtime_accepts_non_default_configured_credentials(self) -> None

odoo_startup._enforce_public_credential_preflight(settings)

def test_public_runtime_config_pins_http_database_filter_to_configured_database(self) -> None:
settings = self._settings(platform_instance="testing", admin_password="safe-admin-password")
parser = configparser.ConfigParser(interpolation=None)

with patch("builtins.open", unittest.mock.mock_open()) as open_mock:
odoo_startup._write_runtime_config(settings)

written_config = "".join(call.args[0] for call in open_mock().write.call_args_list)
parser.read_string(written_config)

self.assertEqual(parser["options"]["db_name"], "opw")
self.assertEqual(parser["options"]["dbfilter"], "^opw$")

def test_local_runtime_config_does_not_pin_http_database_filter(self) -> None:
settings = self._settings(platform_instance="local")
parser = configparser.ConfigParser(interpolation=None)

with patch("builtins.open", unittest.mock.mock_open()) as open_mock:
odoo_startup._write_runtime_config(settings)

written_config = "".join(call.args[0] for call in open_mock().write.call_args_list)
parser.read_string(written_config)

self.assertEqual(parser["options"]["db_name"], "opw")
self.assertNotIn("dbfilter", parser["options"])

def test_database_filter_escapes_database_name(self) -> None:
pattern = odoo_startup._database_filter_pattern("tenant.prod")

self.assertEqual(pattern, r"^tenant\.prod$")

def test_public_odoo_server_command_pins_database_filter_to_configured_database(self) -> None:
settings = self._settings(platform_instance="testing", admin_password="safe-admin-password")

command = odoo_startup._build_odoo_command(settings, stop_after_init=False)

self.assertIn("-d", command)
self.assertIn("opw", command)
self.assertIn("--db-filter=^opw$", command)

def test_public_odoo_init_command_pins_database_filter_to_configured_database(self) -> None:
settings = self._settings(platform_instance="testing", admin_password="safe-admin-password")

command = odoo_startup._build_odoo_command(
settings,
initialize_modules=("opw_custom",),
stop_after_init=True,
)

self.assertIn("--db-filter=^opw$", command)
self.assertIn("--stop-after-init", command)

def test_local_odoo_server_command_does_not_pin_database_filter(self) -> None:
settings = self._settings(platform_instance="local")

command = odoo_startup._build_odoo_command(settings, stop_after_init=False)

self.assertNotIn("--db-filter=^opw$", command)

def test_odoo_shell_command_does_not_pin_database_filter(self) -> None:
settings = self._settings(platform_instance="testing", admin_password="safe-admin-password")

command = odoo_startup._build_odoo_shell_command(settings)

self.assertFalse(any(argument.startswith("--db-filter=") for argument in command))

def test_local_runtime_allows_missing_admin_password(self) -> None:
settings = self._settings(platform_instance="local", admin_password="")

Expand Down