"""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