From 27a0a0443bf390cf05efb647e00c1a5c813f01c5 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Wed, 3 Jun 2026 22:47:19 -0700 Subject: [PATCH] =?UTF-8?q?feat(registry):=20=C2=A722.2=20projects.yaml=20?= =?UTF-8?q?parse/validate/apply=20module?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/registry.py | 181 +++++++++++++++++++++++++++++++++ backend/tests/test_registry.py | 94 +++++++++++++++++ 2 files changed, 275 insertions(+) create mode 100644 backend/app/registry.py create mode 100644 backend/tests/test_registry.py diff --git a/backend/app/registry.py b/backend/app/registry.py new file mode 100644 index 0000000..1dfee01 --- /dev/null +++ b/backend/app/registry.py @@ -0,0 +1,181 @@ +"""§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-]*$") + + +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: + 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). + """ + conn = db.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"] not in (None, "", 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") + 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) diff --git a/backend/tests/test_registry.py b/backend/tests/test_registry.py new file mode 100644 index 0000000..be1ecf8 --- /dev/null +++ b/backend/tests/test_registry.py @@ -0,0 +1,94 @@ +"""§22.2 registry parse + apply: validation, type-immutability, upsert.""" +from __future__ import annotations + +import tempfile +from pathlib import Path + +import pytest + +from app import db, registry +from app.config import Config + + +def _db(): + cfg = Config( + gitea_url="x", gitea_bot_user="x", gitea_bot_token="x", gitea_org="x", + meta_repo="meta", registry_repo="registry", oauth_client_id="x", + oauth_client_secret="x", app_url="x", secret_key="x", + database_path=Path(tempfile.mkdtemp(prefix="reg-")) / "t.db", + owner_gitea_login="x", webhook_secret="x", + ) + db.run_migrations(cfg) + if db._CONN is not None: + db._CONN.close() + db._CONN = None + db.init(cfg) + return cfg + + +VALID = """ +deployment: + name: Open Human Model + tagline: A model of human flourishing +projects: + - id: default + name: Open Human Model + type: document + content_repo: meta + visibility: public +""" + + +def test_parse_valid_registry(): + doc = registry.parse_registry(VALID) + assert doc.deployment_name == "Open Human Model" + assert doc.deployment_tagline == "A model of human flourishing" + assert len(doc.projects) == 1 + p = doc.projects[0] + assert (p.id, p.type, p.content_repo, p.visibility) == ("default", "document", "meta", "public") + assert p.initial_state == "super-draft" + + +def test_parse_initial_state_defaults_per_type(): + doc = registry.parse_registry( + "projects:\n - {id: a, name: A, type: bdd, content_repo: a}\n" + ) + assert doc.projects[0].initial_state == "active" # bdd default + + +@pytest.mark.parametrize("bad,msg", [ + ("projects: []\n", "at least one"), + ("projects:\n - {id: 'Bad Slug', name: A, type: document, content_repo: a}\n", "valid slug"), + ("projects:\n - {id: a, name: A, type: nope, content_repo: a}\n", "invalid type"), + ("projects:\n - {id: a, name: A, type: document}\n", "content_repo"), + ("projects:\n - {id: a, name: A, type: document, content_repo: a, visibility: x}\n", "visibility"), + ("projects:\n - {id: a, name: A, type: document, content_repo: a}\n - {id: a, name: B, type: document, content_repo: b}\n", "duplicate"), +]) +def test_parse_rejects_invalid(bad, msg): + with pytest.raises(registry.RegistryError) as e: + registry.parse_registry(bad) + assert msg in str(e.value) + + +def test_apply_upserts_projects_and_deployment(): + _db() + doc = registry.parse_registry(VALID) + registry.apply_registry(doc, registry_sha="regsha1") + prow = db.conn().execute( + "SELECT name, type, content_repo, visibility, initial_state, registry_sha FROM projects WHERE id='default'" + ).fetchone() + assert prow["name"] == "Open Human Model" + assert prow["content_repo"] == "meta" + assert prow["registry_sha"] == "regsha1" + drow = db.conn().execute("SELECT name, tagline FROM deployment WHERE id=1").fetchone() + assert drow["name"] == "Open Human Model" + assert drow["tagline"] == "A model of human flourishing" + + +def test_apply_rejects_type_change_on_existing_project(): + _db() + registry.apply_registry(registry.parse_registry(VALID), "s1") + changed = VALID.replace("type: document", "type: specification") + registry.apply_registry(registry.parse_registry(changed), "s2") # logged + skipped, no raise + t = db.conn().execute("SELECT type FROM projects WHERE id='default'").fetchone()["type"] + assert t == "document" # immutable — unchanged