intermediate commit
This commit is contained in:
331
tools/release/server/app.py
Normal file
331
tools/release/server/app.py
Normal file
@@ -0,0 +1,331 @@
|
||||
"""FastAPI app for the local GovOPlaN release console."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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,
|
||||
)
|
||||
from govoplan_release.model import to_jsonable
|
||||
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"
|
||||
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"
|
||||
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 = ""
|
||||
|
||||
|
||||
def create_app(*, workspace_root: Path = DEFAULT_WORKSPACE_ROOT, token: str | None = None) -> FastAPI:
|
||||
app = FastAPI(title="GovOPlaN Release Console", version="0.1.0")
|
||||
app.state.workspace_root = workspace_root
|
||||
app.state.token = token
|
||||
|
||||
@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") or request.query_params.get("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/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")
|
||||
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,
|
||||
expires_days=request.expires_days,
|
||||
sequence=request.sequence,
|
||||
check_public=request.check_public,
|
||||
)
|
||||
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,
|
||||
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)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user