diff --git a/control_plane/drivers/registry.py b/control_plane/drivers/registry.py index b91ce20..fc77e31 100644 --- a/control_plane/drivers/registry.py +++ b/control_plane/drivers/registry.py @@ -698,6 +698,11 @@ def _route_alias( "/v1/drivers/odoo/stable-verification", "deployment.write", ), + _route_alias( + "prod_rollback_plan", + "/v1/drivers/odoo/prod-rollback-plan", + "generic_web_prod_rollback.plan", + ), ), setting_groups=( DriverSettingGroupDescriptor( diff --git a/control_plane/service.py b/control_plane/service.py index a79b7d6..0f1ab19 100644 --- a/control_plane/service.py +++ b/control_plane/service.py @@ -914,6 +914,13 @@ def _validate_alignment(self) -> "GenericWebRollbackPlanEnvelope": ) +_ODOO_ROLLBACK_PLAN_ROUTE = _DriverRouteExecutionMetadata( + route_path="/v1/drivers/odoo/prod-rollback-plan", + envelope_model=GenericWebRollbackPlanEnvelope, + denial_message="Workflow cannot plan the Odoo prod rollback for the requested product/context.", +) + + class GenericWebRollbackEnvelope(_ProductRouteEnvelope): schema_version: int = Field(default=1, ge=1) rollback: GenericWebRollbackPlanRequest @@ -1320,6 +1327,7 @@ def _odoo_preview_identifier(value: str, *, suffix: str) -> str: _GENERIC_WEB_PROD_PROMOTION_ROUTE.route_path, _GENERIC_WEB_PROD_PROMOTION_WORKFLOW_ROUTE.route_path, _GENERIC_WEB_ROLLBACK_PLAN_ROUTE.route_path, + _ODOO_ROLLBACK_PLAN_ROUTE.route_path, _GENERIC_WEB_ROLLBACK_ROUTE.route_path, _GENERIC_WEB_STABLE_VERIFICATION_ROUTE.route_path, } @@ -11638,10 +11646,16 @@ def product_action_allowed( request=generic_web_workflow_request.workflow, ) result = driver_result.model_dump(mode="json") - elif path == _GENERIC_WEB_ROLLBACK_PLAN_ROUTE.route_path: - generic_web_rollback_request = ( - _GENERIC_WEB_ROLLBACK_PLAN_ROUTE.envelope_model.model_validate(payload) - ) + elif path in { + _GENERIC_WEB_ROLLBACK_PLAN_ROUTE.route_path, + _ODOO_ROLLBACK_PLAN_ROUTE.route_path, + }: + route_metadata = ( + _ODOO_ROLLBACK_PLAN_ROUTE + if path == _ODOO_ROLLBACK_PLAN_ROUTE.route_path + else _GENERIC_WEB_ROLLBACK_PLAN_ROUTE + ) + generic_web_rollback_request = route_metadata.envelope_model.model_validate(payload) resolved_driver_context = _resolve_descriptor_product_driver_context( record_store=record_store, route_path=path, @@ -11659,7 +11673,7 @@ def product_action_allowed( route_path=path, product=resolved_driver_context.profile.product, context=resolved_driver_context.lane.context, - denial_message=_GENERIC_WEB_ROLLBACK_PLAN_ROUTE.denial_message, + denial_message=route_metadata.denial_message, start_response=start_response, trace_id=request_trace_id, ) diff --git a/docs/dokploy-service-deployments.md b/docs/dokploy-service-deployments.md index 6442d20..778568d 100644 --- a/docs/dokploy-service-deployments.md +++ b/docs/dokploy-service-deployments.md @@ -187,6 +187,11 @@ such as Odoo can keep a product-specific rollback action while they still need extra gates around backups, release tuples, manifests, migrations, or post-deploy validation. +Odoo accepts `POST /v1/drivers/odoo/prod-rollback-plan` as a compatibility alias +for the generic-web rollback planner. The alias is plan-only: it writes the same +generic rollback-plan record and does not change Odoo's product-specific +`POST /v1/drivers/odoo/prod-rollback` apply route. + Required planner input: - `product` diff --git a/docs/driver-descriptors.md b/docs/driver-descriptors.md index 34483bb..bf6291a 100644 --- a/docs/driver-descriptors.md +++ b/docs/driver-descriptors.md @@ -123,7 +123,10 @@ The `prod_rollback_plan` action routes to `POST /v1/drivers/generic-web/prod-rollback-plan`. It is a safe-write planner: Launchplane reads the product profile, destination lane, selected deployment record, and optional backup gate evidence, then writes a -`GenericWebRollbackPlanRecord`. It does not mutate the provider. +`GenericWebRollbackPlanRecord`. It does not mutate the provider. Odoo also +accepts `POST /v1/drivers/odoo/prod-rollback-plan` as a non-operator +compatibility alias for existing Odoo-shaped automation; the canonical action +and persisted record remain generic-web-owned. The `prod_rollback` action routes to `POST /v1/drivers/generic-web/prod-rollback`. It re-runs the same rollback-plan @@ -131,7 +134,9 @@ validation, persists the plan record, and applies ready plans through the normal generic-web deploy path using the previous immutable artifact identity. 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. +or post-deploy checks. Odoo keeps `POST /v1/drivers/odoo/prod-rollback` as its +product-specific apply route until those Odoo-only invariants are represented in +the generic workflow contract. The `stable_verification` action routes to `POST /v1/drivers/generic-web/stable-verification`. Product workflows submit the diff --git a/docs/service-boundary.md b/docs/service-boundary.md index 1908c84..55d8c3f 100644 --- a/docs/service-boundary.md +++ b/docs/service-boundary.md @@ -110,6 +110,7 @@ VeriReel product paths: - `POST /v1/drivers/odoo/stable-verification` - `POST /v1/drivers/odoo/prod-backup-gate` - `POST /v1/drivers/odoo/prod-promotion` + - `POST /v1/drivers/odoo/prod-rollback-plan` - `POST /v1/drivers/odoo/prod-rollback` - `POST /v1/drivers/verireel/testing-deploy` - `POST /v1/drivers/verireel/testing-verification` diff --git a/tests/test_driver_descriptors.py b/tests/test_driver_descriptors.py index 5e0fd9a..0da1b1f 100644 --- a/tests/test_driver_descriptors.py +++ b/tests/test_driver_descriptors.py @@ -162,6 +162,11 @@ def test_odoo_descriptor_marks_prod_rollback_as_destructive(self) -> None: "/v1/drivers/odoo/preview-verification", ) self.assertFalse(route_aliases["preview_verification"].operator_visible) + self.assertEqual( + route_aliases["prod_rollback_plan"].route_path, + "/v1/drivers/odoo/prod-rollback-plan", + ) + self.assertFalse(route_aliases["prod_rollback_plan"].operator_visible) self.assertEqual(actions["stable_bootstrap"].safety, "destructive") self.assertEqual( actions["stable_bootstrap"].route_path, @@ -183,6 +188,10 @@ def test_effective_odoo_actions_inherit_generic_web_preview_routes(self) -> None actions["preview_verification"].route_path, "/v1/drivers/generic-web/preview-verification", ) + self.assertEqual( + actions["prod_rollback_plan"].route_path, + "/v1/drivers/generic-web/prod-rollback-plan", + ) def test_verireel_descriptor_exposes_preview_and_stable_capabilities(self) -> None: descriptor = read_driver_descriptor("verireel") @@ -495,6 +504,16 @@ def test_odoo_preview_execution_metadata_matches_descriptors(self) -> None: ].action_id, "preview_verification", ) + self.assertEqual( + control_plane_service._driver_route_metadata_from_descriptors()[ + "/v1/drivers/odoo/prod-rollback-plan" + ].action_id, + "prod_rollback_plan", + ) + self.assertIn( + "/v1/drivers/odoo/prod-rollback-plan", + control_plane_service._build_write_routes(), + ) self.assertIs( control_plane_service._ODOO_PREVIEW_VERIFICATION_ROUTE.envelope_model, control_plane_service.OdooPreviewVerificationEnvelope, diff --git a/tests/test_service.py b/tests/test_service.py index 2e7a506..f2fb83c 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -13906,6 +13906,163 @@ def test_generic_web_rollback_plan_route_rejects_unauthorized_context(self) -> N self.assertEqual(status_code, 403) self.assertEqual(payload["error"]["code"], "authorization_denied") + def test_odoo_rollback_plan_alias_writes_generic_plan_record(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() + ) + ) + deployment_record = DeploymentRecord( + record_id="deployment-cm-prod-previous", + artifact_identity=ArtifactIdentityReference( + artifact_id="ghcr.io/cbusillo/odoo-tenant-cm@sha256:abc123" + ), + context="cm", + instance="prod", + source_git_ref="abc123", + destination_health=HealthcheckEvidence(status="pass"), + resolved_target=ResolvedTargetEvidence( + target_type="application", + target_id="app-cm-prod", + target_name="cm-prod-app", + ), + deploy=DeploymentEvidence( + target_name="cm-prod-app", + target_type="application", + deploy_mode="dokploy-application-api", + deployment_id="deployment-provider-1", + status="pass", + started_at="2026-05-25T12:00:00Z", + finished_at="2026-05-25T12:01:00Z", + ), + ) + store.write_deployment_record(deployment_record) + 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.plan"], + } + ] + } + ) + 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, + ) + + status_code, payload = _invoke_app( + app, + method="POST", + path="/v1/drivers/odoo/prod-rollback-plan", + payload={ + "schema_version": 1, + "product": "odoo-tenant-cm", + "rollback_plan": { + "schema_version": 1, + "product": "odoo-tenant-cm", + "instance": "prod", + "rollback_deployment_record_id": "deployment-cm-prod-previous", + }, + }, + headers={"Idempotency-Key": "odoo-rollback-plan-cm-prod"}, + ) + + plans = store.list_generic_web_rollback_plan_records( + context_name="cm", + instance_name="prod", + limit=1, + ) + plan = plans[0] + + self.assertEqual(status_code, 202) + self.assertEqual(payload["records"]["generic_web_rollback_plan_id"], plan.plan_id) + self.assertEqual(plan.status, "ready") + self.assertEqual(plan.product, "odoo-tenant-cm") + self.assertEqual(plan.context, "cm") + self.assertEqual(plan.rollback_deployment_record_id, "deployment-cm-prod-previous") + + def test_odoo_rollback_plan_alias_rejects_unauthorized_context(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": ["other-context"], + "actions": ["generic_web_prod_rollback.plan"], + } + ] + } + ) + 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, + ) + + status_code, payload = _invoke_app( + app, + method="POST", + path="/v1/drivers/odoo/prod-rollback-plan", + payload={ + "schema_version": 1, + "product": "odoo-tenant-cm", + "rollback_plan": { + "schema_version": 1, + "product": "odoo-tenant-cm", + "instance": "prod", + "rollback_deployment_record_id": "deployment-cm-prod-previous", + }, + }, + ) + + self.assertEqual(status_code, 403) + self.assertEqual(payload["error"]["code"], "authorization_denied") + def test_generic_web_rollback_route_applies_ready_plan(self) -> None: with TemporaryDirectory() as temporary_directory_name: root = Path(temporary_directory_name)