feat(release): persist resumable run state

This commit is contained in:
2026-07-22 18:30:40 +02:00
parent bd715a8473
commit d0dc916837
5 changed files with 2082 additions and 5 deletions

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
import hashlib
from pathlib import Path
from fastapi import FastAPI, HTTPException, Request
@@ -21,6 +22,12 @@ from govoplan_release import (
tag_repositories,
)
from govoplan_release.model import to_jsonable
from govoplan_release.release_run import (
ReleaseRunConflict,
ReleaseRunCorrupt,
ReleaseRunNotFound,
ReleaseRunStore,
)
from govoplan_release.workspace import DEFAULT_WORKSPACE_ROOT, META_ROOT
@@ -90,10 +97,35 @@ class RepositoryTagRequest(BaseModel):
confirm: str = ""
def create_app(*, workspace_root: Path = DEFAULT_WORKSPACE_ROOT, token: str | None = None) -> FastAPI:
class ReleaseRunCreateRequest(BaseModel):
repo_versions: dict[str, str] = Field(default_factory=dict)
channel: str = Field(default="stable", min_length=1, max_length=64)
online: bool = False
remote_tags: bool = False
public_catalog: bool = True
include_migrations: bool = False
class ReleaseRunCommandRequest(BaseModel):
request_id: str = Field(min_length=8, max_length=128)
reconciled: bool = False
def create_app(
*,
workspace_root: Path = DEFAULT_WORKSPACE_ROOT,
token: str | None = None,
run_state_root: Path | None = None,
) -> FastAPI:
app = FastAPI(title="GovOPlaN Release Console", version="0.1.0")
app.state.workspace_root = workspace_root
app.state.token = token
app.state.release_runs = ReleaseRunStore(
run_state_root
if run_state_root is not None
else default_release_run_root(workspace_root)
)
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]
@@ -202,6 +234,105 @@ def create_app(*, workspace_root: Path = DEFAULT_WORKSPACE_ROOT, token: str | No
)
return to_jsonable(release_plan)
@app.get("/api/release-runs")
def release_runs(limit: int = 20) -> dict[str, object]:
return {"runs": app.state.release_runs.list(limit=limit)}
@app.post("/api/release-runs")
def create_release_run(request: ReleaseRunCreateRequest) -> dict[str, object]:
if not request.repo_versions:
raise HTTPException(
status_code=400, detail="At least one repository version is required"
)
snapshot = build_dashboard(
workspace_root=app.state.workspace_root,
online=request.online,
check_remote_tags=request.online or request.remote_tags,
check_public_catalog=request.public_catalog,
include_migrations=request.include_migrations,
channel=request.channel,
)
plan = build_selective_release_plan(
snapshot,
selected_repos=tuple(request.repo_versions),
repo_versions=dict(request.repo_versions),
channel=request.channel,
)
plan_payload = to_jsonable(plan)
planned_units = plan_payload.get("units")
planned_versions = (
{
unit.get("repo"): unit.get("target_version")
for unit in planned_units
if isinstance(unit, dict)
and isinstance(unit.get("repo"), str)
and isinstance(unit.get("target_version"), str)
}
if isinstance(planned_units, list)
else {}
)
if planned_versions != dict(request.repo_versions) or len(
planned_versions
) != len(planned_units or []):
raise HTTPException(
status_code=409,
detail=(
"The release plan did not resolve exactly the requested repository "
"versions; refresh the dashboard and select known release units."
),
)
try:
return app.state.release_runs.create(
input_snapshot={
"channel": request.channel,
"repo_versions": dict(request.repo_versions),
"online": request.online,
"remote_tags": request.remote_tags,
"public_catalog": request.public_catalog,
"include_migrations": request.include_migrations,
"workspace_fingerprint": app.state.workspace_fingerprint,
},
plan_snapshot=plan_payload,
)
except (ReleaseRunConflict, ReleaseRunCorrupt) as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
@app.get("/api/release-runs/{run_id}")
def release_run(run_id: str) -> dict[str, object]:
try:
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:
raise HTTPException(status_code=409, detail=str(exc)) from exc
@app.post("/api/release-runs/{run_id}/resume")
def resume_release_run(
run_id: str, request: ReleaseRunCommandRequest
) -> dict[str, object]:
try:
return app.state.release_runs.resume(run_id, request_id=request.request_id)
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}/retry")
def retry_release_run_step(
run_id: str, step_id: str, request: ReleaseRunCommandRequest
) -> dict[str, object]:
try:
return app.state.release_runs.retry_step(
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.get("/api/catalog-candidates")
def catalog_candidates() -> dict[str, object]:
return {"candidates": list_catalog_candidates()}
@@ -319,6 +450,22 @@ def create_app(*, workspace_root: Path = DEFAULT_WORKSPACE_ROOT, token: str | No
return 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]}"
def release_workspace_fingerprint(workspace_root: Path) -> str:
canonical = str(workspace_root.expanduser().resolve()).encode("utf-8")
return hashlib.sha256(canonical).hexdigest()
def parse_csv(value: str | None) -> tuple[str, ...]:
if not value:
return ()