feat(profiles): schéma et chargement des manifestes module

Valide strictement : un manifeste mal typé deviendrait une décision
d'extinction erronée en Phase 3.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
This commit is contained in:
CyberMind-FR 2026-07-17 11:39:28 +02:00
parent 722b5c1b03
commit e53b049b6c
4 changed files with 219 additions and 0 deletions

View File

@ -0,0 +1,110 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# Source-Disclosed License — All rights reserved except as expressly granted.
# See LICENCE-CMSD-1.0.md for terms.
"""
SecuBox-Deb :: profiles schéma et chargement des manifestes module
CyberMind https://cybermind.fr
Un manifeste décrit le CYCLE DE VIE d'un module : ses units, son runtime, son
exposition, sa priorité. Il ne duplique pas menu.d/ (path, ordre, icône), qui
reste la source UI avec son propre cycle de vie.
La validation est stricte et bruyante : en Phase 3 un manifeste mal typé
deviendrait une décision d'extinction erronée. Mieux vaut refuser au chargement.
"""
from __future__ import annotations
import tomllib
from dataclasses import dataclass
from pathlib import Path
RUNTIMES = ("native", "lxc")
EXPOSURES = ("public", "lan", "internal")
CATEGORIES = ("media", "security", "network", "infra", "dev", "mesh")
DEFAULT_PRIORITY = 50
class ManifestError(Exception):
"""Manifeste illisible ou invalide."""
@dataclass(frozen=True)
class Manifest:
id: str
category: str
runtime: str
exposure: str
units: tuple[str, ...]
lxc: str | None = None
portal_domain: str | None = None
priority: int = DEFAULT_PRIORITY
protected: bool = False
needs: tuple[str, ...] = ()
def _require(d: dict, key: str, path: Path):
if key not in d:
raise ManifestError(f"{path}: champ obligatoire manquant: {key}")
return d[key]
def _enum(value, allowed: tuple[str, ...], key: str, path: Path) -> str:
if value not in allowed:
raise ManifestError(f"{path}: {key}={value!r} invalide (attendu: {', '.join(allowed)})")
return value
def load_manifest(path: Path) -> Manifest:
"""Charge et valide un manifeste. Lève ManifestError sur tout écart."""
try:
d = tomllib.loads(Path(path).read_text(encoding="utf-8"))
except (OSError, tomllib.TOMLDecodeError) as exc:
raise ManifestError(f"{path}: illisible: {exc}") from exc
mid = _require(d, "id", path)
# L'id pilote pins et profils : s'il diverge du nom de fichier, un pin
# viserait un module qui n'existe pas sous ce nom.
if mid != path.stem:
raise ManifestError(f"{path}: id={mid!r} ne correspond pas au nom de fichier {path.stem!r}")
runtime = _enum(_require(d, "runtime", path), RUNTIMES, "runtime", path)
lxc = d.get("lxc")
if runtime == "lxc" and not lxc:
raise ManifestError(f"{path}: runtime='lxc' exige le champ lxc=<nom du conteneur>")
units = _require(d, "units", path)
if not isinstance(units, list) or not all(isinstance(u, str) for u in units):
raise ManifestError(f"{path}: units doit être une liste de chaînes")
priority = d.get("priority", DEFAULT_PRIORITY)
if not isinstance(priority, int) or isinstance(priority, bool) or not 0 <= priority <= 100:
raise ManifestError(f"{path}: priority={priority!r} hors bornes (entier 0-100)")
portal = d.get("portal") or {}
if not isinstance(portal, dict):
raise ManifestError(f"{path}: portal doit être une table")
return Manifest(
id=mid,
category=_enum(_require(d, "category", path), CATEGORIES, "category", path),
runtime=runtime,
exposure=_enum(_require(d, "exposure", path), EXPOSURES, "exposure", path),
units=tuple(units),
lxc=lxc,
portal_domain=portal.get("domain"),
priority=priority,
protected=bool(d.get("protected", False)),
needs=tuple(d.get("needs", ())),
)
def load_all(directory: Path) -> dict[str, Manifest]:
"""Charge tous les *.toml d'un répertoire, indexés par id."""
out: dict[str, Manifest] = {}
for p in sorted(Path(directory).glob("*.toml")):
m = load_manifest(p)
out[m.id] = m
return out

View File

@ -0,0 +1,10 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# Source-Disclosed License — All rights reserved except as expressly granted.
# See LICENCE-CMSD-1.0.md for terms.
import sys
from pathlib import Path
# Rend `api` importable en top-level (miroir du layout runtime sous
# /usr/lib/secubox/profiles), comme le conftest de secubox-billets.
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))

View File

@ -0,0 +1,99 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# Source-Disclosed License — All rights reserved except as expressly granted.
# See LICENCE-CMSD-1.0.md for terms.
import pytest
from api.manifest import Manifest, ManifestError, load_all, load_manifest
FULL = """
id = "peertube"
category = "media"
runtime = "lxc"
exposure = "public"
units = ["secubox-peertube.service"]
lxc = "peertube"
portal = { domain = "peertube.gk2.secubox.in" }
priority = 40
protected = false
needs = ["auth"]
"""
MINIMAL = """
id = "lyrion"
category = "media"
runtime = "native"
exposure = "lan"
units = ["secubox-lyrion.service"]
"""
def test_load_full_manifest(tmp_path):
p = tmp_path / "peertube.toml"
p.write_text(FULL)
m = load_manifest(p)
assert m == Manifest(
id="peertube", category="media", runtime="lxc", exposure="public",
units=("secubox-peertube.service",), lxc="peertube",
portal_domain="peertube.gk2.secubox.in", priority=40,
protected=False, needs=("auth",),
)
def test_defaults_are_applied(tmp_path):
# Un manifeste minimal doit rester valide : la plupart des 134 modules
# n'ont ni LXC, ni portail, ni deps.
p = tmp_path / "lyrion.toml"
p.write_text(MINIMAL)
m = load_manifest(p)
assert m.lxc is None and m.portal_domain is None
assert m.priority == 50 and m.protected is False and m.needs == ()
@pytest.mark.parametrize("field,bad", [
("runtime", '"docker"'),
("exposure", '"world"'),
("category", '"divers"'),
])
def test_rejects_unknown_enum(tmp_path, field, bad):
# Une valeur inconnue doit échouer bruyamment : un manifeste mal typé
# deviendrait une décision d'extinction erronée en Phase 3.
src = MINIMAL.replace(f'{field} = "' + {"runtime": "native", "exposure": "lan",
"category": "media"}[field] + '"',
f"{field} = {bad}")
p = tmp_path / "bad.toml"
p.write_text(src)
with pytest.raises(ManifestError):
load_manifest(p)
def test_rejects_lxc_runtime_without_lxc_name(tmp_path):
p = tmp_path / "bad.toml"
p.write_text(MINIMAL.replace('runtime = "native"', 'runtime = "lxc"'))
with pytest.raises(ManifestError):
load_manifest(p)
def test_rejects_priority_out_of_range(tmp_path):
p = tmp_path / "bad.toml"
p.write_text(MINIMAL + "\npriority = 101\n")
with pytest.raises(ManifestError):
load_manifest(p)
def test_rejects_id_mismatching_filename(tmp_path):
# L'id pilote pins/profils ; s'il diverge du nom de fichier, un pin
# viserait un module fantôme.
p = tmp_path / "autre.toml"
p.write_text(MINIMAL)
with pytest.raises(ManifestError):
load_manifest(p)
def test_load_all_indexes_by_id_and_skips_non_toml(tmp_path):
(tmp_path / "lyrion.toml").write_text(MINIMAL)
(tmp_path / "peertube.toml").write_text(FULL)
(tmp_path / "notes.txt").write_text("ignore me")
all_m = load_all(tmp_path)
assert sorted(all_m) == ["lyrion", "peertube"]
assert all_m["peertube"].runtime == "lxc"