diff --git a/packages/secubox-profiles/api/__init__.py b/packages/secubox-profiles/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/packages/secubox-profiles/api/manifest.py b/packages/secubox-profiles/api/manifest.py new file mode 100644 index 00000000..f0e7b0e0 --- /dev/null +++ b/packages/secubox-profiles/api/manifest.py @@ -0,0 +1,110 @@ +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind — Gérald Kerma +# 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=") + + 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 diff --git a/packages/secubox-profiles/tests/conftest.py b/packages/secubox-profiles/tests/conftest.py new file mode 100644 index 00000000..dd54eed0 --- /dev/null +++ b/packages/secubox-profiles/tests/conftest.py @@ -0,0 +1,10 @@ +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind — Gérald Kerma +# 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])) diff --git a/packages/secubox-profiles/tests/test_manifest.py b/packages/secubox-profiles/tests/test_manifest.py new file mode 100644 index 00000000..2673a7a7 --- /dev/null +++ b/packages/secubox-profiles/tests/test_manifest.py @@ -0,0 +1,99 @@ +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind — Gérald Kerma +# 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"