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
19 changes: 19 additions & 0 deletions control_plane/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -5618,13 +5618,32 @@ 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:
return not _driver_result_contains_status(driver_result, "pending")
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:
Expand Down
87 changes: 87 additions & 0 deletions tests/test_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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)
Expand Down