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

1247 lines
49 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, field_validator
from govoplan_release import (
build_dashboard,
build_release_intelligence,
build_release_plan,
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.candidate_artifact import (
CandidateArtifactError,
issue_candidate_id,
validate_release_channel,
verify_candidate_receipt,
)
from govoplan_release.release_execution import (
DURABLE_REMOTE,
ReleaseExecutionAmbiguous,
ReleaseExecutionBlocked,
bind_plan_source_states,
execute_repository_step,
executor_spec,
generate_catalog_candidate,
publish_received_candidate,
require_trusted_release_runtime,
reconciled_catalog_publication_receipt,
reconciled_repository_receipt,
validated_candidate_receipt,
verify_catalog_publication_precondition,
verify_repository_preflight_binding,
verify_repository_step_precondition,
verify_release_runtime_binding,
)
from govoplan_release.release_run import (
ReleaseRunConflict,
ReleaseRunCorrupt,
ReleaseRunNotFound,
ReleaseRunStore,
)
from govoplan_release.workspace import DEFAULT_WORKSPACE_ROOT, META_ROOT, website_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
base_keyring: 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
@field_validator("channel")
@classmethod
def validate_channel(cls, value: str) -> str:
return validate_release_channel(value)
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 = ""
@field_validator("channel")
@classmethod
def validate_channel(cls, value: str) -> str:
return validate_release_channel(value)
class RepositoryPushRequest(BaseModel):
repos: list[str] = Field(default_factory=list)
remote: Literal["origin"] = "origin"
apply: bool = False # noqa: A003 - API field mirrors CLI.
confirm: str = ""
class RepositorySyncRequest(BaseModel):
repos: list[str] = Field(default_factory=list)
remote: Literal["origin"] = "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
@field_validator("channel")
@classmethod
def validate_channel(cls, value: str) -> str:
return validate_release_channel(value)
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)
class ReleaseRunExecuteRequest(BaseModel):
request_id: str = Field(min_length=8, max_length=128)
confirm: str = Field(default="", max_length=32)
remote: Literal["origin"] = "origin"
signing_keys: list[str] = Field(default_factory=list, max_length=8)
class ReleaseRunPreviewRequest(BaseModel):
remote: Literal["origin"] = "origin"
def create_app(
*,
workspace_root: Path = DEFAULT_WORKSPACE_ROOT,
token: str | None = None,
run_state_root: Path | None = None,
candidate_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.state.release_candidate_root = (
Path(os.path.abspath(os.fspath(candidate_root.expanduser())))
if candidate_root is not None
else release_run_root.parent / "release-candidates"
)
@app.middleware("http")
async def require_token(request: Request, call_next): # type: ignore[no-untyped-def]
if request.url.path.startswith("/api/"):
if token:
provided = request.headers.get("x-release-console-token")
if provided != token:
return JSONResponse(
{"detail": "release console token required"},
status_code=401,
)
elif request.method not in {"GET", "HEAD", "OPTIONS"}:
return JSONResponse(
{
"detail": (
"release console is read-only without an API token"
)
},
status_code=403,
)
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]:
channel = http_release_channel(channel)
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]:
channel = http_release_channel(channel)
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]:
channel = http_release_channel(channel)
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]:
channel = http_release_channel(channel)
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 release_run_view(existing)
try:
require_trusted_release_runtime(
workspace_root=app.state.workspace_root
)
except ReleaseExecutionBlocked as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
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:
plan_payload = bind_plan_source_states(
plan=plan_payload,
repo_versions=dict(request.repo_versions),
workspace_root=app.state.workspace_root,
)
return release_run_view(
app.state.release_runs.create(
request_id=request.request_id,
input_snapshot=input_snapshot,
plan_snapshot=plan_payload,
)
)
except (
ReleaseExecutionBlocked,
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 release_run_view(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 release_run_view(
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 release_run_view(
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:
receipt = None
run = app.state.release_runs.get(run_id)
state_step = release_run_state_step(run, step_id)
immutable = run.get("immutable")
frozen_plan = (
immutable.get("plan") if isinstance(immutable, dict) else None
)
verify_release_runtime_binding(
expected=(
frozen_plan.get("runtime_binding")
if isinstance(frozen_plan, dict)
else None
),
workspace_root=app.state.workspace_root,
)
if (
request.outcome == "effect_succeeded"
and state_step.get("state") == "succeeded"
and state_step.get("result_code") == "reconciled_effect_succeeded"
):
receipt = state_step.get("result_receipt")
if (
request.outcome == "effect_succeeded"
and step_id == "catalog:selective-generator"
and state_step.get("state") == "interrupted"
):
attempt_fingerprint = (
app.state.release_runs.current_attempt_fingerprint(
run_id, step_id
)
)
candidate_id = issue_candidate_id(
run_id, step_id, attempt_fingerprint
)
candidate_receipt = validated_candidate_receipt(
candidate_root=app.state.release_candidate_root,
candidate_id=candidate_id,
channel=app.state.release_runs.get(run_id)["immutable"]["input"][
"channel"
],
)
receipt = {
"kind": "catalog_candidate",
"candidate_id": candidate_receipt.candidate_id,
"catalog_sha256": candidate_receipt.catalog_sha256,
}
elif (
request.outcome == "effect_succeeded"
and step_id == "catalog:validate-sign-publish"
and state_step.get("state") == "interrupted"
):
plan_step = release_run_plan_step(run, step_id)
candidate_receipt = release_run_candidate_receipt(run)
receipt = reconciled_catalog_publication_receipt(
candidate_path=verified_run_candidate(
run,
candidate_root=app.state.release_candidate_root,
),
candidate_receipt=candidate_receipt,
channel=run["immutable"]["input"]["channel"],
workspace_root=app.state.workspace_root,
remote=DURABLE_REMOTE,
expected_website_receipt=plan_step.get("source_binding"),
)
elif request.outcome == "effect_succeeded" and state_step.get(
"state"
) == "interrupted":
plan_step = release_run_plan_step(run, step_id)
spec = executor_spec(plan_step)
if spec is not None and spec.kind in {"tag", "push"}:
repo = str(plan_step["repo"])
receipt = reconciled_repository_receipt(
spec=spec,
plan_step=plan_step,
version=run["immutable"]["input"]["repo_versions"][repo],
workspace_root=app.state.workspace_root,
expected_receipt=preceding_repository_receipt(
run, step_id=step_id, repo=repo
),
)
return release_run_view(
app.state.release_runs.reconcile_step(
run_id,
step_id,
request_id=request.request_id,
outcome=request.outcome,
confirmation=request.confirm,
result_receipt=receipt,
)
)
except ReleaseRunNotFound as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except (
CandidateArtifactError,
ReleaseExecutionBlocked,
ReleaseRunConflict,
ReleaseRunCorrupt,
) as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
@app.post("/api/release-runs/{run_id}/steps/{step_id}/execute")
def execute_release_run_step(
run_id: str, step_id: str, request: ReleaseRunExecuteRequest
) -> dict[str, object]:
claim_acquired = False
try:
run = app.state.release_runs.get(run_id)
plan_step = release_run_plan_step(run, step_id)
immutable = run.get("immutable")
frozen_plan = (
immutable.get("plan") if isinstance(immutable, dict) else None
)
verify_release_runtime_binding(
expected=(
frozen_plan.get("runtime_binding")
if isinstance(frozen_plan, dict)
else None
),
workspace_root=app.state.workspace_root,
)
spec = executor_spec(plan_step)
if spec is None:
raise ReleaseRunConflict(
"This frozen plan step has no bounded durable executor."
)
if request.confirm != spec.confirmation:
required = spec.confirmation or "an empty confirmation"
raise ReleaseRunConflict(
f"Release step requires exactly {required}."
)
expected_repository_receipt = None
claim = app.state.release_runs.claim_step(
run_id,
step_id,
attempt_id=request.request_id,
)
if not claim.claimed:
if claim.outcome == "succeeded":
replay = release_run_view(claim.run)
replay["execution_result"] = {
"status": "replayed",
"result_code": claim.result_code,
"result_receipt": claim.result_receipt,
}
return replay
if claim.outcome == "running":
raise ReleaseRunConflict(
"This exact executor attempt is still running or its response "
"is uncertain; resume and reconcile it before retrying."
)
raise ReleaseRunConflict(
"This exact executor attempt already ended; use the run's explicit "
"retry or reconciliation action."
)
claim_acquired = True
signing_keys = tuple(request.signing_keys or default_signing_keys())
if spec.kind == "catalog_generate" and not signing_keys:
raise ReleaseExecutionBlocked(
"Durable catalog generation requires a configured signing key."
)
candidate_path = None
candidate_receipt = None
website_receipt = None
if spec.kind == "catalog_publish":
candidate_path = verified_run_candidate(
run,
candidate_root=app.state.release_candidate_root,
)
candidate_receipt = release_run_candidate_receipt(run)
website_receipt = verify_catalog_publication_precondition(
plan_step=plan_step,
workspace_root=app.state.workspace_root,
)
if spec.kind in {"preflight", "tag", "push"}:
repo = str(plan_step["repo"])
version = run["immutable"]["input"]["repo_versions"][repo]
expected_repository_receipt = preceding_repository_receipt(
run, step_id=step_id, repo=repo
)
if spec.kind == "preflight":
verify_repository_preflight_binding(
plan_step=plan_step,
version=version,
workspace_root=app.state.workspace_root,
)
else:
verify_repository_step_precondition(
spec=spec,
plan_step=plan_step,
version=version,
workspace_root=app.state.workspace_root,
expected_receipt=expected_repository_receipt,
)
receipt = None
if spec.kind in {"preflight", "tag", "push"}:
result, receipt = execute_repository_step(
spec=spec,
plan_step=plan_step,
repo_versions=run["immutable"]["input"]["repo_versions"],
workspace_root=app.state.workspace_root,
remote=DURABLE_REMOTE,
expected_receipt=expected_repository_receipt,
)
elif spec.kind == "catalog_generate":
attempt_fingerprint = (
app.state.release_runs.current_attempt_fingerprint(
run_id, step_id
)
)
candidate_id = issue_candidate_id(
run_id, step_id, attempt_fingerprint
)
base_catalog, base_keyring = release_catalog_base_paths(
app.state.workspace_root,
channel=run["immutable"]["input"]["channel"],
)
result, candidate_receipt = generate_catalog_candidate(
candidate_root=app.state.release_candidate_root,
candidate_id=candidate_id,
channel=run["immutable"]["input"]["channel"],
repo_versions=run["immutable"]["input"]["repo_versions"],
workspace_root=app.state.workspace_root,
signing_keys=signing_keys,
remote=DURABLE_REMOTE,
check_public=run["immutable"]["input"]["public_catalog"],
source_receipts={
repo: receipt
for repo in run["immutable"]["input"]["repo_versions"]
if isinstance(
(
receipt := preceding_repository_receipt(
run, step_id=step_id, repo=repo
)
),
dict,
)
},
base_catalog=base_catalog,
base_keyring=base_keyring,
)
receipt = {
"kind": "catalog_candidate",
"candidate_id": candidate_receipt.candidate_id,
"catalog_sha256": candidate_receipt.catalog_sha256,
}
elif (
spec.kind == "catalog_publish"
and candidate_path is not None
and candidate_receipt is not None
and website_receipt is not None
):
candidate_path = verified_run_candidate(
run,
candidate_root=app.state.release_candidate_root,
)
result, receipt = publish_received_candidate(
candidate_path=candidate_path,
candidate_receipt=candidate_receipt,
channel=run["immutable"]["input"]["channel"],
workspace_root=app.state.workspace_root,
remote=DURABLE_REMOTE,
expected_website_receipt=website_receipt,
)
try:
verified_run_candidate(
run,
candidate_root=app.state.release_candidate_root,
)
except CandidateArtifactError as exc:
raise ReleaseExecutionAmbiguous(
"Published candidate changed while its effect was in flight; "
"reconcile the immutable website commit and remote tag."
) from exc
else:
raise ReleaseRunConflict("Release executor mapping is incomplete.")
finished = app.state.release_runs.finish_step(
run_id,
step_id,
attempt_id=request.request_id,
succeeded=True,
result_code=spec.result_code,
result_receipt=receipt,
)
response = release_run_view(finished)
response["execution_result"] = {
"status": "succeeded",
"result_code": spec.result_code,
"result_receipt": receipt,
"output": result,
}
return response
except ReleaseExecutionBlocked as exc:
if not claim_acquired:
raise HTTPException(status_code=409, detail=str(exc)[:4000]) from exc
failed = app.state.release_runs.finish_step(
run_id,
step_id,
attempt_id=request.request_id,
succeeded=False,
result_code="executor_blocked",
)
response = release_run_view(failed)
response["execution_result"] = {
"status": "failed",
"result_code": "executor_blocked",
"detail": str(exc)[:4000],
}
return response
except ReleaseExecutionAmbiguous as exc:
app.state.release_runs.interrupt_step(
run_id,
step_id,
attempt_id=request.request_id,
)
raise HTTPException(status_code=409, detail=str(exc)[:4000]) from exc
except ReleaseRunNotFound as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except CandidateArtifactError as exc:
if claim_acquired:
failed = app.state.release_runs.finish_step(
run_id,
step_id,
attempt_id=request.request_id,
succeeded=False,
result_code="candidate_invalid",
)
response = release_run_view(failed)
response["execution_result"] = {
"status": "failed",
"result_code": "candidate_invalid",
"detail": str(exc)[:4000],
}
return response
raise HTTPException(status_code=409, detail=str(exc)) from exc
except ReleaseRunCorrupt as exc:
if claim_acquired:
try:
app.state.release_runs.interrupt_step(
run_id,
step_id,
attempt_id=request.request_id,
)
except (ReleaseRunConflict, ReleaseRunCorrupt, ReleaseRunNotFound):
pass
raise HTTPException(
status_code=409,
detail=(
"The executor outcome could not be persisted safely; resume "
"and reconcile this attempt before retrying."
),
) from exc
raise HTTPException(status_code=409, detail=str(exc)) from exc
except ReleaseRunConflict as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except Exception as exc: # noqa: BLE001 - never retry an uncertain effect.
try:
app.state.release_runs.interrupt_step(
run_id,
step_id,
attempt_id=request.request_id,
)
except (ReleaseRunConflict, ReleaseRunCorrupt, ReleaseRunNotFound):
pass
raise HTTPException(
status_code=409,
detail=(
f"Executor raised {type(exc).__name__}; its effect is unknown. "
"Resume and reconcile before retry."
),
) from exc
@app.post("/api/release-runs/{run_id}/steps/{step_id}/preview")
def preview_release_run_step(
run_id: str, step_id: str, request: ReleaseRunPreviewRequest
) -> dict[str, object]:
try:
run = app.state.release_runs.get(run_id)
plan_step = release_run_plan_step(run, step_id)
spec = executor_spec(plan_step)
if spec is None or spec.kind != "catalog_publish":
raise ReleaseRunConflict(
"Only the receipt-bound catalog publication step has this preview."
)
candidate = verified_run_candidate(
run,
candidate_root=app.state.release_candidate_root,
)
return to_jsonable(
publish_catalog_candidate(
candidate_dir=candidate,
workspace_root=app.state.workspace_root,
channel=run["immutable"]["input"]["channel"],
remote=DURABLE_REMOTE,
source_remote=DURABLE_REMOTE,
apply=False,
)
)
except ReleaseRunNotFound as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except (
CandidateArtifactError,
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.state.release_candidate_root)
}
@app.post("/api/catalog-candidates")
def catalog_candidate(request: CatalogCandidateRequest) -> dict[str, object]:
del request
raise HTTPException(
status_code=409,
detail=(
"Signed catalog generation is available only through a durable "
"release run with a trusted runtime and receipt-bound source tags."
),
)
@app.post("/api/catalog-candidates/publish")
def publish_candidate(request: PublishCandidateRequest) -> dict[str, object]:
if request.apply or request.commit or request.tag or request.push or request.build_web:
raise HTTPException(
status_code=409,
detail=(
"Catalog mutation is available only through a durable release run "
"with a server-issued candidate receipt."
),
)
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:
raise HTTPException(
status_code=409,
detail=(
"Repository push mutation is disabled outside a durable "
"receipt-bound maintenance run."
),
)
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:
raise HTTPException(
status_code=409,
detail=(
"Repository sync mutation is disabled outside a durable "
"receipt-bound maintenance run."
),
)
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:
raise HTTPException(
status_code=409,
detail=(
"Release preparation mutation is disabled outside a durable "
"release executor; use preview and commit reviewed changes "
"outside this console for now."
),
)
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:
raise HTTPException(
status_code=409,
detail=(
"Release tag mutation is available only through a durable "
"release run with creation-time repository bindings."
),
)
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 release_run_view(run: dict[str, object]) -> dict[str, object]:
state = run.get("state")
immutable = run.get("immutable")
plan = immutable.get("plan") if isinstance(immutable, dict) else None
steps = state.get("steps", []) if isinstance(state, dict) else []
plan_steps = plan.get("dry_run_steps", []) if isinstance(plan, dict) else []
if isinstance(steps, list) and isinstance(plan_steps, list):
plan_by_id = {
step.get("id"): step for step in plan_steps if isinstance(step, dict)
}
for step in steps:
if not isinstance(step, dict):
continue
plan_step = plan_by_id.get(step.get("id"))
spec = executor_spec(plan_step) if isinstance(plan_step, dict) else None
step["executor"] = {
"available": spec is not None,
"kind": spec.kind if spec is not None else None,
"confirmation": spec.confirmation if spec is not None else None,
}
return run
def release_run_plan_step(
run: dict[str, object], step_id: str
) -> dict[str, object]:
immutable = run.get("immutable")
plan = immutable.get("plan") if isinstance(immutable, dict) else None
steps = plan.get("dry_run_steps") if isinstance(plan, dict) else None
if not isinstance(steps, list):
raise ReleaseRunCorrupt("Release run plan steps are unavailable.")
for step in steps:
if isinstance(step, dict) and step.get("id") == step_id:
return step
raise ReleaseRunNotFound("Release run step was not found.")
def release_run_state_step(
run: dict[str, object], step_id: str
) -> dict[str, object]:
state = run.get("state")
steps = state.get("steps") if isinstance(state, dict) else None
if not isinstance(steps, list):
raise ReleaseRunCorrupt("Release run state steps are unavailable.")
for step in steps:
if isinstance(step, dict) and step.get("id") == step_id:
return step
raise ReleaseRunNotFound("Release run step was not found.")
def preceding_repository_receipt(
run: dict[str, object], *, step_id: str, repo: str
) -> dict[str, object] | None:
immutable = run.get("immutable")
plan = immutable.get("plan") if isinstance(immutable, dict) else None
plan_steps = plan.get("dry_run_steps") if isinstance(plan, dict) else None
state = run.get("state")
state_steps = state.get("steps") if isinstance(state, dict) else None
if not isinstance(plan_steps, list) or not isinstance(state_steps, list):
raise ReleaseRunCorrupt("Release run repository receipt chain is unavailable.")
pairs = list(zip(plan_steps, state_steps, strict=True))
current_index = next(
(
index
for index, (planned, _mutable) in enumerate(pairs)
if isinstance(planned, dict) and planned.get("id") == step_id
),
None,
)
if current_index is None:
raise ReleaseRunNotFound("Release run step was not found.")
for planned, mutable in reversed(pairs[:current_index]):
if not isinstance(planned, dict) or planned.get("repo") != repo:
continue
if not isinstance(mutable, dict) or mutable.get("state") != "succeeded":
return None
receipt = mutable.get("result_receipt")
return (
receipt
if isinstance(receipt, dict)
and receipt.get("kind") == "repository_state"
and receipt.get("repo") == repo
else None
)
return None
def verified_run_candidate(
run: dict[str, object], *, candidate_root: Path
) -> Path:
receipt, channel = release_run_candidate_receipt(run, include_channel=True)
return verify_candidate_receipt(
root=candidate_root,
candidate_id=str(receipt.get("candidate_id") or ""),
catalog_sha256=str(receipt.get("catalog_sha256") or ""),
channel=channel,
)
def release_run_candidate_receipt(
run: dict[str, object], *, include_channel: bool = False
) -> dict[str, object] | tuple[dict[str, object], str]:
state = run.get("state")
steps = state.get("steps") if isinstance(state, dict) else None
input_snapshot = (
run.get("immutable", {}).get("input", {})
if isinstance(run.get("immutable"), dict)
else {}
)
channel = input_snapshot.get("channel") if isinstance(input_snapshot, dict) else None
if not isinstance(steps, list) or not isinstance(channel, str):
raise ReleaseRunCorrupt("Release run candidate dependency is unavailable.")
generator = next(
(
step
for step in steps
if isinstance(step, dict)
and step.get("id") == "catalog:selective-generator"
),
None,
)
receipt = generator.get("result_receipt") if isinstance(generator, dict) else None
if (
not isinstance(generator, dict)
or generator.get("state") != "succeeded"
or not isinstance(receipt, dict)
or receipt.get("kind") != "catalog_candidate"
):
raise ReleaseRunConflict(
"Catalog publication requires the successful generator's durable receipt."
)
copied = dict(receipt)
return (copied, channel) if include_channel else copied
def default_release_candidate_root(workspace_root: Path) -> Path:
return default_release_run_root(workspace_root).parent / "release-candidates"
def release_catalog_base_paths(
workspace_root: Path, *, channel: str
) -> tuple[Path, Path]:
"""Resolve durable generation trust material only from the website checkout."""
catalog_root = website_root(workspace_root) / "public" / "catalogs" / "v1"
return (
catalog_root / "channels" / f"{validate_release_channel(channel)}.json",
catalog_root / "keyring.json",
)
def http_release_channel(value: str) -> str:
try:
return validate_release_channel(value)
except CandidateArtifactError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
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(root: Path | None = None) -> list[dict[str, object]]:
root = root or (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