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
60 changes: 41 additions & 19 deletions control_plane/dokploy/post_deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,33 @@ def _bounded_dokploy_evidence_id(value: str) -> str:
return normalized_value


class OdooPostDeployReadbackFailure(click.ClickException):
"""Keep safe schedule evidence when maintenance readback refuses success."""

def __init__(self, message: str, *, evidence: Mapping[str, str]) -> None:
super().__init__(message)
self.evidence = {
key: value
for key, value in evidence.items()
if _safe_odoo_post_deploy_marker(key, value)
}
for key in ("schedule_id", "schedule_deployment_key", "schedule_deployment_id"):
value = _bounded_dokploy_evidence_id(evidence.get(key, ""))
if value:
self.evidence[key] = value


def _safe_odoo_post_deploy_marker(key: str, value: str) -> bool:
if key in ODOO_POST_DEPLOY_BOOLEAN_READBACK_MARKERS:
return value in {"true", "false"}
return (
key in ODOO_POST_DEPLOY_NUMERIC_READBACK_MARKERS
and value.isascii()
and value.isdigit()
and len(value) <= 20
)


def _retained_volume_inspection_provider_id(
value: str,
*,
Expand Down Expand Up @@ -582,10 +609,13 @@ def run_compose_post_deploy_update(
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."
)
if require_company_email:
require_odoo_module_update_readback_evidence(evidence)
if evidence.get("website_bootstrap_company_email_matches") != "true":
raise OdooPostDeployReadbackFailure(
"Odoo post-deploy did not prove the requested website company sender was saved.",
evidence=evidence,
)
return evidence


Expand Down Expand Up @@ -2018,36 +2048,28 @@ def extract_odoo_post_deploy_readback_markers(deployment: api.JsonObject | None)
continue
normalized_key = key.strip()
normalized_value = raw_value.strip().lower()
if normalized_key not in ODOO_POST_DEPLOY_READBACK_MARKERS:
continue
if normalized_key in ODOO_POST_DEPLOY_BOOLEAN_READBACK_MARKERS and normalized_value not in {
"true",
"false",
}:
continue
if (
normalized_key in ODOO_POST_DEPLOY_NUMERIC_READBACK_MARKERS
and not normalized_value.isdigit()
):
if not _safe_odoo_post_deploy_marker(normalized_key, normalized_value):
continue
markers[normalized_key] = normalized_value
return markers


def require_odoo_module_update_readback_evidence(evidence: Mapping[str, str]) -> None:
if evidence.get("log_available") != "true":
raise click.ClickException(
"Odoo module install/update evidence is unavailable from the provider schedule logs."
raise OdooPostDeployReadbackFailure(
"Odoo module install/update evidence is unavailable from the provider schedule logs.",
evidence=evidence,
)
missing_markers = tuple(
marker
for marker in ODOO_MODULE_UPDATE_REQUIRED_READBACK_MARKERS
if evidence.get(marker) != "true"
)
if missing_markers:
raise click.ClickException(
raise OdooPostDeployReadbackFailure(
"Odoo module install/update evidence did not prove the current runtime update: "
+ ", ".join(missing_markers)
+ ", ".join(missing_markers),
evidence=evidence,
)


Expand Down
2 changes: 2 additions & 0 deletions control_plane/workflows/odoo_post_deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,8 @@ def execute_odoo_post_deploy(
post_deploy_readback_markers
)
except click.ClickException as error:
if isinstance(error, dokploy_post_deploy.OdooPostDeployReadbackFailure):
post_deploy_readback_markers = error.evidence
if odoo_override_record is not None and override_should_apply:
_write_odoo_instance_override_apply_result(
record_store=typed_record_store,
Expand Down
2 changes: 2 additions & 0 deletions control_plane/workflows/odoo_preview_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -1684,6 +1684,8 @@ def checkpoint_provider_effect(phase: str) -> None:
)
except click.ClickException as exc:
rollback_errors: tuple[str, ...] = ()
if isinstance(exc, dokploy_post_deploy.OdooPostDeployReadbackFailure):
module_install_update_evidence = exc.evidence
if not deploy_triggered:
if created_compose_id and provider_effect_checkpoint is not None:
provider_effect_checkpoint("rollback_started")
Expand Down
8 changes: 8 additions & 0 deletions docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -2304,6 +2304,14 @@ context only, and `context_instance` has both context and instance.
and re-provision derived Odoo service-user API keys. Full database
sanitization such as disabling mail servers and cron remains tied to explicit
restore/bootstrap workflows, not ordinary prod image deploys.
- Post-deploy diagnosis checks maintenance module completion before a requested
website sender. A provider schedule marked `done` does not establish that the
module update succeeded. Module or sender readback failures retain bounded
schedule and deployment IDs plus allowlisted boolean/numeric markers in
`post_deploy_readback_*` evidence (or preview module-update evidence). Use those
exact IDs for a scoped provider diagnostic read. Raw logs, SQL rows, provider
credentials, and sender values are not copied into the failure record; runtime
startup markers do not substitute for the maintenance schedule's proof.
- Reusable Odoo GitHub workflows resolve the Launchplane product before calling
driver routes. Callers may pass `product` explicitly; otherwise the default is
`odoo-tenant-${context}` after normalizing underscores to dashes, so context
Expand Down
18 changes: 16 additions & 2 deletions tests/test_dokploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -3125,7 +3125,13 @@ def test_post_deploy_refuses_unproved_company_sender(self) -> None:
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}"]
logs = [
"odoo_module_update_completed=true",
"odoo_module_update_image_match=true",
"odoo_module_update_modules_configured=true",
]
if marker is not None:
logs.append(f"website_bootstrap_company_email_matches={marker}")
with (
self.subTest(marker=marker),
patch.multiple(
Expand Down Expand Up @@ -3167,8 +3173,16 @@ def deploy() -> dict[str, str]:
if marker == "true":
self.assertEqual(deploy()["website_bootstrap_company_email_matches"], "true")
else:
with self.assertRaisesRegex(click.ClickException, "company sender"):
with self.assertRaisesRegex(
dokploy_post_deploy.OdooPostDeployReadbackFailure, "company sender"
) as raised:
deploy()
self.assertEqual(raised.exception.evidence["schedule_id"], "schedule-example")
self.assertEqual(raised.exception.evidence["schedule_deployment_id"], "after")
self.assertEqual(
raised.exception.evidence.get("website_bootstrap_company_email_matches"),
marker,
)

def test_run_compose_post_deploy_update_reads_inline_schedule_log_markers(
self,
Expand Down
169 changes: 168 additions & 1 deletion tests/test_odoo_post_deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,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 control_plane.contracts.odoo_instance_override_record import (
OdooAddonSettingOverride,
Expand All @@ -16,7 +16,14 @@
OdooWebsiteBootstrapRoute,
)
from control_plane.dokploy import DokploySourceOfTruth, DokployTargetDefinition
from control_plane.dokploy import api as dokploy_api, post_deploy as dokploy_post_deploy
from control_plane.contracts.deployment_record import DeploymentRecord
from control_plane.contracts.promotion_record import DeploymentEvidence
from control_plane.odoo_instance_overrides import build_post_deploy_environment
from control_plane.storage.filesystem import FilesystemRecordStore
from control_plane.workflows.odoo_generic_web_post_deploy import (
post_deploy_evidence_from_odoo_result,
)
from control_plane.workflows.odoo_post_deploy import (
OdooPostDeployRequest,
execute_odoo_post_deploy,
Expand Down Expand Up @@ -215,6 +222,166 @@ def test_execute_fails_when_module_update_evidence_is_incomplete(self) -> None:
self.assertEqual(result.post_deploy_status, "fail")
self.assertIn("did not prove", result.error_message)

def test_provider_done_module_failure_precedes_sender_and_persists_safe_evidence(self) -> None:
override = OdooInstanceOverrideRecord(
context="opw",
instance="testing",
apply_on=("deploy",),
website_bootstrap=OdooWebsiteBootstrapPayload(
tenant="example",
name="Example",
company_email="support@example.test",
),
updated_at="2026-09-26T12:00:00Z",
source_label="test",
)
environment = build_post_deploy_environment(override, workflow_intent="deploy")
target_payload = {
"env": dokploy_api.serialize_dokploy_env_text(
{
"ODOO_DB_NAME": "example",
**environment.inline_environment,
}
),
"appName": "example-app",
"serverId": "server-example",
# Runtime startup proof cannot replace the maintenance schedule's proof.
"logs": "website_bootstrap_company_email_matches=true",
}
for completed in (None, "false"):
for sender in (None, "false", "true"):
logs = [
"odoo_module_update_image_match=true",
"odoo_module_update_modules_configured=true",
"odoo.tools.convert.ParseError: required res.partner.group_rfq is missing",
"DETAIL: Failing row contains (private-row-value)",
"ODOO_DB_PASSWORD=private-password",
"Module update exited with status 255",
]
if completed is not None:
logs.append(f"odoo_module_update_completed={completed}")
if sender is not None:
logs.append(f"website_bootstrap_company_email_matches={sender}")
with (
self.subTest(completed=completed, sender=sender),
TemporaryDirectory() as directory,
):
root = Path(directory)
store = FilesystemRecordStore(state_dir=root / "state")
store.write_odoo_instance_override_record(override)
with (
patch(
"control_plane.workflows.odoo_post_deploy.dokploy_source.read_control_plane_dokploy_source_of_truth",
return_value=self._source_of_truth(),
),
patch(
"control_plane.workflows.odoo_post_deploy.dokploy_source.read_dokploy_config",
return_value=("https://dokploy.example.test", "private-provider-token"),
),
patch.multiple(
dokploy_api,
fetch_dokploy_target_payload=Mock(return_value=target_payload),
find_matching_dokploy_schedule=Mock(return_value=None),
upsert_dokploy_schedule=Mock(
return_value={"scheduleId": "maintenance-schedule"}
),
latest_deployment_for_schedule=Mock(
side_effect=[
{"deploymentId": "previous-maintenance"},
{
"deploymentId": "failed-maintenance",
"status": "done",
"logs": logs,
},
]
),
wait_for_dokploy_schedule_deployment=Mock(
return_value="deployment=failed-maintenance status=done"
),
dokploy_request=Mock(return_value={"ok": True}),
),
):
result = execute_odoo_post_deploy(
control_plane_root=root,
record_store=store,
request=OdooPostDeployRequest(context="opw", instance="testing"),
)
self.assertEqual(result.post_deploy_status, "fail")
self.assertEqual(result.override_status, "fail")
self.assertIn("odoo_module_update_completed", result.error_message)
self.assertNotIn("company sender", result.error_message)
store.write_deployment_record(
DeploymentRecord(
record_id="failed-deploy",
context="opw",
instance="testing",
source_git_ref="a" * 40,
deploy=DeploymentEvidence(
deploy_mode="compose",
target_name="example-testing",
target_type="compose",
status="pass",
),
post_deploy_update=post_deploy_evidence_from_odoo_result(result),
)
)
persisted = store.read_deployment_record("failed-deploy").post_deploy_update
self.assertEqual(persisted.status, "fail")
self.assertEqual(
persisted.evidence["post_deploy_readback_schedule_id"],
"maintenance-schedule",
)
self.assertEqual(
persisted.evidence["post_deploy_readback_schedule_deployment_id"],
"failed-maintenance",
)
self.assertEqual(
persisted.evidence["post_deploy_readback_odoo_module_update_image_match"],
"true",
)
self.assertEqual(
persisted.evidence.get("post_deploy_readback_odoo_module_update_completed"),
completed,
)
self.assertEqual(
persisted.evidence.get(
"post_deploy_readback_website_bootstrap_company_email_matches"
),
sender,
)
self.assertNotIn("private-", persisted.model_dump_json())
self.assertNotIn("support@example.test", persisted.model_dump_json())
self.assertEqual(
store.read_odoo_instance_override_record(
context_name="opw", instance_name="testing"
).last_apply.status,
"fail",
)

def test_readback_failure_keeps_only_bounded_allowlisted_evidence(self) -> None:
failure = dokploy_post_deploy.OdooPostDeployReadbackFailure(
"Module proof missing",
evidence={
"schedule_id": "schedule-example",
"schedule_deployment_id": "id\nprivate-token",
"schedule_deployment_key": "x" * 201,
"log_available": "true",
"odoo_module_update_completed": "false",
"website_bootstrap_website_id": "1" * 21,
"website_bootstrap_company_email_matches": "private-email@example.test",
"raw_log": "private-token",
"ODOO_DB_PASSWORD": "private-password",
},
)
self.assertEqual(
failure.evidence,
{
"schedule_id": "schedule-example",
"log_available": "true",
"odoo_module_update_completed": "false",
},
)

def test_execute_can_request_destructive_restore_for_prelaunch_rebuild(self) -> None:
captured_runs: list[dict[str, object]] = []
with TemporaryDirectory() as temporary_directory_name:
Expand Down
Loading
Loading