feat(release): persist resumable run state
This commit is contained in:
@@ -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 ()
|
||||
|
||||
Reference in New Issue
Block a user