mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-29 12:34:38 +00:00
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:
parent
722b5c1b03
commit
e53b049b6c
0
packages/secubox-profiles/api/__init__.py
Normal file
0
packages/secubox-profiles/api/__init__.py
Normal file
110
packages/secubox-profiles/api/manifest.py
Normal file
110
packages/secubox-profiles/api/manifest.py
Normal 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
|
||||
10
packages/secubox-profiles/tests/conftest.py
Normal file
10
packages/secubox-profiles/tests/conftest.py
Normal 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]))
|
||||
99
packages/secubox-profiles/tests/test_manifest.py
Normal file
99
packages/secubox-profiles/tests/test_manifest.py
Normal 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"
|
||||
Loading…
Reference in New Issue
Block a user