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
7 changes: 7 additions & 0 deletions control_plane/contracts/odoo_instance_override_record.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ def validate_odoo_website_bootstrap_contract(
# Payload model construction strips text; this helper only enforces
# write-path shape so persisted record reads remain repairable.
_validate_local_route_path(payload.homepage_url, label="homepage_url")
if (
payload.company_email
and re.fullmatch(r"[^\s@<>,;\"()]+@[^\s@<>,;\"()]+", payload.company_email) is None
):
raise ValueError("Odoo website bootstrap company_email must be one email address")
if payload.primary_page_xmlid and _ODOO_XMLID_RE.fullmatch(payload.primary_page_xmlid) is None:
raise ValueError("Odoo website bootstrap primary_page_xmlid must be a dotted XML ID")
route_urls = [route.url for route in payload.routes]
Expand Down Expand Up @@ -145,6 +150,7 @@ class OdooWebsiteBootstrapPayload(BaseModel):
logo_path: str = ""
logo_alt: str = ""
canonical_url: str = ""
company_email: str = ""
pages_source: dict[str, object] = Field(default_factory=dict)
routes_source: dict[str, object] = Field(default_factory=dict)
routes: tuple[OdooWebsiteBootstrapRoute, ...] = ()
Expand All @@ -158,6 +164,7 @@ class OdooWebsiteBootstrapPayload(BaseModel):
"logo_path",
"logo_alt",
"canonical_url",
"company_email",
mode="after",
)
@classmethod
Expand Down
23 changes: 22 additions & 1 deletion control_plane/dokploy/post_deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import click

from control_plane.contracts.odoo_post_deploy_payload import OdooPostDeployPayload
from control_plane.contracts.odoo_prod_retained_volume_backup_import import (
ODOO_PROD_RETAINED_VOLUME_BACKUP_IMPORT_FAILURE_STAGE_BY_CODE,
OdooProdRetainedVolumeBackupImportInspectionEvidence,
Expand Down Expand Up @@ -89,6 +90,7 @@
"website_bootstrap_primary_page_xmlid_found",
"website_bootstrap_homepage_matches_page",
"website_bootstrap_logo_present",
"website_bootstrap_company_email_matches",
"website_bootstrap_applied",
}
)
Expand Down Expand Up @@ -339,6 +341,20 @@ def run_compose_post_deploy_update(
else:
desired_env_map.pop("ODOO_ADDONS_PATH", None)
resolved_workflow_environment_overrides = dict(workflow_environment_overrides or {})
require_company_email = False
encoded_payload = resolved_workflow_environment_overrides.get(
ODOO_INSTANCE_OVERRIDES_PAYLOAD_ENV_KEY, ""
)
if encoded_payload:
try:
override_payload = OdooPostDeployPayload.model_validate_json(
base64.b64decode(encoded_payload, validate=True)
)
except ValueError as error:
raise click.ClickException("Odoo post-deploy override payload is invalid.") from error
require_company_email = bool(
override_payload.website_bootstrap and override_payload.website_bootstrap.company_email
)
resolved_required_workflow_environment_keys = tuple(required_workflow_environment_keys)
runtime_override_target_environment = {
key: value
Expand Down Expand Up @@ -558,14 +574,19 @@ def run_compose_post_deploy_update(
)
if api.deployment_key(completed_schedule_deployment) != completed_schedule_deployment_key:
completed_schedule_deployment = None
return _read_odoo_post_deploy_log_markers(
evidence = _read_odoo_post_deploy_log_markers(
host=host,
token=token,
schedule_id=schedule_id,
schedule_deployment_key=completed_schedule_deployment_key,
deployment_id=completed_schedule_deployment_key,
deployment=completed_schedule_deployment,
)
if require_company_email and evidence.get("website_bootstrap_company_email_matches") != "true":
raise click.ClickException(
"Odoo post-deploy did not prove the requested website company sender was saved."
)
return evidence


def run_compose_odoo_stable_bootstrap(
Expand Down
11 changes: 11 additions & 0 deletions docs/records.md
Original file line number Diff line number Diff line change
Expand Up @@ -1976,6 +1976,17 @@ run` is the foreground loop intended for an external process supervisor, and
including site identity, canonical URL, logo path, source metadata, and route
definitions. Product repos remain the source of that intent; Launchplane
persists the typed payload and renders it during Odoo post-deploy.
- Optional `website_bootstrap.company_email` is one sender address for the
selected website's company. The runtime bootstrap saves and reads it back;
omission preserves the existing company email. Native website contact forms
use this company field when constructing the sender. SMTP transport and its
credential remain separate managed runtime configuration. Deploy an Odoo
artifact whose devkit supports the field. Post-deploy requires the
`website_bootstrap_company_email_matches` readback marker whenever a sender
is requested; an older runtime that ignores the field fails this check.
Deploy the supporting Launchplane version before storing the new field;
rolling back to an older service requires removing the field from the record
through the supported record workflow first.
- New website-bootstrap writes through the service route enforce the devkit-safe
contract: homepage and route URLs are local Odoo route paths,
`primary_page_xmlid` is a dotted XML ID, and at most one route can be marked
Expand Down
10 changes: 10 additions & 0 deletions frontend/generated/openapi-canonical.json
Original file line number Diff line number Diff line change
Expand Up @@ -19205,6 +19205,11 @@
"title": "Canonical Url",
"type": "string"
},
"company_email": {
"default": "",
"title": "Company Email",
"type": "string"
},
"default_lang": {
"default": "",
"title": "Default Lang",
Expand Down Expand Up @@ -59987,6 +59992,11 @@
"title": "Canonical Url",
"type": "string"
},
"company_email": {
"default": "",
"title": "Company Email",
"type": "string"
},
"default_lang": {
"default": "",
"title": "Default Lang",
Expand Down
6 changes: 6 additions & 0 deletions frontend/generated/openapi-ui.json
Original file line number Diff line number Diff line change
Expand Up @@ -14421,6 +14421,11 @@
"title": "Canonical Url",
"type": "string"
},
"company_email": {
"default": "",
"title": "Company Email",
"type": "string"
},
"default_lang": {
"default": "",
"title": "Default Lang",
Expand Down Expand Up @@ -14476,6 +14481,7 @@
},
"required": [
"canonical_url",
"company_email",
"default_lang",
"homepage_url",
"logo_alt",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/generated/openapi.ts/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1443,6 +1443,7 @@ export type OdooOverrideValue = {

export type OdooWebsiteBootstrapPayload = {
canonical_url: string;
company_email: string;
default_lang: string;
homepage_url: string;
logo_alt: string;
Expand Down
64 changes: 63 additions & 1 deletion tests/test_dokploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import cast
from unittest.mock import patch
from unittest.mock import Mock, patch
from urllib.error import HTTPError

import click
Expand Down Expand Up @@ -3108,6 +3108,68 @@ def capture_schedule_payload(**_kwargs: object) -> dict[str, str]:
},
)

def test_post_deploy_refuses_unproved_company_sender(self) -> None:
payload = base64.b64encode(
json.dumps(
{
"context": "example",
"instance": "testing",
"website_bootstrap": {
"name": "Example",
"company_email": "support@example.test",
},
}
).encode()
).decode()
target = control_plane_dokploy.DokployTargetDefinition(
context="example", instance="testing", target_id="compose-example"
)
for marker in (None, "false", "true"):
logs = [] if marker is None else [f"website_bootstrap_company_email_matches={marker}"]
with (
self.subTest(marker=marker),
patch.multiple(
dokploy_api,
fetch_dokploy_target_payload=Mock(
return_value={
"env": f"ODOO_DB_NAME=example\n{ODOO_INSTANCE_OVERRIDES_PAYLOAD_ENV_KEY}={payload}\n",
"appName": "example-app",
"serverId": "server-example",
}
),
find_matching_dokploy_schedule=Mock(return_value=None),
upsert_dokploy_schedule=Mock(return_value={"scheduleId": "schedule-example"}),
latest_deployment_for_schedule=Mock(
side_effect=[
{"id": "before"},
{"id": "after", "logs": logs},
]
),
wait_for_dokploy_schedule_deployment=Mock(
return_value="deployment=after status=done"
),
fetch_dokploy_deployment_logs=Mock(return_value=logs),
dokploy_request=Mock(return_value={"ok": True}),
),
):

def deploy() -> dict[str, str]:
return control_plane_dokploy.run_compose_post_deploy_update(
host="https://dokploy.example.test",
token="test-token",
target_definition=target,
env_file=None,
workflow_environment_overrides={
ODOO_INSTANCE_OVERRIDES_PAYLOAD_ENV_KEY: payload
},
)

if marker == "true":
self.assertEqual(deploy()["website_bootstrap_company_email_matches"], "true")
else:
with self.assertRaisesRegex(click.ClickException, "company sender"):
deploy()

def test_run_compose_post_deploy_update_reads_inline_schedule_log_markers(
self,
) -> None:
Expand Down
19 changes: 19 additions & 0 deletions tests/test_odoo_instance_overrides.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,17 @@ def _assert_direct_db_mutation_rejected(test_case: unittest.TestCase, result: Re


class OdooInstanceOverrideTests(unittest.TestCase):
def test_website_bootstrap_rejects_invalid_company_sender_on_write(self) -> None:
for email in (
"not-an-address",
"Name <sender@example.test>",
"sender@example.test\nBcc: other@example.test",
):
with self.subTest(email=email), self.assertRaisesRegex(ValueError, "company_email"):
validate_odoo_website_bootstrap_contract(
OdooWebsiteBootstrapPayload(name="Example", company_email=email)
)

def test_website_bootstrap_payload_accepts_devkit_route_shape(self) -> None:
payload = validate_odoo_website_bootstrap_contract(
OdooWebsiteBootstrapPayload(
Expand Down Expand Up @@ -358,6 +369,7 @@ def test_build_post_deploy_environment_sets_website_bootstrap_required_flag(self
tenant="cm",
name="Cell Mechanic",
canonical_url="https://cm-testing.example.com",
company_email="support@example.test",
),
updated_at="2026-06-13T18:00:00Z",
)
Expand All @@ -370,6 +382,9 @@ def test_build_post_deploy_environment_sets_website_bootstrap_required_flag(self
).decode("utf-8")
)
self.assertIn("website_bootstrap", decoded_payload)
self.assertEqual(
decoded_payload["website_bootstrap"]["company_email"], "support@example.test"
)
self.assertEqual(
environment.inline_environment[LAUNCHPLANE_WEBSITE_BOOTSTRAP_REQUIRED_ENV_KEY],
"true",
Expand Down Expand Up @@ -407,6 +422,7 @@ def test_preview_website_bootstrap_environment_inherits_only_deploy_bootstrap(se
tenant="cm",
name="Cell Mechanic",
canonical_url="https://cm-testing.example.com",
company_email="support@example.test",
),
updated_at="2026-07-31T18:00:00Z",
)
Expand All @@ -426,6 +442,9 @@ def test_preview_website_bootstrap_environment_inherits_only_deploy_bootstrap(se
self.assertEqual(decoded_payload["config_parameters"], [])
self.assertEqual(decoded_payload["addon_settings"], [])
self.assertEqual(decoded_payload["website_bootstrap"]["name"], "Cell Mechanic")
self.assertEqual(
decoded_payload["website_bootstrap"]["company_email"], "support@example.test"
)
self.assertEqual(
decoded_payload["website_bootstrap"]["canonical_url"],
"https://pr-70.cm-preview.example.test",
Expand Down
Loading