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
5 changes: 5 additions & 0 deletions control_plane/drivers/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
24 changes: 19 additions & 5 deletions control_plane/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
}
Expand Down Expand Up @@ -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,
Expand All @@ -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,
)
Expand Down
5 changes: 5 additions & 0 deletions docs/dokploy-service-deployments.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
9 changes: 7 additions & 2 deletions docs/driver-descriptors.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,15 +123,20 @@ 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
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
Expand Down
1 change: 1 addition & 0 deletions docs/service-boundary.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
19 changes: 19 additions & 0 deletions tests/test_driver_descriptors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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")
Expand Down Expand Up @@ -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,
Expand Down
157 changes: 157 additions & 0 deletions tests/test_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down