1247 lines
49 KiB
Python
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
|