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
16 changes: 14 additions & 2 deletions control_plane/contracts/generic_web_rollback.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
BackupGateEvidence,
HealthcheckEvidence,
)
from control_plane.drivers.registry import read_driver_descriptor
from control_plane.workflows.ship import utc_now_timestamp

GenericWebRollbackPlanStatus = Literal["ready", "blocked"]
Expand Down Expand Up @@ -389,9 +390,10 @@ def _resolve_generic_web_profile_lane(
instance: str,
) -> tuple[LaunchplaneProductProfileRecord, ProductLaneProfile]:
profile = record_store.read_product_profile_record(product)
if profile.driver_id != "generic-web":
if not _product_profile_uses_generic_web_base(profile):
raise ValueError(
f"Product {profile.product!r} is configured for driver {profile.driver_id!r}, not generic-web."
f"Product {profile.product!r} is configured for driver {profile.driver_id!r}, "
"not generic-web or a generic-web based driver."
)
for lane in profile.lanes:
if lane.instance == instance:
Expand All @@ -401,6 +403,16 @@ def _resolve_generic_web_profile_lane(
)


def _product_profile_uses_generic_web_base(profile: LaunchplaneProductProfileRecord) -> bool:
driver_id = profile.driver_id.strip()
if driver_id == "generic-web":
return True
try:
return read_driver_descriptor(driver_id).base_driver_id == "generic-web"
except FileNotFoundError:
return False


def _immutable_artifact_id(
*, profile: LaunchplaneProductProfileRecord, deployment_record: DeploymentRecord
) -> str:
Expand Down
6 changes: 5 additions & 1 deletion control_plane/contracts/product_environment_read_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -1037,7 +1037,10 @@ def _product_action_authorization_context(
return ""
if action.route_path == "/v1/drivers/generic-web/prod-promotion-workflow":
return _lane_context_for_instance(profile=profile, preferred_instances=("testing", "prod"))
if action.route_path == "/v1/drivers/generic-web/prod-rollback-plan":
if action.route_path in {
"/v1/drivers/generic-web/prod-rollback-plan",
"/v1/drivers/generic-web/prod-rollback",
}:
return _lane_context_if_present(profile=profile, instance="prod")
if action.route_path in {
"/v1/drivers/odoo/prod-backup-gate",
Expand Down Expand Up @@ -1334,6 +1337,7 @@ def _action_support_reason(
if action.route_path in {
"/v1/drivers/generic-web/prod-promotion-workflow",
"/v1/drivers/generic-web/prod-rollback-plan",
"/v1/drivers/generic-web/prod-rollback",
"/v1/drivers/odoo/prod-backup-gate",
"/v1/drivers/odoo/prod-promotion",
"/v1/drivers/odoo/prod-rollback",
Expand Down
17 changes: 16 additions & 1 deletion control_plane/drivers/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,12 @@ def _route_alias(
capability_id="image_deployable",
label="Image deployable",
description="Deploy immutable container images and record stable-lane deployment evidence.",
actions=("stable_deploy", "prod_promotion", "prod_rollback_plan"),
actions=(
"stable_deploy",
"prod_promotion",
"prod_rollback_plan",
"prod_rollback",
),
panels=("lane_health", "deployment_evidence", "promotion_evidence"),
),
DriverCapabilityDescriptor(
Expand Down Expand Up @@ -383,6 +388,16 @@ def _route_alias(
authz_action="generic_web_prod_rollback.plan",
writes_records=("generic_web_rollback_plan",),
),
_action(
"prod_rollback",
"Apply prod rollback",
"Revalidate and apply a generic-web rollback by deploying a previous immutable artifact.",
safety="destructive",
scope="instance",
route_path="/v1/drivers/generic-web/prod-rollback",
authz_action="generic_web_prod_rollback.execute",
writes_records=("generic_web_rollback_plan", "deployment", "inventory"),
),
_action(
"stable_verification",
"Record stable verification",
Expand Down
85 changes: 84 additions & 1 deletion control_plane/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,7 @@
GenericWebPromotionWorkflowRequest,
dispatch_generic_web_promotion_workflow,
)
from control_plane.workflows.generic_web_rollback import execute_generic_web_rollback
from control_plane.workflows.generic_web_preview import (
GenericWebPreviewDesiredStateRequest,
GenericWebPreviewDestroyRequest,
Expand Down Expand Up @@ -913,6 +914,28 @@ def _validate_alignment(self) -> "GenericWebRollbackPlanEnvelope":
)


class GenericWebRollbackEnvelope(_ProductRouteEnvelope):
schema_version: int = Field(default=1, ge=1)
rollback: GenericWebRollbackPlanRequest

@model_validator(mode="after")
def _validate_alignment(self) -> "GenericWebRollbackEnvelope":
if not self.product.strip():
raise ValueError("generic web rollback requires product")
if self.product.strip() != self.rollback.product.strip():
raise ValueError("generic web rollback requires matching product values")
return self


_GENERIC_WEB_ROLLBACK_ROUTE = _DriverRouteExecutionMetadata(
route_path="/v1/drivers/generic-web/prod-rollback",
envelope_model=GenericWebRollbackEnvelope,
denial_message=(
"Workflow cannot execute the generic web prod rollback for the requested product/context."
),
)


class GenericWebStableVerificationRequest(BaseModel):
model_config = ConfigDict(extra="forbid")

Expand Down Expand Up @@ -1297,6 +1320,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,
_GENERIC_WEB_ROLLBACK_ROUTE.route_path,
_GENERIC_WEB_STABLE_VERIFICATION_ROUTE.route_path,
}
)
Expand Down Expand Up @@ -4879,6 +4903,7 @@ def _accepted_payload(
trace_id: str,
result: dict[str, object],
driver_result: BaseModel | dict[str, object] | None,
extra_record_keys: frozenset[str] = frozenset(),
replayed: bool = False,
original_trace_id: str = "",
) -> dict[str, object]:
Expand Down Expand Up @@ -4932,9 +4957,10 @@ def _accepted_payload(
"runner_host_hygiene_audit_record_key",
"generic_web_rollback_plan_id",
}
accepted_record_keys = record_keys | extra_record_keys
records: dict[str, object] = {}
for key, value in result.items():
if key not in record_keys:
if key not in accepted_record_keys:
continue
if key.endswith("_preview_verification") and isinstance(value, dict):
records[key] = value
Expand All @@ -4952,6 +4978,12 @@ def _accepted_payload(
return 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()


def _operation_payload(
operation: OdooStableBootstrapOperationRecord,
) -> dict[str, object]:
Expand Down Expand Up @@ -5311,6 +5343,7 @@ def _replay_idempotent_response(
trace_id=trace_id,
result=dict(stored_payload.get("records") or {}),
driver_result=stored_driver_result if isinstance(stored_driver_result, dict) else None,
extra_record_keys=_accepted_payload_extra_record_keys(route_path=stored_record.route_path),
replayed=True,
original_trace_id=stored_record.response_trace_id,
)
Expand Down Expand Up @@ -11648,6 +11681,55 @@ def product_action_allowed(
request=generic_web_rollback_request.rollback_plan,
)
result = {"generic_web_rollback_plan_id": driver_result.plan_id}
elif path == _GENERIC_WEB_ROLLBACK_ROUTE.route_path:
generic_web_rollback_apply_request = (
_GENERIC_WEB_ROLLBACK_ROUTE.envelope_model.model_validate(payload)
)
resolved_driver_context = _resolve_descriptor_product_driver_context(
record_store=record_store,
route_path=path,
product=generic_web_rollback_apply_request.rollback.product,
instance=generic_web_rollback_apply_request.rollback.instance,
require_profile=True,
)
if resolved_driver_context.profile is None or resolved_driver_context.lane is None:
raise ProductDriverMismatchError(
"Generic web rollback requires a product profile lane."
)
authorization_response = _driver_route_authorization_response(
authz_policy=authz_policy,
identity=identity,
route_path=path,
product=resolved_driver_context.profile.product,
context=resolved_driver_context.lane.context,
denial_message=_GENERIC_WEB_ROLLBACK_ROUTE.denial_message,
start_response=start_response,
trace_id=request_trace_id,
)
if authorization_response is not None:
return authorization_response
idempotent_response = _check_idempotent_request(
record_store=record_store,
scope=request_scope,
route_path=path,
idempotency_key=request_idempotency_key,
request_fingerprint=request_fingerprint,
start_response=start_response,
trace_id=request_trace_id,
)
if idempotent_response is not None:
return idempotent_response
driver_result = execute_generic_web_rollback(
control_plane_root=resolved_root,
record_store=record_store,
request=generic_web_rollback_apply_request.rollback,
)
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,
}
elif path in _PREVIEW_DESIRED_STATE_ROUTE_PATHS:
generic_web_desired_state_request, profile, authorization_response = (
_authorize_generic_web_preview_route(
Expand Down Expand Up @@ -13919,6 +14001,7 @@ def product_action_allowed(
trace_id=request_trace_id,
result=result,
driver_result=driver_result,
extra_record_keys=_accepted_payload_extra_record_keys(route_path=path),
)
should_store_idempotency = _should_store_idempotency_record(
path=path,
Expand Down
116 changes: 116 additions & 0 deletions control_plane/workflows/generic_web_rollback.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
from __future__ import annotations

from pathlib import Path
from typing import Literal, Protocol

from pydantic import BaseModel, ConfigDict, model_validator

from control_plane.contracts.generic_web_rollback import (
GenericWebRollbackBlocker,
GenericWebRollbackPlanRequest,
GenericWebRollbackPlanStore,
build_generic_web_rollback_plan,
)
from control_plane.workflows.generic_web_deploy import (
GenericWebDeployRequest,
GenericWebDeployStore,
execute_generic_web_deploy,
)


class GenericWebRollbackApplyStore(GenericWebRollbackPlanStore, GenericWebDeployStore, Protocol):
pass


class GenericWebRollbackApplyResult(BaseModel):
model_config = ConfigDict(extra="forbid")

plan_id: str
deployment_record_id: str = ""
rollback_status: Literal["pass", "fail", "blocked"]
deploy_status: Literal["pass", "fail", "skipped"] = "skipped"
product: str
context: str
instance: str
rollback_deployment_record_id: str
blockers: tuple[GenericWebRollbackBlocker, ...] = ()
error_message: str = ""

@model_validator(mode="after")
def _validate_result(self) -> "GenericWebRollbackApplyResult":
self.plan_id = _required_text(
self.plan_id, "generic web rollback apply result requires plan_id"
)
self.product = _required_text(
self.product, "generic web rollback apply result requires product"
)
self.context = _required_text(
self.context, "generic web rollback apply result requires context"
)
self.instance = _required_text(
self.instance, "generic web rollback apply result requires instance"
)
self.rollback_deployment_record_id = _required_text(
self.rollback_deployment_record_id,
"generic web rollback apply result requires rollback_deployment_record_id",
)
self.deployment_record_id = self.deployment_record_id.strip()
self.error_message = self.error_message.strip()
if self.rollback_status == "pass" and not self.deployment_record_id:
raise ValueError("passing generic web rollback apply requires deployment_record_id")
if self.rollback_status == "blocked" and not self.blockers:
raise ValueError("blocked generic web rollback apply requires blockers")
if self.rollback_status != "blocked" and self.blockers:
raise ValueError("non-blocked generic web rollback apply cannot include blockers")
return self


def execute_generic_web_rollback(
*,
control_plane_root: Path,
record_store: GenericWebRollbackApplyStore,
request: GenericWebRollbackPlanRequest,
) -> GenericWebRollbackApplyResult:
plan = build_generic_web_rollback_plan(record_store=record_store, request=request)
record_store.write_generic_web_rollback_plan_record(plan)
if plan.status == "blocked" or plan.planned_deploy is None:
return GenericWebRollbackApplyResult(
plan_id=plan.plan_id,
rollback_status="blocked",
product=plan.product,
context=plan.context,
instance=plan.instance,
rollback_deployment_record_id=plan.rollback_deployment_record_id,
blockers=plan.blockers,
)
planned_deploy = plan.planned_deploy
deploy_result = execute_generic_web_deploy(
control_plane_root=control_plane_root,
record_store=record_store,
request=GenericWebDeployRequest(
product=planned_deploy.product,
instance=planned_deploy.instance,
artifact_id=planned_deploy.artifact_id,
source_git_ref=planned_deploy.source_git_ref,
timeout_seconds=planned_deploy.timeout_seconds,
no_cache=planned_deploy.no_cache,
),
)
return GenericWebRollbackApplyResult(
plan_id=plan.plan_id,
deployment_record_id=deploy_result.deployment_record_id,
rollback_status="pass" if deploy_result.deploy_status == "pass" else "fail",
deploy_status=deploy_result.deploy_status,
product=plan.product,
context=plan.context,
instance=plan.instance,
rollback_deployment_record_id=plan.rollback_deployment_record_id,
error_message=deploy_result.error_message,
)


def _required_text(value: str, message: str) -> str:
normalized = value.strip()
if not normalized:
raise ValueError(message)
return normalized
8 changes: 8 additions & 0 deletions docs/dokploy-service-deployments.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,14 @@ Launchplane deployment record selected as the rollback target, and optional
backup-gate evidence, then writes a rollback-plan record. It does not mutate
Dokploy or trigger a product workflow.

Launchplane applies generic-web rollback through
`POST /v1/drivers/generic-web/prod-rollback`. The apply route rebuilds and
persists the rollback plan from current records, then calls the normal
generic-web deploy path with the selected previous immutable artifact. Drivers
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.

Required planner input:

- `product`
Expand Down
12 changes: 9 additions & 3 deletions docs/driver-descriptors.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,9 +123,15 @@ 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. A future
explicit apply action can consume a ready plan and call the normal generic-web
deploy path.
`GenericWebRollbackPlanRecord`. It does not mutate the provider.

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.

The `stable_verification` action routes to
`POST /v1/drivers/generic-web/stable-verification`. Product workflows submit the
Expand Down
Loading