From fc8efd96981639f02b9252fb88a60f2f23a95003 Mon Sep 17 00:00:00 2001 From: Chris Busillo Date: Mon, 25 May 2026 19:24:46 -0400 Subject: [PATCH] Add generic web rollback apply route --- .../contracts/generic_web_rollback.py | 16 +- .../product_environment_read_model.py | 6 +- control_plane/drivers/registry.py | 17 +- control_plane/service.py | 85 +++++++- .../workflows/generic_web_rollback.py | 116 ++++++++++ docs/dokploy-service-deployments.md | 8 + docs/driver-descriptors.md | 12 +- docs/records.md | 10 +- docs/service-boundary.md | 1 + tests/test_driver_descriptors.py | 10 + tests/test_generic_web_rollback.py | 89 ++++++++ tests/test_product_environment_read_model.py | 2 + tests/test_service.py | 202 ++++++++++++++++++ 13 files changed, 562 insertions(+), 12 deletions(-) create mode 100644 control_plane/workflows/generic_web_rollback.py diff --git a/control_plane/contracts/generic_web_rollback.py b/control_plane/contracts/generic_web_rollback.py index 48d04fbf..86277662 100644 --- a/control_plane/contracts/generic_web_rollback.py +++ b/control_plane/contracts/generic_web_rollback.py @@ -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"] @@ -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: @@ -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: diff --git a/control_plane/contracts/product_environment_read_model.py b/control_plane/contracts/product_environment_read_model.py index 21020b46..c231d697 100644 --- a/control_plane/contracts/product_environment_read_model.py +++ b/control_plane/contracts/product_environment_read_model.py @@ -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", @@ -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", diff --git a/control_plane/drivers/registry.py b/control_plane/drivers/registry.py index e3bb9444..b91ce201 100644 --- a/control_plane/drivers/registry.py +++ b/control_plane/drivers/registry.py @@ -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( @@ -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", diff --git a/control_plane/service.py b/control_plane/service.py index 5a9b6ddd..a79b7d60 100644 --- a/control_plane/service.py +++ b/control_plane/service.py @@ -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, @@ -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") @@ -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, } ) @@ -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]: @@ -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 @@ -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]: @@ -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, ) @@ -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( @@ -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, diff --git a/control_plane/workflows/generic_web_rollback.py b/control_plane/workflows/generic_web_rollback.py new file mode 100644 index 00000000..134136c6 --- /dev/null +++ b/control_plane/workflows/generic_web_rollback.py @@ -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 diff --git a/docs/dokploy-service-deployments.md b/docs/dokploy-service-deployments.md index 9b894f65..6442d208 100644 --- a/docs/dokploy-service-deployments.md +++ b/docs/dokploy-service-deployments.md @@ -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` diff --git a/docs/driver-descriptors.md b/docs/driver-descriptors.md index 9d12cad4..34483bba 100644 --- a/docs/driver-descriptors.md +++ b/docs/driver-descriptors.md @@ -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 diff --git a/docs/records.md b/docs/records.md index dfa5f847..5b9fe122 100644 --- a/docs/records.md +++ b/docs/records.md @@ -423,10 +423,12 @@ state/ - Generic-web rollback planning writes `GenericWebRollbackPlanRecord` entries under `generic_web_rollback_plans` in file-backed state and `launchplane_generic_web_rollback_plans` in DB-backed state. These records are - safe-write plans, not provider mutations. They store the destination lane, - selected deployment-record id, immutable artifact identity, source git ref, - planned deploy payload, backup-gate evidence, target health evidence, and any - blockers that prevent a rollback apply. + safe-write plans when written by the plan route and audit records when written + by the apply route before mutation. They store the destination lane, selected + deployment-record id, immutable artifact identity, source git ref, planned + deploy payload, backup-gate evidence, target health evidence, and any blockers + that prevent a rollback apply. Generic-web rollback apply then writes the + normal deployment and inventory records through the generic deploy path. - Direct `ship` and `promote` execution fail closed if the referenced artifact id does not already have a stored manifest in control-plane state. - Artifact manifests may also carry `addon_selectors` metadata so operators can diff --git a/docs/service-boundary.md b/docs/service-boundary.md index d2ff09a3..1908c84b 100644 --- a/docs/service-boundary.md +++ b/docs/service-boundary.md @@ -91,6 +91,7 @@ VeriReel product paths: - `POST /v1/drivers/generic-web/prod-promotion` - `POST /v1/drivers/generic-web/prod-promotion-workflow` - `POST /v1/drivers/generic-web/prod-rollback-plan` + - `POST /v1/drivers/generic-web/prod-rollback` - `POST /v1/drivers/generic-web/stable-verification` - `POST /v1/drivers/generic-web/preview-desired-state` - `POST /v1/drivers/generic-web/preview-refresh` diff --git a/tests/test_driver_descriptors.py b/tests/test_driver_descriptors.py index 1ec6b3e3..5e0fd9ab 100644 --- a/tests/test_driver_descriptors.py +++ b/tests/test_driver_descriptors.py @@ -213,6 +213,11 @@ def test_generic_web_descriptor_is_provider_neutral_base_driver(self) -> None: "/v1/drivers/generic-web/prod-rollback-plan", ) self.assertEqual(actions["prod_rollback_plan"].safety, "safe_write") + self.assertEqual( + actions["prod_rollback"].route_path, + "/v1/drivers/generic-web/prod-rollback", + ) + self.assertEqual(actions["prod_rollback"].safety, "destructive") self.assertEqual( actions["stable_verification"].route_path, "/v1/drivers/generic-web/stable-verification", @@ -385,6 +390,11 @@ def test_generic_web_execution_metadata_matches_descriptors(self) -> None: control_plane_service.GenericWebRollbackPlanEnvelope, "rollback", ), + "prod_rollback": ( + control_plane_service._GENERIC_WEB_ROLLBACK_ROUTE, + control_plane_service.GenericWebRollbackEnvelope, + "rollback", + ), "stable_verification": ( control_plane_service._GENERIC_WEB_STABLE_VERIFICATION_ROUTE, control_plane_service.GenericWebStableVerificationEnvelope, diff --git a/tests/test_generic_web_rollback.py b/tests/test_generic_web_rollback.py index a92867d7..a5f71a09 100644 --- a/tests/test_generic_web_rollback.py +++ b/tests/test_generic_web_rollback.py @@ -1,10 +1,12 @@ import unittest from typing import Literal, cast +from unittest.mock import patch from pydantic import ValidationError from control_plane.contracts.backup_gate_record import BackupGateRecord from control_plane.contracts.deployment_record import DeploymentRecord, ResolvedTargetEvidence +from control_plane.contracts.environment_inventory import EnvironmentInventory from control_plane.contracts.generic_web_rollback import ( GenericWebRollbackPlanRequest, build_generic_web_rollback_plan, @@ -18,6 +20,8 @@ from control_plane.contracts.promotion_record import ArtifactIdentityReference, HealthcheckEvidence from control_plane.contracts.runtime_identity import RuntimeIdentity from control_plane.contracts.ship_request import ShipRequest +from control_plane.workflows.generic_web_deploy import GenericWebDeployResult +from control_plane.workflows.generic_web_rollback import execute_generic_web_rollback from control_plane.workflows.ship import build_deployment_record @@ -48,6 +52,13 @@ def read_backup_gate_record(self, record_id: str) -> BackupGateRecord: def write_generic_web_rollback_plan_record(self, record: object) -> None: self.rollback_plans.append(record) + def write_deployment_record(self, record: DeploymentRecord) -> None: + self.deployments[record.record_id] = record + + def write_environment_inventory(self, record: EnvironmentInventory) -> None: + _ = record + return None + def _profile() -> LaunchplaneProductProfileRecord: return LaunchplaneProductProfileRecord( @@ -75,6 +86,10 @@ def _profile() -> LaunchplaneProductProfileRecord: ) +def _inherited_profile() -> LaunchplaneProductProfileRecord: + return _profile().model_copy(update={"driver_id": "odoo", "product": "cm"}) + + def _request(**overrides: object) -> GenericWebRollbackPlanRequest: payload: dict[str, object] = { "product": "sellyouroutboard", @@ -187,6 +202,80 @@ def test_execute_writes_ready_plan_record(self) -> None: self.assertEqual(plan.status, "ready") self.assertEqual(store.rollback_plans, [plan]) + def test_builds_plan_for_generic_web_based_driver(self) -> None: + store = _GenericWebRollbackStore(_inherited_profile()) + store.deployments["deployment-syo-prod-previous"] = _deployment_record() + + plan = build_generic_web_rollback_plan( + record_store=store, + request=_request(product="cm"), + ) + + self.assertEqual(plan.status, "ready") + self.assertEqual(plan.product, "cm") + self.assertEqual(plan.instance, "prod") + + def test_execute_apply_writes_plan_before_deploying_previous_artifact(self) -> None: + store = _GenericWebRollbackStore(_profile()) + store.deployments["deployment-syo-prod-previous"] = _deployment_record() + deploy_result = GenericWebDeployResult( + deployment_record_id="deployment-syo-prod-rollback", + deploy_status="pass", + deploy_started_at="2026-05-25T12:00:00Z", + deploy_finished_at="2026-05-25T12:01:00Z", + product="sellyouroutboard", + context="sellyouroutboard-testing", + instance="prod", + target_name="syo-prod-app", + target_type="application", + target_id="app-prod", + ) + + with patch( + "control_plane.workflows.generic_web_rollback.execute_generic_web_deploy", + return_value=deploy_result, + ) as deploy: + result = execute_generic_web_rollback( + control_plane_root=__import__("pathlib").Path("/tmp/launchplane"), + record_store=store, + request=_request(timeout_seconds=90, no_cache=True), + ) + + self.assertEqual(result.rollback_status, "pass") + self.assertEqual(result.deploy_status, "pass") + self.assertEqual(result.deployment_record_id, "deployment-syo-prod-rollback") + self.assertEqual(len(store.rollback_plans), 1) + deploy.assert_called_once() + deploy_request = deploy.call_args.kwargs["request"] + self.assertEqual(deploy_request.product, "sellyouroutboard") + self.assertEqual(deploy_request.instance, "prod") + self.assertEqual( + deploy_request.artifact_id, + "ghcr.io/cbusillo/sellyouroutboard@sha256:abc123", + ) + self.assertEqual(deploy_request.source_git_ref, "abc123") + self.assertEqual(deploy_request.timeout_seconds, 90) + self.assertTrue(deploy_request.no_cache) + + def test_execute_apply_returns_blocked_without_deploying(self) -> None: + store = _GenericWebRollbackStore(_profile()) + + with patch( + "control_plane.workflows.generic_web_rollback.execute_generic_web_deploy" + ) as deploy: + result = execute_generic_web_rollback( + control_plane_root=__import__("pathlib").Path("/tmp/launchplane"), + record_store=store, + request=_request(), + ) + + self.assertEqual(result.rollback_status, "blocked") + self.assertEqual(result.deploy_status, "skipped") + self.assertEqual(result.deployment_record_id, "") + self.assertEqual([blocker.code for blocker in result.blockers], ["missing_rollback_target"]) + self.assertEqual(len(store.rollback_plans), 1) + deploy.assert_not_called() + def test_blocks_missing_rollback_target(self) -> None: store = _GenericWebRollbackStore(_profile()) diff --git a/tests/test_product_environment_read_model.py b/tests/test_product_environment_read_model.py index 2bdd8eaf..3cb43263 100644 --- a/tests/test_product_environment_read_model.py +++ b/tests/test_product_environment_read_model.py @@ -553,6 +553,7 @@ def action_allowed(action: str, product: str, context: str) -> bool: "generic_web_prod_promotion.dispatch", "generic_web_prod_promotion.execute", "generic_web_prod_rollback.plan", + "generic_web_prod_rollback.execute", } and context == "example-site-prod" ) @@ -567,6 +568,7 @@ def action_allowed(action: str, product: str, context: str) -> bool: self.assertTrue(actions["prod_promotion_workflow"].enabled) self.assertTrue(actions["prod_promotion"].enabled) self.assertTrue(actions["prod_rollback_plan"].enabled) + self.assertTrue(actions["prod_rollback"].enabled) self.assertFalse(actions["preview_refresh"].enabled) def test_odoo_product_site_overview_uses_prod_context_for_inherited_rollback_plan( diff --git a/tests/test_service.py b/tests/test_service.py index eca46a99..2e7a5062 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -153,6 +153,7 @@ from tests.merge_train_policy_fixtures import build_test_merge_train_policy_record from tests.merge_train_policy_fixtures import build_test_merge_train_policy_with_codex_skills from control_plane.workflows.generic_web_promotion import GenericWebProdPromotionResult +from control_plane.workflows.generic_web_rollback import GenericWebRollbackApplyResult from control_plane.workflows.generic_web_promotion_workflow import GenericWebPromotionWorkflowResult from control_plane.workflows.generic_web_preview import ( GenericWebPreviewDestroyResult, @@ -13905,6 +13906,207 @@ 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_generic_web_rollback_route_applies_ready_plan(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(_product_profile_payload_with_prod()) + ) + 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_prod_rollback.execute"], + } + ] + } + ) + app = create_launchplane_service_app( + state_dir=state_dir, + verifier=_StubVerifier(_identity()), + authz_policy=policy, + control_plane_root_path=root, + ) + driver_result = GenericWebRollbackApplyResult( + plan_id="generic-web-rollback-syo-prod", + deployment_record_id="deployment-syo-prod-rollback", + rollback_status="pass", + deploy_status="pass", + product="sellyouroutboard", + context="sellyouroutboard-testing", + instance="prod", + rollback_deployment_record_id="deployment-syo-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": "sellyouroutboard", + "rollback": { + "schema_version": 1, + "product": "sellyouroutboard", + "instance": "prod", + "rollback_deployment_record_id": "deployment-syo-prod-previous", + }, + }, + headers={"Idempotency-Key": "generic-web-rollback-syo-prod"}, + ) + + self.assertEqual(status_code, 202) + self.assertEqual( + payload["records"]["generic_web_rollback_plan_id"], + "generic-web-rollback-syo-prod", + ) + 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") + rollback.assert_called_once() + self.assertEqual(rollback.call_args.kwargs["request"].product, "sellyouroutboard") + + def test_generic_web_rollback_route_replays_idempotent_response_shape(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(_product_profile_payload_with_prod()) + ) + 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_prod_rollback.execute"], + } + ] + } + ) + app = create_launchplane_service_app( + state_dir=state_dir, + verifier=_StubVerifier(_identity()), + authz_policy=policy, + control_plane_root_path=root, + ) + driver_result = GenericWebRollbackApplyResult( + plan_id="generic-web-rollback-syo-prod", + deployment_record_id="deployment-syo-prod-rollback", + rollback_status="pass", + deploy_status="pass", + product="sellyouroutboard", + context="sellyouroutboard-testing", + instance="prod", + rollback_deployment_record_id="deployment-syo-prod-previous", + ) + request_payload = { + "schema_version": 1, + "product": "sellyouroutboard", + "rollback": { + "schema_version": 1, + "product": "sellyouroutboard", + "instance": "prod", + "rollback_deployment_record_id": "deployment-syo-prod-previous", + }, + } + + with patch( + "control_plane.service.execute_generic_web_rollback", + return_value=driver_result, + ) as rollback: + first_status_code, first_payload = _invoke_app( + app, + method="POST", + path="/v1/drivers/generic-web/prod-rollback", + payload=request_payload, + headers={"Idempotency-Key": "generic-web-rollback-replay-syo-prod"}, + ) + second_status_code, second_payload = _invoke_app( + app, + method="POST", + path="/v1/drivers/generic-web/prod-rollback", + payload=request_payload, + headers={"Idempotency-Key": "generic-web-rollback-replay-syo-prod"}, + ) + + self.assertEqual(first_status_code, 202) + self.assertEqual(second_status_code, 202) + 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.assertTrue(second_payload["replayed"]) + rollback.assert_called_once() + + def test_generic_web_rollback_route_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(_product_profile_payload_with_prod()) + ) + 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": ["other-context"], + "actions": ["generic_web_prod_rollback.execute"], + } + ] + } + ) + app = create_launchplane_service_app( + state_dir=state_dir, + verifier=_StubVerifier(_identity()), + authz_policy=policy, + control_plane_root_path=root, + ) + + status_code, payload = _invoke_app( + app, + method="POST", + path="/v1/drivers/generic-web/prod-rollback", + payload={ + "schema_version": 1, + "product": "sellyouroutboard", + "rollback": { + "schema_version": 1, + "product": "sellyouroutboard", + "instance": "prod", + "rollback_deployment_record_id": "deployment-syo-prod-previous", + }, + }, + ) + + self.assertEqual(status_code, 403) + self.assertEqual(payload["error"]["code"], "authorization_denied") + def test_generic_web_deploy_route_resolves_literal_generic_web_profile(self) -> None: with TemporaryDirectory() as temporary_directory_name: root = Path(temporary_directory_name)