feat(registry): §22.2 projects.yaml parse/validate/apply module

This commit is contained in:
Ben Stull
2026-06-03 22:47:19 -07:00
parent 8f21dc5f9c
commit 27a0a0443b
2 changed files with 275 additions and 0 deletions
+181
View File
@@ -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)
+94
View File
@@ -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