fix(release): harden durable run recovery

This commit is contained in:
2026-07-22 18:58:54 +02:00
parent ead697d049
commit 1dc9148ec3
5 changed files with 1046 additions and 127 deletions

View File

@@ -3,7 +3,9 @@
from __future__ import annotations
import hashlib
import os
from pathlib import Path
from typing import Literal
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse
@@ -108,7 +110,12 @@ class ReleaseRunCreateRequest(BaseModel):
class ReleaseRunCommandRequest(BaseModel):
request_id: str = Field(min_length=8, max_length=128)
reconciled: bool = False
class ReleaseRunReconcileRequest(BaseModel):
request_id: str = Field(min_length=8, max_length=128)
outcome: Literal["effect_absent", "effect_succeeded", "unresolved"]
confirm: str = Field(default="", max_length=32)
def create_app(
@@ -120,12 +127,13 @@ def create_app(
app = FastAPI(title="GovOPlaN Release Console", version="0.1.0")
app.state.workspace_root = workspace_root
app.state.token = token
app.state.workspace_fingerprint = release_workspace_fingerprint(workspace_root)
app.state.release_runs = ReleaseRunStore(
run_state_root
if run_state_root is not None
else default_release_run_root(workspace_root)
else default_release_run_root(workspace_root),
expected_workspace_fingerprint=app.state.workspace_fingerprint,
)
app.state.workspace_fingerprint = release_workspace_fingerprint(workspace_root)
@app.middleware("http")
async def require_token(request: Request, call_next): # type: ignore[no-untyped-def]
@@ -236,7 +244,10 @@ def create_app(
@app.get("/api/release-runs")
def release_runs(limit: int = 20) -> dict[str, object]:
return {"runs": app.state.release_runs.list(limit=limit)}
try:
return {"runs": app.state.release_runs.list(limit=limit)}
except (ReleaseRunConflict, ReleaseRunCorrupt) as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
@app.post("/api/release-runs")
def create_release_run(request: ReleaseRunCreateRequest) -> dict[str, object]:
@@ -303,7 +314,7 @@ def create_app(
return app.state.release_runs.get(run_id)
except ReleaseRunNotFound as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except ReleaseRunCorrupt as exc:
except (ReleaseRunConflict, ReleaseRunCorrupt) as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
@app.post("/api/release-runs/{run_id}/resume")
@@ -326,7 +337,23 @@ def create_app(
run_id,
step_id,
request_id=request.request_id,
reconciled=request.reconciled,
)
except ReleaseRunNotFound as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except (ReleaseRunConflict, ReleaseRunCorrupt) as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
@app.post("/api/release-runs/{run_id}/steps/{step_id}/reconcile")
def reconcile_release_run_step(
run_id: str, step_id: str, request: ReleaseRunReconcileRequest
) -> dict[str, object]:
try:
return app.state.release_runs.reconcile_step(
run_id,
step_id,
request_id=request.request_id,
outcome=request.outcome,
confirmation=request.confirm,
)
except ReleaseRunNotFound as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@@ -451,14 +478,18 @@ def create_app(
def default_release_run_root(workspace_root: Path) -> Path:
workspace = workspace_root.expanduser().resolve()
if (workspace / "repositories.json").is_file():
return workspace / "runtime" / "release-runs"
workspace_meta = workspace / "govoplan"
if (workspace_meta / "repositories.json").is_file():
return workspace_meta / "runtime" / "release-runs"
fingerprint = release_workspace_fingerprint(workspace)
return META_ROOT / "runtime" / "release-runs" / f"workspace-{fingerprint[:16]}"
configured_state_home = os.environ.get("XDG_STATE_HOME", "").strip()
state_home = Path(configured_state_home).expanduser()
if not configured_state_home or not state_home.is_absolute():
state_home = Path.home() / ".local" / "state"
fingerprint = release_workspace_fingerprint(workspace_root)
return (
state_home
/ "govoplan"
/ "release-console"
/ f"workspace-{fingerprint}"
/ "release-runs"
)
def release_workspace_fingerprint(workspace_root: Path) -> str: