diff --git a/src/flightdeck/server/routes/actions.py b/src/flightdeck/server/routes/actions.py index ad807a3..bc3123e 100644 --- a/src/flightdeck/server/routes/actions.py +++ b/src/flightdeck/server/routes/actions.py @@ -3,7 +3,7 @@ from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel, Field -from flightdeck.operations import OperationError, compute_diff, promote_release, rollback_release +from flightdeck.operations import ActionOutcome, OperationError, compute_diff, promote_release, rollback_release from flightdeck.server.routes.common import ensure_app_state router = APIRouter() @@ -32,6 +32,29 @@ def _raise_bad_request(exc: OperationError) -> HTTPException: return HTTPException(status_code=400, detail=str(exc)) +def _action_body(outcome: ActionOutcome) -> dict[str, object]: + return { + "action_id": outcome.action_id, + "action": outcome.action, + "release_id": outcome.release_id, + "agent_id": outcome.agent_id, + "environment": outcome.environment, + "baseline_release_id": outcome.baseline_release_id, + "promoted_pointer_changed": outcome.promoted_pointer_changed, + "policy": outcome.policy.model_dump(mode="json"), + } + + +def _raise_policy_blocked(action: str, outcome: ActionOutcome) -> HTTPException: + return HTTPException( + status_code=409, + detail={ + "message": f"{action.capitalize()} blocked by policy.", + "outcome": _action_body(outcome), + }, + ) + + def _require_mutation_access(request: Request) -> None: ensure_app_state(request) expected_token: str | None = request.app.state.local_api_token @@ -123,16 +146,10 @@ def post_promote(request: Request, req: ActionRequest) -> dict[str, object]: except OperationError as exc: raise _raise_bad_request(exc) from exc - return { - "action_id": outcome.action_id, - "action": outcome.action, - "release_id": outcome.release_id, - "agent_id": outcome.agent_id, - "environment": outcome.environment, - "baseline_release_id": outcome.baseline_release_id, - "promoted_pointer_changed": outcome.promoted_pointer_changed, - "policy": outcome.policy.model_dump(mode="json"), - } + if not outcome.policy.passed: + raise _raise_policy_blocked("promotion", outcome) + + return _action_body(outcome) @router.post("/v1/rollback") @@ -152,13 +169,7 @@ def post_rollback(request: Request, req: ActionRequest) -> dict[str, object]: except OperationError as exc: raise _raise_bad_request(exc) from exc - return { - "action_id": outcome.action_id, - "action": outcome.action, - "release_id": outcome.release_id, - "agent_id": outcome.agent_id, - "environment": outcome.environment, - "baseline_release_id": outcome.baseline_release_id, - "promoted_pointer_changed": outcome.promoted_pointer_changed, - "policy": outcome.policy.model_dump(mode="json"), - } + if not outcome.policy.passed: + raise _raise_policy_blocked("rollback", outcome) + + return _action_body(outcome) diff --git a/tests/test_sdk_client.py b/tests/test_sdk_client.py index 2a0a5f8..f9045f3 100644 --- a/tests/test_sdk_client.py +++ b/tests/test_sdk_client.py @@ -101,6 +101,37 @@ def handler(request: httpx.Request) -> httpx.Response: assert client.list_releases() == {"releases": []} +def test_flightdeck_client_promote_raises_on_policy_block() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "POST" + assert request.url.path == "/v1/promote" + return httpx.Response( + 409, + json={ + "detail": { + "message": "Promotion blocked by policy.", + "outcome": { + "promoted_pointer_changed": False, + "policy": {"passed": False, "reasons": ["candidate cost exceeds max"]}, + }, + } + }, + ) + + transport = httpx.MockTransport(handler) + with httpx.Client(transport=transport, base_url="http://flightdeck.test") as http: + client = FlightdeckClient("http://flightdeck.test", client=http) + with pytest.raises(httpx.HTTPStatusError) as excinfo: + client.post_promote( + release_id="rel_blocked", + environment="local", + window="7d", + reason="policy check", + ) + + assert excinfo.value.response.status_code == 409 + + def test_flightdeck_client_ingest_batch_chunks_payload() -> None: seen_lengths: list[int] = [] diff --git a/tests/test_server_actions.py b/tests/test_server_actions.py index 87c74ba..25e9a17 100644 --- a/tests/test_server_actions.py +++ b/tests/test_server_actions.py @@ -160,6 +160,40 @@ def test_http_promote_requires_reason(tmp_path: Path) -> None: assert res.status_code == 422 +def test_http_promote_policy_block_returns_conflict_and_keeps_audit(tmp_path: Path) -> None: + ws = tmp_path / "ws_policy_block" + runner, baseline_id, candidate_id = _seed_workspace(ws) + with _cwd(ws): + policy = write_policy(ws, max_cost_per_run_usd=0.0001) + assert runner.invoke(cli, ["policy", "set", str(policy)]).exit_code == 0 + + with TestClient(create_app()) as client: + res = client.post( + "/v1/promote", + json={ + "release_id": candidate_id, + "environment": "local", + "window": "7d", + "reason": "too expensive", + "actor": "http-test", + }, + ) + + assert res.status_code == 409 + detail = res.json()["detail"] + assert detail["message"] == "Promotion blocked by policy." + outcome = detail["outcome"] + assert outcome["promoted_pointer_changed"] is False + assert outcome["policy"]["passed"] is False + + storage = Storage(load_config().db_path) + storage.migrate() + assert storage.get_promoted_release_id("agent_support", "local") == baseline_id + last = storage.list_release_actions(agent_id="agent_support", environment="local")[0] + assert last.release_id == candidate_id + assert last.policy_result.passed is False + + def test_ui_root_serves_vite_index(tmp_path: Path, monkeypatch) -> None: monkeypatch.chdir(tmp_path) assert CliRunner().invoke(cli, ["init"]).exit_code == 0