"""FastAPI app for the local GovOPlaN release console.""" from __future__ import annotations import hashlib from pathlib import Path 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): 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] 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) -> 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()} @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: 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 () 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