From d0225ebaa5f968490f6220598ba0ec2cc3e52b8a Mon Sep 17 00:00:00 2001 From: Chris Busillo Date: Mon, 25 May 2026 22:38:08 -0400 Subject: [PATCH] Preserve deploy idempotency after post-deploy failure --- control_plane/service.py | 19 +++++++++ tests/test_service.py | 87 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+) diff --git a/control_plane/service.py b/control_plane/service.py index 45096eb..2eb14fb 100644 --- a/control_plane/service.py +++ b/control_plane/service.py @@ -5618,6 +5618,8 @@ def _should_store_idempotency_record( return True if _driver_result_contains_status(driver_result, "blocked"): return False + if _driver_result_has_completed_deploy_with_post_deploy_failure(driver_result): + return True if _driver_result_contains_status(driver_result, "fail"): return False if path in _PENDING_RESULT_IDEMPOTENCY_SKIP_ROUTES: @@ -5625,6 +5627,23 @@ def _should_store_idempotency_record( return True +def _driver_result_has_completed_deploy_with_post_deploy_failure( + driver_result: BaseModel | dict[str, object] | object, +) -> bool: + if isinstance(driver_result, BaseModel): + result_payload = driver_result.model_dump(mode="json") + elif isinstance(driver_result, dict): + result_payload = driver_result + elif hasattr(driver_result, "__dict__"): + result_payload = vars(driver_result) + else: + return False + return ( + result_payload.get("deploy_status") == "pass" + and result_payload.get("post_deploy_status") == "fail" + ) + + def _read_json_request(environ: dict[str, object]) -> dict[str, object]: body_bytes = _read_request_body(environ) if not body_bytes: diff --git a/tests/test_service.py b/tests/test_service.py index 2a0b3e0..26cfa2e 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -156,6 +156,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_deploy import GenericWebDeployResult 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 ( @@ -13711,6 +13712,92 @@ def test_generic_web_deploy_route_keeps_literal_generic_products_without_post_de deploy.assert_called_once() self.assertIsNone(deploy.call_args.kwargs["post_deploy_executor"]) + def test_generic_web_deploy_route_replays_post_deploy_failure_after_deploy_pass( + 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()) + ) + 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=state_dir, + verifier=_StubVerifier(_identity()), + authz_policy=policy, + control_plane_root_path=root, + ) + driver_result = GenericWebDeployResult( + deployment_record_id="deployment-syo-testing-post-deploy-failed", + deploy_status="pass", + deploy_started_at="2026-05-26T02:00:00Z", + deploy_finished_at="2026-05-26T02:05:00Z", + product="sellyouroutboard", + context="sellyouroutboard-testing", + instance="testing", + target_name="syo-testing", + target_type="application", + target_id="app-syo-testing", + post_deploy_status="fail", + error_message="post-deploy failed after deploy passed", + ) + request_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", + }, + } + + with patch( + "control_plane.service.execute_generic_web_deploy", + return_value=driver_result, + ) as deploy: + first_status_code, first_payload = _invoke_app( + app, + method="POST", + path="/v1/drivers/generic-web/deploy", + payload=request_payload, + headers={"Idempotency-Key": "generic-web-deploy-post-deploy-failed"}, + ) + second_status_code, second_payload = _invoke_app( + app, + method="POST", + path="/v1/drivers/generic-web/deploy", + payload=request_payload, + headers={"Idempotency-Key": "generic-web-deploy-post-deploy-failed"}, + ) + + 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"]["deployment_record_id"], driver_result.deployment_record_id + ) + self.assertTrue(second_payload["replayed"]) + deploy.assert_called_once() + def test_generic_web_deploy_route_rejects_unknown_base_driver_product(self) -> None: with TemporaryDirectory() as temporary_directory_name: root = Path(temporary_directory_name)