fix(registry): RegistryError on non-dict entry; atomic apply; tighten type guard + slug regex
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+40
-36
@@ -32,7 +32,7 @@ _TYPE_DEFAULT_INITIAL_STATE = {
|
|||||||
"specification": "super-draft",
|
"specification": "super-draft",
|
||||||
"bdd": "active",
|
"bdd": "active",
|
||||||
}
|
}
|
||||||
_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$")
|
_SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$")
|
||||||
|
|
||||||
|
|
||||||
class RegistryError(Exception):
|
class RegistryError(Exception):
|
||||||
@@ -72,6 +72,8 @@ def parse_registry(text: str) -> RegistryDoc:
|
|||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
entries: list[ProjectEntry] = []
|
entries: list[ProjectEntry] = []
|
||||||
for p in projects_raw:
|
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()
|
pid = str(p.get("id") or "").strip()
|
||||||
if not _SLUG_RE.match(pid):
|
if not _SLUG_RE.match(pid):
|
||||||
raise RegistryError(f"project id {pid!r} is not a valid slug")
|
raise RegistryError(f"project id {pid!r} is not a valid slug")
|
||||||
@@ -119,46 +121,46 @@ def apply_registry(doc: RegistryDoc, registry_sha: str) -> None:
|
|||||||
(skip + log), never applied. Projects absent from the registry are left in
|
(skip + log), never applied. Projects absent from the registry are left in
|
||||||
place (archival is out of scope for M3; they simply stop refreshing).
|
place (archival is out of scope for M3; they simply stop refreshing).
|
||||||
"""
|
"""
|
||||||
conn = db.conn()
|
with db.tx() as conn:
|
||||||
for e in doc.projects:
|
for e in doc.projects:
|
||||||
existing = conn.execute(
|
existing = conn.execute(
|
||||||
"SELECT type FROM projects WHERE id = ?", (e.id,)
|
"SELECT type FROM projects WHERE id = ?", (e.id,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if existing is not None and existing["type"] not in (None, "", e.type):
|
if existing is not None and existing["type"] != e.type:
|
||||||
log.error(
|
log.error(
|
||||||
"registry: refusing immutable type change on project %s (%s -> %s)",
|
"registry: refusing immutable type change on project %s (%s -> %s)",
|
||||||
e.id, existing["type"], e.type,
|
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,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
continue
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO projects
|
UPDATE deployment
|
||||||
(id, name, type, content_repo, visibility, initial_state,
|
SET name = ?, tagline = ?, registry_sha = ?, updated_at = datetime('now')
|
||||||
config_json, registry_sha, updated_at)
|
WHERE id = 1
|
||||||
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')
|
|
||||||
""",
|
""",
|
||||||
(
|
(doc.deployment_name, doc.deployment_tagline, registry_sha),
|
||||||
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:
|
async def refresh_registry(config: Config, gitea: Gitea) -> None:
|
||||||
@@ -175,6 +177,8 @@ async def refresh_registry(config: Config, gitea: Gitea) -> None:
|
|||||||
f"{config.gitea_org}/{config.registry_repo}/projects.yaml not found"
|
f"{config.gitea_org}/{config.registry_repo}/projects.yaml not found"
|
||||||
)
|
)
|
||||||
text = base64.b64decode(item["content"]).decode("utf-8")
|
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 ""
|
sha = item.get("last_commit_sha") or item.get("sha") or ""
|
||||||
doc = parse_registry(text)
|
doc = parse_registry(text)
|
||||||
apply_registry(doc, sha)
|
apply_registry(doc, sha)
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ def test_parse_initial_state_defaults_per_type():
|
|||||||
|
|
||||||
@pytest.mark.parametrize("bad,msg", [
|
@pytest.mark.parametrize("bad,msg", [
|
||||||
("projects: []\n", "at least one"),
|
("projects: []\n", "at least one"),
|
||||||
|
("projects:\n - just-a-string\n", "must be a mapping"),
|
||||||
("projects:\n - {id: 'Bad Slug', name: A, type: document, content_repo: a}\n", "valid slug"),
|
("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: 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}\n", "content_repo"),
|
||||||
@@ -92,3 +93,6 @@ def test_apply_rejects_type_change_on_existing_project():
|
|||||||
registry.apply_registry(registry.parse_registry(changed), "s2") # logged + skipped, no raise
|
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"]
|
t = db.conn().execute("SELECT type FROM projects WHERE id='default'").fetchone()["type"]
|
||||||
assert t == "document" # immutable — unchanged
|
assert t == "document" # immutable — unchanged
|
||||||
|
# The deployment row IS still advanced even though the project upsert was skipped.
|
||||||
|
drow = db.conn().execute("SELECT registry_sha FROM deployment WHERE id=1").fetchone()
|
||||||
|
assert drow["registry_sha"] == "s2"
|
||||||
|
|||||||
Reference in New Issue
Block a user