f7bd466f31
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
186 lines
7.0 KiB
Python
186 lines
7.0 KiB
Python
"""§22.2 project registry mirror — the config-side analogue of
|
|
cache.refresh_meta_repo.
|
|
|
|
A deployment declares its projects in a `projects.yaml` at the root of
|
|
REGISTRY_REPO. This module mirrors that file into the `projects` cache table
|
|
and the `deployment` singleton. Per §22.2, `projects` rows flow from the
|
|
registry only — never from user actions. The mirror runs on the registry-repo
|
|
webhook and on every reconciler sweep (Option A wiring, Task 5).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import json
|
|
import logging
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
|
|
import yaml
|
|
|
|
from . import db
|
|
from .config import Config
|
|
from .gitea import Gitea
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
VALID_TYPES = {"document", "specification", "bdd"}
|
|
VALID_VISIBILITY = {"gated", "public", "unlisted"}
|
|
VALID_INITIAL_STATE = {"super-draft", "active"}
|
|
# §22.4b: per-type default landing state.
|
|
_TYPE_DEFAULT_INITIAL_STATE = {
|
|
"document": "super-draft",
|
|
"specification": "super-draft",
|
|
"bdd": "active",
|
|
}
|
|
_SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$")
|
|
|
|
|
|
class RegistryError(Exception):
|
|
"""A registry document that fails validation, or a missing registry file.
|
|
|
|
Raised by parse_registry/refresh_registry. The caller decides severity:
|
|
fatal at startup (no last-good to serve), tolerated on a running deployment
|
|
(keep the last-good projects rows). See Task 5 wiring.
|
|
"""
|
|
|
|
|
|
@dataclass
|
|
class ProjectEntry:
|
|
id: str
|
|
name: str
|
|
type: str
|
|
content_repo: str
|
|
visibility: str
|
|
initial_state: str
|
|
config: dict = field(default_factory=dict) # theme, enabled_models
|
|
|
|
|
|
@dataclass
|
|
class RegistryDoc:
|
|
deployment_name: str
|
|
deployment_tagline: str
|
|
projects: list[ProjectEntry]
|
|
|
|
|
|
def parse_registry(text: str) -> RegistryDoc:
|
|
"""Parse + validate projects.yaml. Pure (no I/O). Raises RegistryError."""
|
|
raw = yaml.safe_load(text) or {}
|
|
dep = raw.get("deployment") or {}
|
|
projects_raw = raw.get("projects") or []
|
|
if not isinstance(projects_raw, list) or not projects_raw:
|
|
raise RegistryError("registry must declare at least one project")
|
|
seen: set[str] = set()
|
|
entries: list[ProjectEntry] = []
|
|
for p in projects_raw:
|
|
if not isinstance(p, dict):
|
|
raise RegistryError(f"each project entry must be a mapping, got {type(p).__name__}")
|
|
pid = str(p.get("id") or "").strip()
|
|
if not _SLUG_RE.match(pid):
|
|
raise RegistryError(f"project id {pid!r} is not a valid slug")
|
|
if pid in seen:
|
|
raise RegistryError(f"duplicate project id {pid!r}")
|
|
seen.add(pid)
|
|
name = str(p.get("name") or "").strip()
|
|
if not name:
|
|
raise RegistryError(f"project {pid!r} missing name")
|
|
ptype = str(p.get("type") or "").strip()
|
|
if ptype not in VALID_TYPES:
|
|
raise RegistryError(f"project {pid!r} has invalid type {ptype!r}")
|
|
content_repo = str(p.get("content_repo") or "").strip()
|
|
if not content_repo:
|
|
raise RegistryError(f"project {pid!r} missing content_repo")
|
|
vis = str(p.get("visibility") or "gated").strip()
|
|
if vis not in VALID_VISIBILITY:
|
|
raise RegistryError(f"project {pid!r} has invalid visibility {vis!r}")
|
|
initial_state = str(
|
|
p.get("initial_state") or _TYPE_DEFAULT_INITIAL_STATE[ptype]
|
|
).strip()
|
|
if initial_state not in VALID_INITIAL_STATE:
|
|
raise RegistryError(
|
|
f"project {pid!r} has invalid initial_state {initial_state!r}"
|
|
)
|
|
cfg: dict = {}
|
|
if p.get("theme") is not None:
|
|
cfg["theme"] = p["theme"]
|
|
if p.get("enabled_models") is not None:
|
|
cfg["enabled_models"] = [str(m) for m in p["enabled_models"]]
|
|
entries.append(
|
|
ProjectEntry(pid, name, ptype, content_repo, vis, initial_state, cfg)
|
|
)
|
|
return RegistryDoc(
|
|
deployment_name=str(dep.get("name") or "").strip(),
|
|
deployment_tagline=str(dep.get("tagline") or "").strip(),
|
|
projects=entries,
|
|
)
|
|
|
|
|
|
def apply_registry(doc: RegistryDoc, registry_sha: str) -> None:
|
|
"""Upsert the parsed registry into projects + deployment. Idempotent.
|
|
|
|
§22.4a: `type` is immutable — a change against an existing row is rejected
|
|
(skip + log), never applied. Projects absent from the registry are left in
|
|
place (archival is out of scope for M3; they simply stop refreshing).
|
|
"""
|
|
with db.tx() as conn:
|
|
for e in doc.projects:
|
|
existing = conn.execute(
|
|
"SELECT type FROM projects WHERE id = ?", (e.id,)
|
|
).fetchone()
|
|
if existing is not None and existing["type"] != e.type:
|
|
log.error(
|
|
"registry: refusing immutable type change on project %s (%s -> %s)",
|
|
e.id, existing["type"], e.type,
|
|
)
|
|
continue
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO projects
|
|
(id, name, type, content_repo, visibility, initial_state,
|
|
config_json, registry_sha, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
|
ON CONFLICT(id) DO UPDATE SET
|
|
name = excluded.name,
|
|
type = excluded.type,
|
|
content_repo = excluded.content_repo,
|
|
visibility = excluded.visibility,
|
|
initial_state = excluded.initial_state,
|
|
config_json = excluded.config_json,
|
|
registry_sha = excluded.registry_sha,
|
|
updated_at = datetime('now')
|
|
""",
|
|
(
|
|
e.id, e.name, e.type, e.content_repo, e.visibility,
|
|
e.initial_state, json.dumps(e.config), registry_sha,
|
|
),
|
|
)
|
|
conn.execute(
|
|
"""
|
|
UPDATE deployment
|
|
SET name = ?, tagline = ?, registry_sha = ?, updated_at = datetime('now')
|
|
WHERE id = 1
|
|
""",
|
|
(doc.deployment_name, doc.deployment_tagline, registry_sha),
|
|
)
|
|
|
|
|
|
async def refresh_registry(config: Config, gitea: Gitea) -> None:
|
|
"""Mirror REGISTRY_REPO/projects.yaml into projects + deployment.
|
|
|
|
Idempotent. Raises RegistryError on a missing/invalid file and GiteaError
|
|
on transport failure; the caller chooses fatal-vs-tolerated.
|
|
"""
|
|
item = await gitea.get_contents(
|
|
config.gitea_org, config.registry_repo, "projects.yaml", ref="main"
|
|
)
|
|
if not item or item.get("type") != "file":
|
|
raise RegistryError(
|
|
f"{config.gitea_org}/{config.registry_repo}/projects.yaml not found"
|
|
)
|
|
text = base64.b64decode(item["content"]).decode("utf-8")
|
|
# Prefer the file's last commit sha for provenance (production Gitea
|
|
# includes it on the contents response); fall back to the blob sha.
|
|
sha = item.get("last_commit_sha") or item.get("sha") or ""
|
|
doc = parse_registry(text)
|
|
apply_registry(doc, sha)
|
|
log.info("registry: mirrored %d project(s) at %s", len(doc.projects), sha)
|