Files
govoplan/tools/release/server/app.py

575 lines
22 KiB
Python

"""FastAPI app for the local GovOPlaN release console."""
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
from pydantic import BaseModel, Field
from govoplan_release import (
build_dashboard,
build_release_intelligence,
build_release_plan,
build_selective_catalog_candidate,
build_selective_release_plan,
publish_catalog_candidate,
prepare_repositories,
push_repositories,
sync_repositories,
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
class CatalogCandidateRequest(BaseModel):
repo_versions: dict[str, str] = Field(default_factory=dict)
repos: list[str] = Field(default_factory=list)
target_version: str | None = None
channel: str = "stable"
signing_keys: list[str] = Field(default_factory=list)
output_dir: str | None = None
base_catalog: str | None = None
expires_days: int = 90
sequence: int | None = None
public_base_url: str = "https://govoplan.add-ideas.de"
repository_base: str = "git+ssh://git@git.add-ideas.de/add-ideas"
source_remote: str = "origin"
check_public: bool = True
class PublishCandidateRequest(BaseModel):
candidate_dir: str
channel: str = "stable"
web_root: str | None = None
apply: bool = False # noqa: A003 - API field mirrors CLI.
build_web: bool = False
commit: bool = False
tag: bool = False
push: bool = False
remote: str = "origin"
source_remote: str = "origin"
branch: str | None = None
tag_name: str | None = None
npm: str = "npm"
allow_dirty_website: bool = False
confirm: str = ""
class RepositoryPushRequest(BaseModel):
repos: list[str] = Field(default_factory=list)
remote: str = "origin"
apply: bool = False # noqa: A003 - API field mirrors CLI.
confirm: str = ""
class RepositorySyncRequest(BaseModel):
repos: list[str] = Field(default_factory=list)
remote: str = "origin"
apply: bool = False # noqa: A003 - API field mirrors CLI.
confirm: str = ""
class RepositoryPrepareRequest(BaseModel):
repos: list[str] = Field(default_factory=list)
repo_versions: dict[str, str] = Field(default_factory=dict)
message: str | None = None
apply: bool = False # noqa: A003 - API field mirrors CLI.
confirm: str = ""
class RepositoryTagRequest(BaseModel):
repos: list[str] = Field(default_factory=list)
repo_versions: dict[str, str] = Field(default_factory=dict)
remote: str = "origin"
message: str | None = None
apply: bool = False # noqa: A003 - API field mirrors CLI.
push: bool = False
confirm: str = ""
class ReleaseRunCreateRequest(BaseModel):
request_id: str = Field(min_length=8, max_length=128)
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)
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(
*,
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.workspace_fingerprint = release_workspace_fingerprint(workspace_root)
release_run_root = (
workspace_release_run_root(
run_state_root, app.state.workspace_fingerprint
)
if run_state_root is not None
else default_release_run_root(workspace_root)
)
app.state.release_runs = ReleaseRunStore(
release_run_root,
expected_workspace_fingerprint=app.state.workspace_fingerprint,
)
@app.middleware("http")
async def require_token(request: Request, call_next): # type: ignore[no-untyped-def]
if token and request.url.path.startswith("/api/"):
provided = request.headers.get("x-release-console-token")
if provided != token:
return JSONResponse({"detail": "release console token required"}, status_code=401)
return await call_next(request)
@app.get("/", response_class=HTMLResponse)
def index() -> HTMLResponse:
index_path = Path(__file__).resolve().parents[1] / "webui" / "index.html"
if not index_path.exists():
raise HTTPException(status_code=500, detail="release console UI is missing")
return HTMLResponse(index_path.read_text(encoding="utf-8"), headers={"Cache-Control": "no-store"})
@app.get("/api/health")
def health() -> dict[str, object]:
return {
"ok": True,
"workspace_root": str(app.state.workspace_root),
"token_required": bool(app.state.token),
}
@app.get("/api/dashboard")
def dashboard(
target_version: str | None = None,
online: bool = False,
remote_tags: bool = False,
public_catalog: bool = True,
include_migrations: bool = False,
include_website: bool = False,
channel: str = "stable",
) -> dict[str, object]:
snapshot = build_dashboard(
workspace_root=app.state.workspace_root,
target_version=target_version,
online=online,
check_remote_tags=online or remote_tags,
check_public_catalog=public_catalog,
include_migrations=include_migrations,
include_website=include_website,
channel=channel,
)
return to_jsonable(snapshot)
@app.get("/api/plan")
def plan(
target_version: str | None = None,
online: bool = False,
remote_tags: bool = False,
public_catalog: bool = True,
include_migrations: bool = False,
channel: str = "stable",
) -> dict[str, object]:
snapshot = build_dashboard(
workspace_root=app.state.workspace_root,
target_version=target_version,
online=online,
check_remote_tags=online or remote_tags,
check_public_catalog=public_catalog,
include_migrations=include_migrations,
channel=channel,
)
release_plan = build_release_plan(snapshot)
return to_jsonable(release_plan)
@app.get("/api/release-intelligence")
def release_intelligence(
channel: str = "stable",
public_catalog: bool = True,
) -> dict[str, object]:
intelligence = build_release_intelligence(
workspace_root=app.state.workspace_root,
channel=channel,
prefer_public=public_catalog,
)
return to_jsonable(intelligence)
@app.get("/api/selective-plan")
def selective_plan(
repos: str | None = None,
target_version: str | None = None,
repo_versions: str | None = None,
online: bool = False,
remote_tags: bool = False,
public_catalog: bool = True,
include_migrations: bool = False,
channel: str = "stable",
) -> dict[str, object]:
snapshot = build_dashboard(
workspace_root=app.state.workspace_root,
target_version=target_version,
online=online,
check_remote_tags=online or remote_tags,
check_public_catalog=public_catalog,
include_migrations=include_migrations,
channel=channel,
)
release_plan = build_selective_release_plan(
snapshot,
selected_repos=parse_csv(repos),
target_version=target_version,
repo_versions=parse_repo_versions(repo_versions),
channel=channel,
)
return to_jsonable(release_plan)
@app.get("/api/release-runs")
def release_runs(
limit: int = 20, cursor: str | None = None
) -> dict[str, object]:
try:
return app.state.release_runs.list_page(limit=limit, cursor=cursor)
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]:
if not request.repo_versions:
raise HTTPException(
status_code=400, detail="At least one repository version is required"
)
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,
}
try:
existing = app.state.release_runs.find_created_run(
request_id=request.request_id,
input_snapshot=input_snapshot,
)
except (ReleaseRunConflict, ReleaseRunCorrupt) as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
if existing is not None:
return existing
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(
request_id=request.request_id,
input_snapshot=input_snapshot,
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 (ReleaseRunConflict, 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,
)
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
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()}
@app.post("/api/catalog-candidates")
def catalog_candidate(request: CatalogCandidateRequest) -> dict[str, object]:
repo_versions = dict(request.repo_versions)
if not repo_versions and request.repos and request.target_version:
repo_versions = {repo: request.target_version for repo in request.repos}
if not repo_versions:
raise HTTPException(status_code=400, detail="repo_versions or repos with target_version is required")
signing_keys = tuple(request.signing_keys or default_signing_keys())
if not signing_keys:
raise HTTPException(status_code=400, detail="No signing key supplied and default release key was not found")
try:
candidate = build_selective_catalog_candidate(
repo_versions=repo_versions,
channel=request.channel,
workspace_root=app.state.workspace_root,
output_dir=request.output_dir,
base_catalog=request.base_catalog,
signing_keys=signing_keys,
public_base_url=request.public_base_url,
repository_base=request.repository_base,
source_remote=request.source_remote,
expires_days=request.expires_days,
sequence=request.sequence,
check_public=request.check_public,
)
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
return to_jsonable(candidate)
@app.post("/api/catalog-candidates/publish")
def publish_candidate(request: PublishCandidateRequest) -> dict[str, object]:
if request.push and request.confirm != "PUSH":
raise HTTPException(status_code=400, detail="Push requires confirm=PUSH")
if (request.apply or request.commit or request.tag or request.build_web) and not request.push and request.confirm != "APPLY":
raise HTTPException(status_code=400, detail="Apply/commit/tag/build requires confirm=APPLY")
result = publish_catalog_candidate(
candidate_dir=request.candidate_dir,
workspace_root=app.state.workspace_root,
web_root=request.web_root,
channel=request.channel,
apply=request.apply,
build_web=request.build_web,
commit=request.commit,
tag=request.tag,
push=request.push,
remote=request.remote,
source_remote=request.source_remote,
branch=request.branch,
tag_name=request.tag_name,
npm=request.npm,
allow_dirty_website=request.allow_dirty_website,
)
return to_jsonable(result)
@app.post("/api/repositories/push")
def repository_push(request: RepositoryPushRequest) -> dict[str, object]:
repos = tuple(dict.fromkeys(repo.strip() for repo in request.repos if repo.strip()))
if not repos:
raise HTTPException(status_code=400, detail="At least one repository must be selected")
if request.apply and request.confirm != "PUSH":
raise HTTPException(status_code=400, detail="Repository push requires confirm=PUSH")
result = push_repositories(repos=repos, workspace_root=app.state.workspace_root, remote=request.remote, apply=request.apply)
return to_jsonable(result)
@app.post("/api/repositories/sync")
def repository_sync(request: RepositorySyncRequest) -> dict[str, object]:
repos = tuple(dict.fromkeys(repo.strip() for repo in request.repos if repo.strip()))
if not repos:
raise HTTPException(status_code=400, detail="At least one repository must be selected")
if request.apply and request.confirm != "SYNC":
raise HTTPException(status_code=400, detail="Repository sync requires confirm=SYNC")
result = sync_repositories(repos=repos, workspace_root=app.state.workspace_root, remote=request.remote, apply=request.apply)
return to_jsonable(result)
@app.post("/api/repositories/prepare")
def repository_prepare(request: RepositoryPrepareRequest) -> dict[str, object]:
repos = tuple(dict.fromkeys(repo.strip() for repo in request.repos if repo.strip()))
if not repos:
raise HTTPException(status_code=400, detail="At least one repository must be selected")
if request.apply and request.confirm != "COMMIT":
raise HTTPException(status_code=400, detail="Repository prepare requires confirm=COMMIT")
result = prepare_repositories(
repos=repos,
repo_versions=request.repo_versions,
message=request.message,
workspace_root=app.state.workspace_root,
apply=request.apply,
)
return to_jsonable(result)
@app.post("/api/repositories/tag")
def repository_tag(request: RepositoryTagRequest) -> dict[str, object]:
repos = tuple(dict.fromkeys(repo.strip() for repo in request.repos if repo.strip()))
if not repos:
raise HTTPException(status_code=400, detail="At least one repository must be selected")
if request.apply and request.push and request.confirm != "PUBLISH":
raise HTTPException(status_code=400, detail="Release tag publication requires confirm=PUBLISH")
if request.apply and not request.push and request.confirm != "TAG":
raise HTTPException(status_code=400, detail="Release tag creation requires confirm=TAG")
result = tag_repositories(
repos=repos,
repo_versions=request.repo_versions,
message=request.message,
workspace_root=app.state.workspace_root,
remote=request.remote,
apply=request.apply,
push=request.push,
)
return to_jsonable(result)
return app
def default_release_run_root(workspace_root: Path) -> Path:
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 workspace_release_run_root(state_root: Path, workspace_fingerprint: str) -> Path:
return (
state_root.expanduser()
/ f"workspace-{workspace_fingerprint}"
/ "release-runs"
)
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 ()
return tuple(item.strip() for item in value.split(",") if item.strip())
def parse_repo_versions(value: str | None) -> dict[str, str]:
if not value:
return {}
result: dict[str, str] = {}
for item in value.split(","):
if ":" not in item:
continue
repo, version = item.split(":", 1)
repo = repo.strip()
version = version.strip()
if repo and version:
result[repo] = version
return result
def default_signing_keys() -> tuple[str, ...]:
key_path = Path.home() / ".config" / "govoplan" / "release-keys" / "release-key-1.pem"
if not key_path.exists():
return ()
return (f"release-key-1={key_path}",)
def list_catalog_candidates() -> list[dict[str, object]]:
root = META_ROOT / "runtime" / "release-candidates"
if not root.exists():
return []
candidates: list[dict[str, object]] = []
for path in sorted((item for item in root.iterdir() if item.is_dir()), reverse=True):
summary = path / "summary.json"
if summary.exists():
try:
import json
payload = json.loads(summary.read_text(encoding="utf-8"))
if isinstance(payload, dict):
payload.setdefault("candidate_dir", str(path))
candidates.append(payload)
continue
except json.JSONDecodeError:
pass
candidates.append({"candidate_dir": str(path), "status": "unknown"})
return candidates