diff --git a/control_plane/service.py b/control_plane/service.py index 0f1ab19..74538c6 100644 --- a/control_plane/service.py +++ b/control_plane/service.py @@ -283,6 +283,9 @@ ) from control_plane.workflows.generic_web_deploy import ( GenericWebDeployRequest, + GenericWebDeployStore, + GenericWebPostDeployContext, + GenericWebPostDeployExecutor, execute_generic_web_deploy, ) from control_plane.workflows.generic_web_promotion import ( @@ -347,6 +350,7 @@ ) from control_plane.workflows.odoo_post_deploy import ( OdooPostDeployRequest, + OdooPostDeployResult, execute_odoo_post_deploy, ) from control_plane.contracts.odoo_stable_bootstrap import ( @@ -728,6 +732,7 @@ class _ResolvedProductDriverContext: lane: ProductLaneProfile | None = None +_ODOO_GENERIC_WEB_POST_DEPLOY_DRIVERS = frozenset({"odoo"}) _LAUNCHPLANE_IMAGE_REFERENCE_ENV_KEY = "DOCKER_IMAGE_REFERENCE" _LOGGER = logging.getLogger(__name__) _LAUNCHPLANE_SELF_DEPLOY_OAUTH_ENV_KEYS = frozenset( @@ -4988,7 +4993,7 @@ def _accepted_payload( def _accepted_payload_extra_record_keys(*, route_path: str) -> frozenset[str]: if route_path == _GENERIC_WEB_ROLLBACK_ROUTE.route_path: - return frozenset({"rollback_status", "deploy_status"}) + return frozenset({"rollback_status", "deploy_status", "post_deploy_status"}) return frozenset() @@ -6421,6 +6426,40 @@ def _product_driver_route_compatible( ) +def _post_deploy_evidence_from_odoo_result( + result: OdooPostDeployResult, +) -> PostDeployUpdateEvidence: + return PostDeployUpdateEvidence( + attempted=True, + status=result.post_deploy_status, + detail=( + result.error_message + or "Odoo post-deploy completed through the generic-web extension hook." + ), + ) + + +def _execute_odoo_generic_web_post_deploy( + control_plane_root: Path, + record_store: GenericWebDeployStore, + context: GenericWebPostDeployContext, +) -> PostDeployUpdateEvidence: + result = execute_odoo_post_deploy( + control_plane_root=control_plane_root, + record_store=record_store, + request=OdooPostDeployRequest(context=context.context, instance=context.instance), + ) + return _post_deploy_evidence_from_odoo_result(result) + + +def _generic_web_post_deploy_executor_for_profile( + profile: LaunchplaneProductProfileRecord, +) -> GenericWebPostDeployExecutor | None: + if profile.driver_id.strip() in _ODOO_GENERIC_WEB_POST_DEPLOY_DRIVERS: + return _execute_odoo_generic_web_post_deploy + return None + + def _find_product_profile_lane( *, profile: LaunchplaneProductProfileRecord, context: str, instance: str ) -> ProductLaneProfile | None: @@ -11523,6 +11562,7 @@ def product_action_allowed( request=generic_web_deploy_request.deploy, profile=profile, lane=lane, + post_deploy_executor=_generic_web_post_deploy_executor_for_profile(profile), ) result = {"deployment_record_id": driver_result.deployment_record_id} elif path == _GENERIC_WEB_PROD_PROMOTION_ROUTE.route_path: @@ -11737,12 +11777,16 @@ def product_action_allowed( control_plane_root=resolved_root, record_store=record_store, request=generic_web_rollback_apply_request.rollback, + post_deploy_executor=_generic_web_post_deploy_executor_for_profile( + resolved_driver_context.profile + ), ) result = { "generic_web_rollback_plan_id": driver_result.plan_id, "deployment_record_id": driver_result.deployment_record_id, "rollback_status": driver_result.rollback_status, "deploy_status": driver_result.deploy_status, + "post_deploy_status": driver_result.post_deploy_status, } elif path in _PREVIEW_DESIRED_STATE_ROUTE_PATHS: generic_web_desired_state_request, profile, authorization_response = ( diff --git a/control_plane/workflows/generic_web_rollback.py b/control_plane/workflows/generic_web_rollback.py index 12ced73..d589472 100644 --- a/control_plane/workflows/generic_web_rollback.py +++ b/control_plane/workflows/generic_web_rollback.py @@ -30,6 +30,7 @@ class GenericWebRollbackApplyResult(BaseModel): deployment_record_id: str = "" rollback_status: Literal["pass", "fail", "blocked"] deploy_status: Literal["pass", "fail", "skipped"] = "skipped" + post_deploy_status: Literal["pass", "fail", "skipped"] = "skipped" product: str context: str instance: str @@ -99,16 +100,18 @@ def execute_generic_web_rollback( ), post_deploy_executor=post_deploy_executor, ) + rollback_status: Literal["pass", "fail"] = ( + "pass" + if deploy_result.deploy_status == "pass" + and deploy_result.post_deploy_status in {"pass", "skipped"} + else "fail" + ) return GenericWebRollbackApplyResult( plan_id=plan.plan_id, deployment_record_id=deploy_result.deployment_record_id, - rollback_status=( - "pass" - if deploy_result.deploy_status == "pass" - and deploy_result.post_deploy_status in {"pass", "skipped"} - else "fail" - ), + rollback_status=rollback_status, deploy_status=deploy_result.deploy_status, + post_deploy_status=deploy_result.post_deploy_status, product=plan.product, context=plan.context, instance=plan.instance, diff --git a/docs/dokploy-service-deployments.md b/docs/dokploy-service-deployments.md index ff63e46..1e56b7f 100644 --- a/docs/dokploy-service-deployments.md +++ b/docs/dokploy-service-deployments.md @@ -175,6 +175,11 @@ extension writes terminal post-deploy evidence without changing the underlying deploy status, so operators can distinguish "image deploy failed" from "image deploy passed but product maintenance failed". +Odoo profiles that execute generic-web deploy or rollback apply use this +extension to run the Odoo post-deploy driver after the provider deploy succeeds. +The Odoo-specific rollback apply route remains available for rollback flows that +still need Odoo release tuple and promotion-state updates. + Promotion uses the same artifact identity and target records. When health URLs exist, Launchplane verifies the source and destination lane health around the deployment and writes promotion evidence. diff --git a/docs/driver-descriptors.md b/docs/driver-descriptors.md index 8de9116..da0ae4a 100644 --- a/docs/driver-descriptors.md +++ b/docs/driver-descriptors.md @@ -114,7 +114,9 @@ records post-deploy evidence as `skipped` unless a based driver explicitly provides a product post-deploy extension. That extension point is the boundary for product-only work after a provider deploy succeeds; it must return terminal post-deploy evidence and must keep deploy status distinct from post-deploy -status. +status. Odoo profiles receive this extension when they execute generic-web +deploy, which runs the Odoo post-deploy driver after the provider deploy +succeeds. The `prod_promotion` action routes to `POST /v1/drivers/generic-web/prod-promotion`. It promotes a generic-web @@ -139,7 +141,10 @@ validation, persists the plan record, and applies ready plans through the normal generic-web deploy path using the previous immutable artifact identity. Generic rollback also forwards the generic deploy post-deploy extension hook, so a based driver can keep product-only post-deploy checks while reusing the common -rollback deployment path once its other invariants are represented. Product +rollback deployment path once its other invariants are represented. Odoo profiles +therefore run Odoo post-deploy through generic rollback apply, but the canonical +Odoo rollback apply route remains product-specific until its release and +promotion bookkeeping is generic-contract represented or explicitly wrapped. Product drivers keep their own `prod_rollback` action only when they need additional product-specific gates, such as Odoo backup, release tuple, manifest, migration, or post-deploy checks. Odoo keeps `POST /v1/drivers/odoo/prod-rollback` as its diff --git a/docs/driver-development.md b/docs/driver-development.md index fead5b3..77a4b36 100644 --- a/docs/driver-development.md +++ b/docs/driver-development.md @@ -62,6 +62,9 @@ as Odoo override/application maintenance. The extension must return terminal `PostDeployUpdateEvidence` and must not hide provider deploy status: a failed extension can fail the lifecycle action while the deployment record still shows the underlying image deploy as `pass` and the post-deploy evidence as `fail`. +Launchplane wires this extension for Odoo profiles when they execute generic-web +deploy or rollback apply, so Odoo can reuse common provider deployment while its +post-deploy maintenance remains explicit driver behavior. Do not move a product apply route onto generic deploy until its remaining release, backup, promotion, migration, and post-deploy invariants are either represented in generic contracts or still explicitly wrapped by the product diff --git a/tests/test_service.py b/tests/test_service.py index f2fb83c..1e06073 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -165,6 +165,10 @@ GenericWebPreviewSmokeResult, ) from control_plane.workflows.odoo_preview_runtime import OdooPreviewDokployApplyResult +from control_plane.workflows.generic_web_deploy import ( + GenericWebDeployStore, + GenericWebPostDeployContext, +) StartResponse = Callable[[str, list[tuple[str, str]]], None] WsgiApp = Callable[[dict[str, object], StartResponse], Iterable[bytes]] @@ -13643,6 +13647,70 @@ def test_generic_web_deploy_route_accepts_base_driver_product(self) -> None: _, kwargs = deploy.call_args self.assertEqual(kwargs["profile"].driver_id, "odoo") self.assertEqual(kwargs["lane"].context, "sellyouroutboard-testing") + self.assertIs( + kwargs["post_deploy_executor"], + control_plane_service._execute_odoo_generic_web_post_deploy, + ) + + def test_generic_web_deploy_route_keeps_literal_generic_products_without_post_deploy_adapter( + self, + ) -> None: + with TemporaryDirectory() as temporary_directory_name: + root = Path(temporary_directory_name) + store = FilesystemRecordStore(state_dir=root / "state") + store.write_product_profile_record( + LaunchplaneProductProfileRecord.model_validate(_product_profile_payload()) + ) + policy = LaunchplaneAuthzPolicy.model_validate( + { + "github_actions": [ + { + "repository": "every/verireel", + "workflow_refs": [ + "every/verireel/.github/workflows/preview-control-plane.yml@refs/heads/main" + ], + "event_names": ["pull_request"], + "products": ["sellyouroutboard"], + "contexts": ["sellyouroutboard-testing"], + "actions": ["generic_web_deploy.execute"], + } + ] + } + ) + app = create_launchplane_service_app( + state_dir=root / "state", + verifier=_StubVerifier(_identity()), + authz_policy=policy, + control_plane_root_path=root, + ) + driver_result = SimpleNamespace(deployment_record_id="deployment-syo-testing") + + with patch( + "control_plane.service.execute_generic_web_deploy", + return_value=driver_result, + ) as deploy: + status_code, payload = _invoke_app( + app, + method="POST", + path="/v1/drivers/generic-web/deploy", + payload={ + "schema_version": 1, + "product": "sellyouroutboard", + "deploy": { + "schema_version": 1, + "product": "sellyouroutboard", + "instance": "testing", + "artifact_id": "ghcr.io/cbusillo/sellyouroutboard@sha256:abc123", + "source_git_ref": "abc123", + }, + }, + headers={"Idempotency-Key": "generic-web-deploy-syo-no-adapter"}, + ) + + self.assertEqual(status_code, 202) + self.assertEqual(payload["records"]["deployment_record_id"], "deployment-syo-testing") + deploy.assert_called_once() + self.assertIsNone(deploy.call_args.kwargs["post_deploy_executor"]) def test_generic_web_deploy_route_rejects_unknown_base_driver_product(self) -> None: with TemporaryDirectory() as temporary_directory_name: @@ -14063,6 +14131,83 @@ def test_odoo_rollback_plan_alias_rejects_unauthorized_context(self) -> None: self.assertEqual(status_code, 403) self.assertEqual(payload["error"]["code"], "authorization_denied") + def test_odoo_generic_web_post_deploy_adapter_returns_terminal_evidence(self) -> None: + with TemporaryDirectory() as temporary_directory_name: + root = Path(temporary_directory_name) + store = FilesystemRecordStore(state_dir=root / "state") + context = GenericWebPostDeployContext( + product="odoo-tenant-cm", + context="cm", + instance="prod", + deployment_record_id="deployment-cm-prod", + target_name="cm-prod", + target_type="compose", + target_id="compose-cm-prod", + artifact_id="ghcr.io/cbusillo/odoo-tenant-cm@sha256:abc123", + source_git_ref="abc123", + ) + + with patch( + "control_plane.service.execute_odoo_post_deploy", + return_value=OdooPostDeployResult( + context="cm", + instance="prod", + phase="deploy", + post_deploy_status="pass", + override_status="pass", + ), + ) as post_deploy: + evidence = control_plane_service._execute_odoo_generic_web_post_deploy( + root, + cast(GenericWebDeployStore, store), + context, + ) + + self.assertTrue(evidence.attempted) + self.assertEqual(evidence.status, "pass") + self.assertIn("generic-web extension hook", evidence.detail) + post_deploy.assert_called_once() + request = post_deploy.call_args.kwargs["request"] + self.assertEqual(request.context, "cm") + self.assertEqual(request.instance, "prod") + + def test_odoo_generic_web_post_deploy_adapter_preserves_failure_detail(self) -> None: + with TemporaryDirectory() as temporary_directory_name: + root = Path(temporary_directory_name) + store = FilesystemRecordStore(state_dir=root / "state") + context = GenericWebPostDeployContext( + product="odoo-tenant-cm", + context="cm", + instance="prod", + deployment_record_id="deployment-cm-prod", + target_name="cm-prod", + target_type="compose", + target_id="compose-cm-prod", + artifact_id="ghcr.io/cbusillo/odoo-tenant-cm@sha256:abc123", + source_git_ref="abc123", + ) + + with patch( + "control_plane.service.execute_odoo_post_deploy", + return_value=OdooPostDeployResult( + context="cm", + instance="prod", + phase="deploy", + post_deploy_status="fail", + override_status="fail", + error_message="override failed", + ), + ): + evidence = control_plane_service._execute_odoo_generic_web_post_deploy( + root, + cast(GenericWebDeployStore, store), + context, + ) + + self.assertTrue(evidence.attempted) + self.assertEqual(evidence.status, "fail") + self.assertEqual(evidence.detail, "override failed") + def test_generic_web_rollback_route_applies_ready_plan(self) -> None: with TemporaryDirectory() as temporary_directory_name: root = Path(temporary_directory_name) @@ -14133,8 +14278,93 @@ def test_generic_web_rollback_route_applies_ready_plan(self) -> None: self.assertEqual(payload["records"]["deployment_record_id"], "deployment-syo-prod-rollback") self.assertEqual(payload["records"]["rollback_status"], "pass") self.assertEqual(payload["records"]["deploy_status"], "pass") + self.assertEqual(payload["records"]["post_deploy_status"], "skipped") rollback.assert_called_once() self.assertEqual(rollback.call_args.kwargs["request"].product, "sellyouroutboard") + self.assertIsNone(rollback.call_args.kwargs["post_deploy_executor"]) + + def test_generic_web_rollback_route_passes_odoo_post_deploy_adapter_for_odoo_profile( + self, + ) -> None: + with TemporaryDirectory() as temporary_directory_name: + root = Path(temporary_directory_name) + state_dir = root / "state" + store = FilesystemRecordStore(state_dir=state_dir) + store.write_product_profile_record( + LaunchplaneProductProfileRecord.model_validate( + _odoo_profile_payload_with_prod_lane() + ) + ) + policy = LaunchplaneAuthzPolicy.model_validate( + { + "github_actions": [ + { + "repository": "cbusillo/odoo-tenant-cm", + "workflow_refs": [ + "cbusillo/odoo-tenant-cm/.github/workflows/deploy-odoo.yml@refs/heads/main" + ], + "event_names": ["workflow_dispatch"], + "products": ["odoo-tenant-cm"], + "contexts": ["cm"], + "actions": ["generic_web_prod_rollback.execute"], + } + ] + } + ) + app = create_launchplane_service_app( + state_dir=state_dir, + verifier=_StubVerifier( + _identity( + repository="cbusillo/odoo-tenant-cm", + workflow_ref=( + "cbusillo/odoo-tenant-cm/.github/workflows/deploy-odoo.yml@refs/heads/main" + ), + event_name="workflow_dispatch", + ) + ), + authz_policy=policy, + control_plane_root_path=root, + ) + driver_result = GenericWebRollbackApplyResult( + plan_id="generic-web-rollback-cm-prod", + deployment_record_id="deployment-cm-prod-rollback", + rollback_status="pass", + deploy_status="pass", + product="odoo-tenant-cm", + context="cm", + instance="prod", + rollback_deployment_record_id="deployment-cm-prod-previous", + ) + + with patch( + "control_plane.service.execute_generic_web_rollback", + return_value=driver_result, + ) as rollback: + status_code, payload = _invoke_app( + app, + method="POST", + path="/v1/drivers/generic-web/prod-rollback", + payload={ + "schema_version": 1, + "product": "odoo-tenant-cm", + "rollback": { + "schema_version": 1, + "product": "odoo-tenant-cm", + "instance": "prod", + "rollback_deployment_record_id": "deployment-cm-prod-previous", + }, + }, + headers={"Idempotency-Key": "generic-web-rollback-cm-prod"}, + ) + + self.assertEqual(status_code, 202) + self.assertEqual(payload["records"]["deployment_record_id"], "deployment-cm-prod-rollback") + self.assertEqual(payload["records"]["post_deploy_status"], "skipped") + rollback.assert_called_once() + self.assertIs( + rollback.call_args.kwargs["post_deploy_executor"], + control_plane_service._execute_odoo_generic_web_post_deploy, + ) def test_generic_web_rollback_route_replays_idempotent_response_shape(self) -> None: with TemporaryDirectory() as temporary_directory_name: @@ -14211,6 +14441,7 @@ def test_generic_web_rollback_route_replays_idempotent_response_shape(self) -> N self.assertEqual(first_payload["records"], second_payload["records"]) self.assertEqual(second_payload["records"]["rollback_status"], "pass") self.assertEqual(second_payload["records"]["deploy_status"], "pass") + self.assertEqual(second_payload["records"]["post_deploy_status"], "skipped") self.assertTrue(second_payload["replayed"]) rollback.assert_called_once()