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
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,24 @@ Current runtime ownership is intentionally narrow and explicit:

## Runtime Contract Notes

- The startup wrapper maps explicitly supplied `ODOO_SMTP_SERVER`,
`ODOO_SMTP_PORT`, `ODOO_SMTP_USER`, `ODOO_SMTP_PASSWORD`, `ODOO_SMTP_SSL`,
`ODOO_EMAIL_FROM`, and `ODOO_FROM_FILTER` to Odoo's native mail options.
`ODOO_SMTP_SSL=True` selects STARTTLS; it is not implicit TLS on port 465.
Launchplane owns hosted values and secret bindings. Empty supplied values
clear inherited mail options; omitted values preserve the base config.
The generated config containing credentials is readable only by its owner.
Existing Odoo outgoing-server records take precedence over the config fallback.
Restored databases that undergo sanitization get an active dummy outgoing
server, following Odoo's neutralization behavior, so even configured SMTP
fallback cannot send copied customer mail. Copied SMTP usernames/passwords are
cleared. Fresh bootstrap does not insert that dummy server, so an empty new
database can use the operator's explicitly supplied mail configuration.
- An optional `company_email` in Launchplane's website-bootstrap payload sets
the selected website company's sender address and verifies it was saved.
Omission preserves the existing company email. This fixes the company sender
used by native website contact forms without changing form submissions or
sending email during bootstrap.
- The shared tenant compose database service stays pinned to `postgres:17`
while existing tenant DB volumes still use the legacy
`/var/lib/postgresql/data` layout.
Expand Down
11 changes: 11 additions & 0 deletions docker/scripts/odoo_website_bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,17 @@ def apply_website_bootstrap(env: Any, parsed_payload: dict[str, object] | None)
create_values = _field_values(website_model, {"name": default_name})
website = website_model.create(create_values or {"name": default_name})

company_email = str(website_payload.get("company_email") or "").strip()
if company_email:
_require_existing_fields(website, ("company_id",), label="website company")
company = website.company_id
if not company:
raise RuntimeError("Website bootstrap cannot set company email; the selected website has no company.")
_require_existing_fields(company, ("email",), label="company email")
company.sudo().write({"email": company_email})
_assert_field_value(company, "email", company_email, label="company email")
print("website_bootstrap_company_email_matches=true")

website_values: dict[str, object] = {}
website_name = str(website_payload.get("name") or "").strip()
if website_name:
Expand Down
14 changes: 11 additions & 3 deletions docker/scripts/run_odoo_data_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -904,11 +904,10 @@ def call_odoo_sql(self, sql_call: SqlCall, call_type: SqlCallType) -> list[tuple
else:
return []

def sanitize_database(self) -> None:
def sanitize_database(self, *, block_smtp_fallback: bool = True) -> None:
disable_cron = self.local.disable_cron

sql_calls: list[SqlCall] = [
SqlCall("ir.mail_server", KeyValuePair("active", False)),
SqlCall("ir.config_parameter", KeyValuePair("value", "False"), KeyValuePair("key", "mail.catchall.domain")),
SqlCall("ir.config_parameter", KeyValuePair("value", "False"), KeyValuePair("key", "mail.catchall.alias")),
SqlCall("ir.config_parameter", KeyValuePair("value", "False"), KeyValuePair("key", "mail.bounce.alias")),
Expand All @@ -917,6 +916,15 @@ def sanitize_database(self) -> None:
sql_calls.append(SqlCall("ir.cron", KeyValuePair("active", False)))

_logger.info("Sanitizing database...")
# An active dummy server also blocks Odoo's config/CLI SMTP fallback.
# Match Odoo's neutralization behavior and remove copied credentials.
with self.connect_to_db().cursor() as cursor:
cursor.execute("UPDATE ir_mail_server SET active = false, smtp_user = NULL, smtp_pass = NULL")
if block_smtp_fallback:
cursor.execute(
"INSERT INTO ir_mail_server (name, smtp_port, smtp_host, smtp_encryption, active, smtp_authentication) "
"VALUES ('neutralization - disable emails', 1025, 'invalid', 'none', true, 'login')"
)
# noinspection PyUnresolvedReferences # call_odoo_sql exists on this class; PyCharm false positive.
call_odoo_sql = self.call_odoo_sql
for sql_call in sql_calls:
Expand Down Expand Up @@ -1503,7 +1511,7 @@ def run_bootstrap(self, *, do_sanitize: bool) -> None:
self.connect_to_db()

if do_sanitize:
self.sanitize_database()
self.sanitize_database(block_smtp_fallback=False)
self.local.db_conn.commit()
else:
_logger.info("Skipping sanitization per --no-sanitize flag.")
Expand Down
19 changes: 18 additions & 1 deletion docker/scripts/run_odoo_startup.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@
("ODOO_LIMIT_MEMORY_SOFT", "limit_memory_soft"),
("ODOO_LIMIT_MEMORY_HARD", "limit_memory_hard"),
)
MAIL_OPTION_MAP: tuple[tuple[str, str], ...] = (
("ODOO_SMTP_SERVER", "smtp_server"),
("ODOO_SMTP_PORT", "smtp_port"),
("ODOO_SMTP_USER", "smtp_user"),
("ODOO_SMTP_PASSWORD", "smtp_password"),
("ODOO_SMTP_SSL", "smtp_ssl"),
("ODOO_EMAIL_FROM", "email_from"),
("ODOO_FROM_FILTER", "from_filter"),
)

UNSAFE_MASTER_PASSWORDS = {"admin"}
LOCAL_INSTANCE_NAMES = {"", "local", "dev", "development"}
Expand Down Expand Up @@ -181,6 +190,12 @@ def _write_runtime_config(settings: StartupSettings) -> None:
if option_value:
options[option_name] = option_value

# Explicit empty values clear inherited mail options. Preserve password bytes.
for env_name, option_name in MAIL_OPTION_MAP:
if env_name in os.environ:
value = os.environ[env_name]
options[option_name] = value if option_name == "smtp_password" else value.strip()

dev_mode_value = os.environ.get("ODOO_DEV_MODE", "").strip()
if dev_mode_value:
options["dev_mode"] = dev_mode_value
Expand All @@ -191,7 +206,9 @@ def _write_runtime_config(settings: StartupSettings) -> None:
config_directory = os.path.dirname(config_path)
if config_directory:
os.makedirs(config_directory, exist_ok=True)
with open(config_path, "w", encoding="utf-8") as config_file:
descriptor = os.open(config_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(descriptor, "w", encoding="utf-8") as config_file:
os.fchmod(config_file.fileno(), 0o600)
config_parser.write(config_file)


Expand Down
48 changes: 48 additions & 0 deletions tests/test_odoo_data_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@

import importlib.util
import os
import sqlite3
import subprocess
import sys
import types
import unittest
from contextlib import closing
from pathlib import Path
from unittest.mock import MagicMock, patch

Expand Down Expand Up @@ -44,6 +46,52 @@ def _load_data_workflows_module() -> types.ModuleType:


class OdooDataWorkflowShellEnvironmentTests(unittest.TestCase):
def test_bootstrap_allows_configured_mail_but_sanitized_restores_block_it(self) -> None:
with closing(sqlite3.connect(":memory:")) as database:
database.execute(
"CREATE TABLE ir_mail_server (name TEXT, smtp_port INTEGER, smtp_host TEXT, smtp_encryption TEXT, "
"active BOOLEAN, smtp_authentication TEXT, smtp_user TEXT, smtp_pass TEXT)"
)
runner = odoo_data_workflows.OdooDataWorkflowRunner(self._local_settings(), upstream=None, env_file=None)
runner.local.db_conn = types.SimpleNamespace(cursor=lambda: closing(database.cursor()), commit=database.commit)
with patch.multiple(
runner,
_resolve_filestore_owner=MagicMock(return_value=None),
database_exists=MagicMock(return_value=False),
_clean_filestore=MagicMock(),
normalize_filestore_permissions=MagicMock(),
create_database=MagicMock(),
_reset_db_connection=MagicMock(),
needs_base_install=MagicMock(return_value=False),
install_addons=MagicMock(),
update_addons=MagicMock(),
call_odoo_sql=MagicMock(return_value=[]),
assert_install_queue_is_resolvable=MagicMock(),
apply_environment_overrides=MagicMock(),
ensure_admin_user=MagicMock(),
assert_core_schema_healthy=MagicMock(),
ensure_gpt_users=MagicMock(),
):
runner.run_bootstrap(do_sanitize=True)
self.assertEqual(database.execute("SELECT count(*) FROM ir_mail_server WHERE active = true").fetchone()[0], 0)
database.execute(
"INSERT INTO ir_mail_server VALUES ('Production', 587, 'smtp.example.test', 'starttls', true, 'login', "
"'mailbox@example.test', 'copied-secret')"
)
with patch.object(runner, "call_odoo_sql", return_value=[]):
runner.sanitize_database()
runner.sanitize_database()
self.assertEqual(
database.execute("SELECT smtp_host, smtp_port FROM ir_mail_server WHERE active = true").fetchall(),
[("invalid", 1025)],
)
self.assertEqual(
database.execute(
"SELECT count(*) FROM ir_mail_server WHERE smtp_user IS NOT NULL OR smtp_pass IS NOT NULL"
).fetchone()[0],
0,
)

@staticmethod
def _local_settings() -> object:
return odoo_data_workflows.LocalServerSettings(
Expand Down
66 changes: 55 additions & 11 deletions tests/test_odoo_startup.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,21 @@
import argparse
import configparser
import importlib.util
import io
import os
import sys
import types
import unittest
from contextlib import redirect_stdout
from dataclasses import replace
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import TYPE_CHECKING
from unittest.mock import patch

if TYPE_CHECKING:
from docker.scripts.run_odoo_startup import StartupSettings


def _load_startup_module() -> types.ModuleType:
module_path = Path(__file__).resolve().parents[1] / "docker" / "scripts" / "run_odoo_startup.py"
Expand Down Expand Up @@ -43,7 +51,7 @@ def _settings(
platform_instance: str = "local",
master_password: str = "master-password",
admin_password: str = "",
) -> object:
) -> StartupSettings:
return odoo_startup.StartupSettings(
config_path="/tmp/generated.conf",
base_config_path="/tmp/base.conf",
Expand Down Expand Up @@ -154,11 +162,11 @@ def test_public_runtime_config_pins_http_database_filter_to_configured_database(
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)
with TemporaryDirectory() as directory:
settings = replace(settings, config_path=str(Path(directory) / "odoo.conf"), base_config_path="")
with patch.dict(os.environ, {}, clear=True):
odoo_startup._write_runtime_config(settings)
parser.read(settings.config_path)

self.assertEqual(parser["options"]["db_name"], "opw")
self.assertEqual(parser["options"]["dbfilter"], "^opw$")
Expand All @@ -167,15 +175,51 @@ 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)
with TemporaryDirectory() as directory:
settings = replace(settings, config_path=str(Path(directory) / "odoo.conf"), base_config_path="")
with patch.dict(os.environ, {}, clear=True):
odoo_startup._write_runtime_config(settings)
parser.read(settings.config_path)

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

def test_managed_mail_options_replace_base_config_without_logging_password(self) -> None:
environment = {
"ODOO_SMTP_SERVER": "smtp.example.test",
"ODOO_SMTP_PORT": "587",
"ODOO_SMTP_USER": "mailbox@example.test",
"ODOO_SMTP_PASSWORD": "secret%with#punctuation",
"ODOO_SMTP_SSL": "True",
"ODOO_EMAIL_FROM": "support@example.test",
"ODOO_FROM_FILTER": "support@example.test",
}
output = io.StringIO()
with TemporaryDirectory() as directory:
base = Path(directory) / "base.conf"
target = Path(directory) / "runtime.conf"
base.write_text("[options]\nsmtp_server = old.example.test\nsmtp_password = old-secret\n", encoding="utf-8")
target.touch(mode=0o644)
target.chmod(0o644)
settings = replace(self._settings(), base_config_path=str(base), config_path=str(target))
with patch.dict(os.environ, environment, clear=True), redirect_stdout(output):
odoo_startup._write_runtime_config(settings)
parser = configparser.ConfigParser(interpolation=None)
parser.read(target)
self.assertEqual(parser["options"]["smtp_server"], "smtp.example.test")
self.assertEqual(parser["options"].getint("smtp_port"), 587)
self.assertEqual(parser["options"]["smtp_user"], "mailbox@example.test")
self.assertEqual(parser["options"]["smtp_password"], environment["ODOO_SMTP_PASSWORD"])
self.assertTrue(parser["options"].getboolean("smtp_ssl"))
self.assertEqual(parser["options"]["email_from"], "support@example.test")
self.assertEqual(parser["options"]["from_filter"], "support@example.test")
self.assertEqual(target.stat().st_mode & 0o777, 0o600)
with patch.dict(os.environ, {"ODOO_SMTP_PASSWORD": ""}, clear=True):
odoo_startup._write_runtime_config(settings)
parser.read(target)
self.assertEqual(parser["options"]["smtp_password"], "")
self.assertNotIn(environment["ODOO_SMTP_PASSWORD"], output.getvalue())

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

Expand Down
23 changes: 23 additions & 0 deletions tests/test_odoo_website_bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,29 @@ def ref(self, xmlid: str, *unused_args: object, **unused_kwargs: object) -> Fake


class WebsiteBootstrapHelperTests(unittest.TestCase):
def test_company_sender_is_set_on_selected_website_and_verified(self) -> None:
env = FakeEnv()
company = FakeRecord(fields=("email",), values={"email": False})
env.website._fields.add("company_id")
env.website.company_id = company
payload = {"website_bootstrap": {"name": "Example", "company_email": "support@example.test"}}
with redirect_stdout(io.StringIO()):
website_bootstrap.apply_website_bootstrap(env, payload)
self.assertEqual(company.email, "support@example.test")
company.persist_writes = False
company.email = False
with self.assertRaisesRegex(RuntimeError, "failed to persist company email"):
website_bootstrap.apply_website_bootstrap(env, payload)

def test_company_sender_request_fails_when_selected_website_has_no_company(self) -> None:
env = FakeEnv()
env.website._fields.add("company_id")
env.website.company_id = FakeRecord(truthy=False)
with self.assertRaisesRegex(RuntimeError, "selected website has no company"):
website_bootstrap.apply_website_bootstrap(
env, {"website_bootstrap": {"name": "Example", "company_email": "support@example.test"}}
)

def test_required_instance_overrides_fail_without_payload(self) -> None:
with patch.dict(
os.environ,
Expand Down
Loading