fix(release): harden durable run recovery
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user