From a33651237b07889e22a28e1b8a11a9e315748a5e Mon Sep 17 00:00:00 2001 From: shiny-code-bot Date: Sat, 26 Sep 2026 18:13:15 -0400 Subject: [PATCH] Preserve failed Odoo maintenance readback before sender checks --- control_plane/dokploy/post_deploy.py | 60 +++++-- control_plane/workflows/odoo_post_deploy.py | 2 + .../workflows/odoo_preview_runtime.py | 2 + docs/operations.md | 8 + tests/test_dokploy.py | 18 +- tests/test_odoo_post_deploy.py | 169 +++++++++++++++++- tests/test_odoo_preview_runtime.py | 61 ++++++- 7 files changed, 297 insertions(+), 23 deletions(-) diff --git a/control_plane/dokploy/post_deploy.py b/control_plane/dokploy/post_deploy.py index d48c3f279..d75f787c2 100644 --- a/control_plane/dokploy/post_deploy.py +++ b/control_plane/dokploy/post_deploy.py @@ -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, *, @@ -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 @@ -2018,17 +2048,7 @@ 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 @@ -2036,8 +2056,9 @@ def extract_odoo_post_deploy_readback_markers(deployment: api.JsonObject | None) 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 @@ -2045,9 +2066,10 @@ def require_odoo_module_update_readback_evidence(evidence: Mapping[str, str]) -> 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, ) diff --git a/control_plane/workflows/odoo_post_deploy.py b/control_plane/workflows/odoo_post_deploy.py index fccdef5ff..6cbfb758b 100644 --- a/control_plane/workflows/odoo_post_deploy.py +++ b/control_plane/workflows/odoo_post_deploy.py @@ -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, diff --git a/control_plane/workflows/odoo_preview_runtime.py b/control_plane/workflows/odoo_preview_runtime.py index 8f873561f..72fc6aebd 100644 --- a/control_plane/workflows/odoo_preview_runtime.py +++ b/control_plane/workflows/odoo_preview_runtime.py @@ -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") diff --git a/docs/operations.md b/docs/operations.md index 6f9a7dd37..56bf6a032 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -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 diff --git a/tests/test_dokploy.py b/tests/test_dokploy.py index 945564a30..6b9ae7d50 100644 --- a/tests/test_dokploy.py +++ b/tests/test_dokploy.py @@ -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( @@ -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, diff --git a/tests/test_odoo_post_deploy.py b/tests/test_odoo_post_deploy.py index a8ee6cadc..55bf90d9b 100644 --- a/tests/test_odoo_post_deploy.py +++ b/tests/test_odoo_post_deploy.py @@ -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, @@ -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, @@ -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: diff --git a/tests/test_odoo_preview_runtime.py b/tests/test_odoo_preview_runtime.py index db4a2af23..470843ebc 100644 --- a/tests/test_odoo_preview_runtime.py +++ b/tests/test_odoo_preview_runtime.py @@ -4,7 +4,7 @@ from contextlib import ExitStack, contextmanager from pathlib import Path from typing import Iterator, Literal, cast -from unittest.mock import ANY, patch +from unittest.mock import ANY, Mock, patch from urllib.error import HTTPError, URLError import click @@ -2307,6 +2307,65 @@ def test_apply_existing_refresh_smoke_failure_preserves_existing_domain(self) -> delete_domain.assert_not_called() delete_compose.assert_not_called() + def test_apply_preserves_failed_maintenance_evidence_without_running_smoke(self) -> None: + dry_run = build_odoo_preview_dokploy_dry_run( + request=OdooPreviewDokployDryRunRequest( + runtime_plan=_runtime_plan(target=_target()), + endpoint_spec=_endpoint_spec(), + ) + ) + failure = dokploy_post_deploy.OdooPostDeployReadbackFailure( + "Odoo module update did not complete.", + evidence={ + "schedule_id": "schedule-example", + "schedule_deployment_id": "failed-update", + "log_available": "true", + }, + ) + with ( + patch( + "control_plane.workflows.odoo_preview_runtime.dokploy_source.read_dokploy_config", + return_value=("https://dokploy.example.test", "token"), + ), + patch.multiple( + dokploy_api, + fetch_dokploy_target_payload=Mock( + return_value={ + "composeId": "compose-cm-pr-45", + "environmentId": "env-cm-preview", + "serverId": "server-nonprod", + } + ), + update_dokploy_target_env=Mock(), + latest_deployment_for_target=Mock(return_value={"deploymentId": "before"}), + trigger_deployment=Mock(), + wait_for_target_deployment=Mock(), + ), + patch( + "control_plane.workflows.odoo_preview_runtime.dokploy_compose.sync_dokploy_compose_raw_source" + ), + patch( + "control_plane.workflows.odoo_preview_runtime.dokploy_compose.ensure_compose_web_domain_route", + return_value="domain-cm-pr-45", + ), + patch.object( + dokploy_post_deploy, "run_compose_post_deploy_update", side_effect=failure + ), + patch("control_plane.workflows.odoo_preview_runtime._wait_for_smoke_check") as smoke, + ): + result = execute_odoo_preview_dokploy_apply( + control_plane_root=Path("."), + request=OdooPreviewDokployApplyRequest( + dry_run_plan=dry_run, + image_reference="ghcr.io/example/site@sha256:abc123", + environment_values=_environment_values(), + ), + ) + self.assertEqual(result.status, "fail") + self.assertEqual(result.module_install_update_status, "fail") + self.assertEqual(result.module_install_update_evidence, failure.evidence) + smoke.assert_not_called() + def test_apply_refresh_blocks_create_without_template_server_id(self) -> None: dry_run = build_odoo_preview_dokploy_dry_run( request=OdooPreviewDokployDryRunRequest(