Merge pull request #878 from CyberMind-FR/feat/profiles-tiers-export

feat(profiles): functional tiers + installer export (0.3.0)
This commit is contained in:
CyberMind 2026-07-18 11:46:22 +02:00 committed by GitHub
commit 80733a1bf9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 1166 additions and 0 deletions

View File

@ -0,0 +1,614 @@
# Profils fonctionnels + export installateur — Plan d'implémentation
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax.
**Goal:** Ship four functional optimized profile tiers (grounded in real gk2 inventory) and a read-only `export` subcommand that emits the Debian package list for a profile's active modules only.
**Architecture:** Data files (`profiles/*.toml`) shipped read-only + postinst-seeded; a pure `api/export.py` (resolve desired-ON via existing `state.resolve`, map module→package via `units`→`dpkg -S` with fallback, three formatters); a thin `export` CLI subcommand; packaging + version bump.
**Tech Stack:** Python 3.11 stdlib only (`tomllib`, `subprocess`, `json`, `dataclasses`), existing `secubox-profiles` Phase-1 code.
## Global Constraints
- Python 3.11, stdlib only. SPDX 4-line header on every new `.py`. Copyright `Gérald Kerma <devel@cybermind.fr>`.
- Phase-1 discipline: **read-only**. No `apply`, no writes to systemd/LXC. `export` and profile files never toggle anything.
- Reuse, do not reimplement: `state.resolve(m, profile, pins)`, `state.load_profile`, `state.load_pins`, `manifest.load_all`, `manifest.Manifest` (fields: `id, category, runtime, exposure, units:tuple, lxc, portal_domain, priority, protected, needs`). `state.ON`/`OFF` constants.
- `_run` contract (copied from `cli._run`): returns `(rc, stdout)` where `rc is None` means the command could NOT run (OSError/timeout) — never fabricate a `(1, "")`. A dpkg failure must degrade to `unresolved`, never to a made-up package.
- Profile `on` lists are EXHAUSTIVE. `name` in each TOML MUST equal the filename stem (enforced by `load_profile`).
- Commit messages end `Co-Authored-By: Gerald KERMA <devel@cybermind.fr>`, NO Claude reference.
- Tests run per-directory: `.venv/bin/python -m pytest packages/secubox-profiles/tests -q` (never combine package dirs; `api` name collides — see `pytest.ini`).
- The installer export must NEVER silently drop a module: unresolved modules are always surfaced (JSON field + stderr warning).
---
### Task 1: Les quatre profils + packaging + test de cohérence
**Files:**
- Create: `packages/secubox-profiles/profiles/full.toml`
- Create: `packages/secubox-profiles/profiles/lite.toml`
- Create: `packages/secubox-profiles/profiles/secure-gateway.toml`
- Create: `packages/secubox-profiles/profiles/media-lab.toml`
- Modify: `packages/secubox-profiles/debian/install`
- Modify: `packages/secubox-profiles/debian/postinst`
- Test: `packages/secubox-profiles/tests/test_profiles_data.py`
**Interfaces:**
- Consumes: `state.load_profile` (validates `name`==stem, `on` is list[str]).
- Produces: four installable profile files under `/usr/share/secubox/profiles/`, seeded to `/etc/secubox/profiles/`.
- [ ] **Step 1: Write the failing cohérence test**
Create `packages/secubox-profiles/tests/test_profiles_data.py`. It loads every shipped profile via the real `load_profile` and asserts each `on` id is a plausible module id (non-empty, no whitespace) and that `name`==stem. (Cross-checking against live manifests needs the board; here we validate structure + the invariant `load_profile` enforces.)
```python
# 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.
from pathlib import Path
import pytest
from api.state import load_profile
PROFILES_DIR = Path(__file__).resolve().parent.parent / "profiles"
PROFILE_FILES = sorted(PROFILES_DIR.glob("*.toml"))
EXPECTED = {"full", "lite", "secure-gateway", "media-lab"}
def test_all_four_profiles_ship():
assert {p.stem for p in PROFILE_FILES} == EXPECTED
@pytest.mark.parametrize("path", PROFILE_FILES, ids=lambda p: p.stem)
def test_profile_loads_and_name_matches_stem(path):
prof = load_profile(path) # raises StateError if name != stem or bad 'on'
assert prof.name == path.stem
assert prof.on, f"{path.stem}: empty on-list"
for mid in prof.on:
assert mid and mid == mid.strip() and " " not in mid, f"bad id {mid!r}"
def test_protected_core_present_in_every_profile():
# aggregator/auth/core are protected (always ON) but every functional tier
# lists them explicitly for readability; guard that nobody drops them.
for path in PROFILE_FILES:
on = load_profile(path).on
assert {"core", "auth", "aggregator"} <= on, f"{path.stem} missing protected"
```
- [ ] **Step 2: Run — must fail**
Run: `.venv/bin/python -m pytest packages/secubox-profiles/tests/test_profiles_data.py -q`
Expected: FAIL (no `profiles/` dir / files yet → `test_all_four_profiles_ship` fails, params empty).
- [ ] **Step 3: Create the four profile files**
Create `packages/secubox-profiles/profiles/secure-gateway.toml`:
```toml
name = "secure-gateway"
label = "Secure Gateway — appliance durcie"
on = [
"aggregator", "auth", "certs", "core", "crowdsec", "dns",
"dns-guard", "dns-provider", "exposure", "haproxy", "hardening", "ipblock",
"users", "vortex-firewall", "waf", "waf-ng", "waf-ratelimit",
]
```
Create `packages/secubox-profiles/profiles/lite.toml`:
```toml
name = "lite"
label = "Lite — daily-driver optimisé"
on = [
"ad-guard", "admin", "aggregator", "annuaire", "auth", "backup",
"cdn", "certs", "core", "crowdsec", "cs-bridge", "dns",
"dns-guard", "dns-provider", "exposure", "haproxy", "hardening", "health-doctor",
"hub", "ipblock", "led-trigger", "leds", "mesh", "netdata",
"network-ready", "p2p", "portal", "routes", "runtime", "system",
"users", "vhost", "vortex-firewall", "waf", "waf-ng", "waf-ratelimit",
"watchdog", "wireguard",
]
```
Create `packages/secubox-profiles/profiles/media-lab.toml`:
```toml
name = "media-lab"
label = "Media Lab — box maison-média"
on = [
"ad-guard", "admin", "aggregator", "annuaire", "appstore", "auth",
"avatar", "backup", "billets", "c3box", "cdn", "certs",
"cloner", "config-advisor", "cookies", "core", "crowdsec", "cs-bridge",
"cyberfeed", "dns", "dns-guard", "dns-provider", "dpi", "dpi-flowcap",
"droplet", "exposure", "eye-remote", "eye-remote-dhcp", "fmrelay", "frigate",
"gitea", "glances", "grafana", "haproxy", "hardening", "health-doctor",
"health-prober", "hub", "identity", "ipblock", "ksm", "led-heartbeat",
"led-trigger", "leds", "localrecall", "lyrion", "mail", "mcp-server",
"mesh", "meshname", "metablogizer", "metacatalog", "metrics", "mirror",
"mitmproxy", "modem", "module-prober", "mqtt", "nac", "ndpid",
"netboot", "netdata", "netdiag", "netifyd", "netmodes", "nettweak",
"network-ready", "nextcloud", "p2p", "peertube", "photoprism", "podcaster",
"portal", "proxypac", "publish", "qos", "reality", "repo",
"reporter", "rezapp", "roadmap", "routes", "rtty", "runtime",
"rustdesk", "saas-relay", "streamforge", "streamlit", "streamlit-routes", "system",
"threats", "tor", "traffic", "turn", "users", "vault",
"vhost", "vm", "vortex-dns", "vortex-firewall", "waf", "waf-ng",
"waf-ratelimit", "watchdog", "wireguard", "yacy", "zigbee", "zkp",
]
```
Create `packages/secubox-profiles/profiles/full.toml`:
```toml
name = "full"
label = "Full — loadout courant (baseline)"
on = [
"ad-guard", "admin", "aggregator", "annuaire", "appstore", "auth",
"avatar", "backup", "billets", "c3box", "cdn", "certs",
"cloner", "config-advisor", "cookies", "core", "crowdsec", "cs-bridge",
"cve-triage", "cyberfeed", "dns", "dns-guard", "dns-provider", "dpi",
"dpi-flowcap", "droplet", "exposure", "eye-remote", "eye-remote-dhcp", "fmrelay",
"frigate", "gitea", "glances", "grafana", "haproxy", "hardening",
"health-doctor", "health-prober", "hub", "identity", "interceptor", "ipblock",
"ksm", "led-heartbeat", "led-trigger", "leds", "localrecall", "lyrion",
"mail", "mcp-server", "mesh", "meshname", "metablogizer", "metabolizer",
"metacatalog", "metoblizer", "metrics", "mirror", "mitmproxy", "modem",
"module-prober", "mqtt", "nac", "ndpid", "netboot", "netdata",
"netdiag", "netifyd", "netmodes", "nettweak", "network-anomaly", "network-ready",
"nextcloud", "p2p", "peertube", "photoprism", "podcaster", "portal",
"proxypac", "publish", "qos", "reality", "repo", "reporter",
"rezapp", "roadmap", "routes", "rtty", "runtime", "rustdesk",
"saas-relay", "security-posture", "soc", "streamforge", "streamlit", "streamlit-routes",
"system", "threat-analyst", "threatmesh", "threats", "toolbox", "toolbox-mitm",
"tor", "traffic", "turn", "users", "vault", "vhost",
"vm", "vortex-dns", "vortex-firewall", "waf", "waf-ng", "waf-ratelimit",
"watchdog", "wireguard", "yacy", "zigbee", "zkp",
]
```
- [ ] **Step 4: Run — must pass**
Run: `.venv/bin/python -m pytest packages/secubox-profiles/tests/test_profiles_data.py -q`
Expected: PASS (4 files, all load, names match, protected present).
- [ ] **Step 5: Ship the profiles (packaging)**
In `packages/secubox-profiles/debian/install`, add a line (match the existing column style):
```
profiles/*.toml usr/share/secubox/profiles/
```
In `packages/secubox-profiles/debian/postinst`, inside the `configure` case AFTER the `install -d ... /etc/secubox/profiles` line, seed missing profiles WITHOUT overwriting operator edits:
```sh
# Seed reference profiles into the operator-editable dir on first
# install only — never overwrite an edited profile on upgrade.
for f in /usr/share/secubox/profiles/*.toml; do
[ -e "$f" ] || continue
dest="/etc/secubox/profiles/$(basename "$f")"
if [ ! -e "$dest" ]; then
install -o secubox -g secubox -m 644 "$f" "$dest"
fi
done
```
- [ ] **Step 6: Commit**
```bash
git add packages/secubox-profiles/profiles packages/secubox-profiles/debian/install packages/secubox-profiles/debian/postinst packages/secubox-profiles/tests/test_profiles_data.py
git commit -m "feat(profiles): four functional tiers (full/lite/secure-gateway/media-lab) + seed
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>"
```
---
### Task 2: `api/export.py` — résolution pure module→paquet + formatteurs
**Files:**
- Create: `packages/secubox-profiles/api/export.py`
- Test: `packages/secubox-profiles/tests/test_export.py`
**Interfaces:**
- Consumes: `state.resolve`, `state.ON`, `state.Profile`, `manifest.Manifest`.
- Produces: `resolve_packages(manifests, profile, pins, *, run=_run, rss_kb=None) -> ExportResult`; `ExportResult(profile, on_ids, packages, unresolved, rss_estimate_mo)`; `format_pkglist(r) -> str`, `format_apt(r) -> str`, `format_json(r) -> str`; `_run(argv) -> tuple[int|None, str]`.
- [ ] **Step 1: Write the failing tests**
Create `packages/secubox-profiles/tests/test_export.py`:
```python
# 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 json
from api.export import (ExportResult, format_apt, format_json, format_pkglist,
resolve_packages)
from api.manifest import Manifest
from api.state import Profile
def _m(mid, units=(), protected=False):
return Manifest(id=mid, category="infra", runtime="native",
exposure="lan", units=tuple(units), protected=protected)
def _fake_run(mapping):
"""mapping: unit-or-path substring -> package name (dpkg -S hit).
dpkg-query -W install-check succeeds for packages in `mapping.values()`."""
pkgs = set(mapping.values())
def run(argv):
if argv[:2] == ["dpkg", "-S"]:
target = argv[2]
for key, pkg in mapping.items():
if key in target:
return 0, f"{pkg}: {target}\n"
return 1, ""
if argv[:1] == ["dpkg-query"]:
pkg = argv[-1]
return (0, "install ok installed") if pkg in pkgs else (1, "")
return None, ""
return run
def test_resolve_maps_units_to_packages_deduped_sorted():
manifests = {
"auth": _m("auth", ["secubox-auth.service"], protected=True),
"waf": _m("waf", ["secubox-waf.service"]),
"off1": _m("off1", ["secubox-off1.service"]),
}
profile = Profile(name="p", label="p", on=frozenset({"waf"})) # off1 not listed
run = _fake_run({"secubox-auth.service": "secubox-auth",
"secubox-waf.service": "secubox-waf"})
r = resolve_packages(manifests, profile, {}, run=run)
assert r.on_ids == ["auth", "waf"] # protected auth + listed waf, off1 excluded
assert r.packages == ["secubox-auth", "secubox-waf"]
assert r.unresolved == []
def test_fallback_to_secubox_id_when_no_unit():
# core is protected (always ON) and has no units → must fall back to the
# secubox-<id> package name, verified installed via dpkg-query.
manifests = {"core": _m("core", units=(), protected=True)}
def run(argv):
if argv[:1] == ["dpkg-query"]:
return (0, "install ok installed") if argv[-1] == "secubox-core" else (1, "")
if argv[:2] == ["dpkg", "-S"]:
return 1, "" # no unit → no dpkg -S hit
return None, ""
r = resolve_packages(manifests, None, {}, run=run)
assert r.packages == ["secubox-core"]
assert r.unresolved == []
def test_truly_unknown_module_is_unresolved_never_fabricated():
manifests = {"ghost": _m("ghost", ["secubox-ghost.service"])}
profile = Profile(name="p", label="p", on=frozenset({"ghost"}))
def run(argv):
if argv[:2] == ["dpkg", "-S"]:
return 1, "" # dpkg -S: not found
if argv[:1] == ["dpkg-query"]:
return 1, "" # secubox-ghost NOT installed
return None, ""
r = resolve_packages(manifests, profile, {}, run=run)
assert r.packages == []
assert r.unresolved == ["ghost"]
def test_dpkg_cannot_run_degrades_to_unresolved_not_a_package():
manifests = {"waf": _m("waf", ["secubox-waf.service"])}
profile = Profile(name="p", label="p", on=frozenset({"waf"}))
def run(argv):
return None, "" # rc=None everywhere: command could not run
r = resolve_packages(manifests, profile, {}, run=run)
assert r.packages == []
assert r.unresolved == ["waf"]
def test_rss_estimate_summed_over_on_set():
manifests = {"a": _m("a", ["secubox-a.service"]),
"b": _m("b", ["secubox-b.service"], protected=True)}
profile = Profile(name="p", label="p", on=frozenset({"a"}))
run = _fake_run({"secubox-a.service": "secubox-a", "secubox-b.service": "secubox-b"})
r = resolve_packages(manifests, profile, {}, run=run,
rss_kb={"a": 20480, "b": 10240}) # 20 + 10 Mo
assert r.rss_estimate_mo == 30
def test_formatters():
r = ExportResult(profile="lite", on_ids=["auth", "waf"],
packages=["secubox-auth", "secubox-waf"],
unresolved=["ghost"], rss_estimate_mo=42)
assert format_pkglist(r) == "secubox-auth\nsecubox-waf"
assert format_apt(r) == "apt-get install -y secubox-auth secubox-waf"
doc = json.loads(format_json(r))
assert doc["profile"] == "lite" and doc["packages"] == ["secubox-auth", "secubox-waf"]
assert doc["unresolved"] == ["ghost"] and doc["rss_estimate_mo"] == 42
```
- [ ] **Step 2: Run — must fail**
Run: `.venv/bin/python -m pytest packages/secubox-profiles/tests/test_export.py -q`
Expected: FAIL `ModuleNotFoundError: No module named 'api.export'`.
- [ ] **Step 3: Implement `api/export.py`**
```python
# 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 — export installateur (lecture seule)
CyberMind — https://cybermind.fr
Résout l'ensemble désiré ON d'un profil (via state.resolve, pur) et le mappe
sur les paquets Debian propriétaires (units -> dpkg -S, repli secubox-<id>).
Un module dont le paquet reste introuvable part dans `unresolved` — JAMAIS un
paquet fabriqué : un installateur qui omet un module en silence casse l'image.
"""
from __future__ import annotations
import json
import subprocess
from dataclasses import dataclass
from pathlib import Path
from .manifest import Manifest
from .state import ON, Profile, resolve
_UNIT_DIRS = ("/usr/lib/systemd/system/", "/lib/systemd/system/",
"/etc/systemd/system/")
@dataclass(frozen=True)
class ExportResult:
profile: str
on_ids: list[str]
packages: list[str]
unresolved: list[str]
rss_estimate_mo: int
def _run(argv: list[str]) -> tuple[int | None, str]:
"""rc=None => la commande n'a PAS pu s'exécuter (à distinguer d'un rc!=0,
réponse authentique). Même contrat que cli._run / observe._run_cmd."""
try:
p = subprocess.run(argv, capture_output=True, text=True, timeout=15)
return p.returncode, p.stdout
except (OSError, subprocess.SubprocessError):
return None, ""
def _package_for_unit(unit: str, run) -> str | None:
for base in _UNIT_DIRS:
rc, out = run(["dpkg", "-S", base + unit])
if rc == 0 and ":" in out:
return out.splitlines()[0].split(":", 1)[0].strip()
rc, out = run(["dpkg", "-S", unit]) # bare name (dpkg matches the path)
if rc == 0 and ":" in out:
return out.splitlines()[0].split(":", 1)[0].strip()
return None
def _installed(pkg: str, run) -> bool:
rc, out = run(["dpkg-query", "-W", "-f=${Status}", pkg])
return rc == 0 and "install ok installed" in out
def resolve_packages(manifests: dict[str, Manifest], profile: Profile | None,
pins: dict[str, str], *, run=_run,
rss_kb: dict[str, int] | None = None) -> ExportResult:
rss_kb = rss_kb or {}
on = [m for _, m in sorted(manifests.items()) if resolve(m, profile, pins) == ON]
packages: set[str] = set()
unresolved: list[str] = []
for m in on:
pkg = None
for u in m.units:
pkg = _package_for_unit(u, run)
if pkg:
break
if not pkg:
candidate = f"secubox-{m.id}"
if _installed(candidate, run):
pkg = candidate
if pkg:
packages.add(pkg)
else:
unresolved.append(m.id)
rss_mo = int(sum(rss_kb.get(m.id, 0) for m in on) / 1024)
return ExportResult(
profile=profile.name if profile else "",
on_ids=[m.id for m in on],
packages=sorted(packages),
unresolved=sorted(unresolved),
rss_estimate_mo=rss_mo,
)
def format_pkglist(r: ExportResult) -> str:
return "\n".join(r.packages)
def format_apt(r: ExportResult) -> str:
return "apt-get install -y" + ("" if not r.packages else " " + " ".join(r.packages))
def format_json(r: ExportResult) -> str:
return json.dumps({
"profile": r.profile, "on_ids": r.on_ids, "packages": r.packages,
"unresolved": r.unresolved, "rss_estimate_mo": r.rss_estimate_mo,
}, ensure_ascii=False, indent=2)
```
- [ ] **Step 4: Run — must pass**
Run: `.venv/bin/python -m pytest packages/secubox-profiles/tests/test_export.py -q`
Expected: PASS (6 tests).
- [ ] **Step 5: Commit**
```bash
git add packages/secubox-profiles/api/export.py packages/secubox-profiles/tests/test_export.py
git commit -m "feat(profiles): export.py — resolve a profile's active modules to Debian packages
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>"
```
---
### Task 3: sous-commande CLI `export` + bump version + README
**Files:**
- Modify: `packages/secubox-profiles/api/cli.py`
- Modify: `packages/secubox-profiles/debian/changelog`
- Modify: `packages/secubox-profiles/README.md`
- Test: `packages/secubox-profiles/tests/test_cli.py` (extend if present; else create)
**Interfaces:**
- Consumes: `export.resolve_packages`, `export.format_pkglist/apt/json`; existing `_paths`, `_load_profile_or_none`, `load_all`, `load_pins`, `_observe_all`, `observe.Actual`.
- Produces: `secubox-profilectl export <profile> [--format pkglist|apt|json]`.
- [ ] **Step 1: Write the failing CLI test**
Add to `packages/secubox-profiles/tests/test_cli.py` (create the file with the SPDX header if it does not exist). This test drives `cli.main(["export", ...])` against a tmp `--root`, monkeypatching the dpkg-facing `run` and the observer so it needs no board.
```python
def test_export_pkglist_from_tmp_root(tmp_path, monkeypatch, capsys):
import api.cli as cli
import api.export as export
root = tmp_path / "etc-secubox"
(root / "modules.d").mkdir(parents=True)
(root / "profiles").mkdir(parents=True)
(root / "modules.d" / "waf.toml").write_text(
'id="waf"\ncategory="security"\nruntime="native"\nexposure="lan"\n'
'units=["secubox-waf.service"]\nprotected=false\n')
(root / "modules.d" / "auth.toml").write_text(
'id="auth"\ncategory="security"\nruntime="native"\nexposure="lan"\n'
'units=["secubox-auth.service"]\nprotected=true\n')
(root / "profiles" / "p.toml").write_text('name="p"\non=["waf"]\n')
def fake_run(argv):
if argv[:2] == ["dpkg", "-S"]:
if "secubox-waf.service" in argv[2]:
return 0, "secubox-waf: " + argv[2] + "\n"
if "secubox-auth.service" in argv[2]:
return 0, "secubox-auth: " + argv[2] + "\n"
return 1, ""
return None, ""
monkeypatch.setattr(export, "_run", fake_run)
rc = cli.main(["--root", str(root), "export", "p", "--format", "pkglist"])
out = capsys.readouterr().out.strip().splitlines()
assert rc == 0
assert out == ["secubox-auth", "secubox-waf"] # protected auth + listed waf
def test_export_unknown_profile_errors(tmp_path):
import api.cli as cli
root = tmp_path / "etc-secubox"
(root / "modules.d").mkdir(parents=True)
(root / "profiles").mkdir(parents=True)
assert cli.main(["--root", str(root), "export", "nope"]) == 2 # StateError -> rc 2
```
- [ ] **Step 2: Run — must fail**
Run: `.venv/bin/python -m pytest packages/secubox-profiles/tests/test_cli.py -q`
Expected: FAIL (no `export` subparser → argparse SystemExit, or AttributeError).
- [ ] **Step 3: Wire the `export` subcommand in `cli.py`**
Add the import near the others:
```python
from .export import format_apt, format_json, format_pkglist, resolve_packages
```
Add the handler (place beside `_cmd_diff`). It observes RSS best-effort (same batched path as status/diff) so the estimate is real on-box, but never fails if observation is unavailable:
```python
def _cmd_export(args) -> int:
root = Path(args.root)
mod_dir, _, pins_file, _ = _paths(root)
manifests = load_all(mod_dir)
profile = _load_profile_or_none(root, args.profile) # StateError -> rc 2 if unknown
pins = load_pins(pins_file)
actuals = _observe_all(manifests, load_routes())
rss_kb = {mid: (a.rss_kb or 0) for mid, a in actuals.items()}
result = resolve_packages(manifests, profile, pins, rss_kb=rss_kb)
if result.unresolved:
print("⚠️ paquet introuvable pour: " + ", ".join(result.unresolved)
+ " — exclus de la liste (installateur incomplet).", file=sys.stderr)
fmt = {"pkglist": format_pkglist, "apt": format_apt, "json": format_json}[args.format]
print(fmt(result))
return 0
```
Register the subparser in `main` (after the `scan` parser). `export` requires a profile name (positional), so it does not fall back to the active profile:
```python
sp = sub.add_parser("export", help="liste des paquets des modules actifs d'un profil")
sp.add_argument("profile", help="nom du profil à exporter")
sp.add_argument("--format", choices=["pkglist", "apt", "json"], default="pkglist")
sp.set_defaults(func=_cmd_export)
```
Note: `_load_profile_or_none` raises `StateError` on an unknown profile; a non-None name never returns None, so `_cmd_export` always has a real profile (the export of "nothing" is not a valid request).
- [ ] **Step 4: Run — must pass**
Run: `.venv/bin/python -m pytest packages/secubox-profiles/tests/test_cli.py -q`
Expected: PASS.
- [ ] **Step 5: Bump version + README**
Prepend to `packages/secubox-profiles/debian/changelog`:
```
secubox-profiles (0.3.0-1~bookworm1) bookworm; urgency=medium
* Ship four functional profile tiers: full, lite, secure-gateway, media-lab
(seeded into /etc/secubox/profiles on install, never overwriting edits).
* New read-only CLI: secubox-profilectl export <profile> [--format
pkglist|apt|json] — the Debian package list of a profile's active modules,
for building a slim installer/image. Unresolved modules are surfaced, never
silently dropped.
-- Gerald KERMA <devel@cybermind.fr> Fri, 18 Jul 2026 12:00:00 +0200
```
In `README.md`, add a short "Profils & export" section documenting the four tiers, the pin-precedence caveat (a pinned-off module still shows in `diff`), and the three `export` formats. Keep it brief.
- [ ] **Step 6: Full suite + commit**
Run: `.venv/bin/python -m pytest packages/secubox-profiles/tests -q`
Expected: all prior Phase-1 tests + the new profile/export/cli tests pass.
```bash
git add packages/secubox-profiles/api/cli.py packages/secubox-profiles/debian/changelog packages/secubox-profiles/README.md packages/secubox-profiles/tests/test_cli.py
git commit -m "feat(profiles): secubox-profilectl export subcommand (0.3.0)
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>"
```
---
## Auto-revue du plan
- **Couverture spec** : profils 4 tiers (Task 1) ; livraison read-only + seed (Task 1 packaging) ; export pur module→paquet + repli + unresolved + 3 formats (Task 2) ; CLI export read-only (Task 3) ; version + README + caveat pins (Task 3). ✓
- **Placeholders** : aucun — chaque étape porte code + commande + attendu.
- **Cohérence des types** : `resolve_packages(manifests, profile, pins, *, run, rss_kb)` défini Task 2, consommé Task 3 ; `ExportResult` champs identiques dans formatteurs et test ; `_load_profile_or_none`/`_paths`/`_observe_all` réutilisés tels quels (signatures inchangées).
- **Sûreté Phase 1** : aucun write, aucun toggle ; export lit dpkg seulement ; unresolved jamais masqué.

View File

@ -0,0 +1,115 @@
# Profils fonctionnels + export installateur — Conception
**Date** : 2026-07-18
**Statut** : conception validée (design approuvé « go ok all »), prête pour le plan
**Auteur** : Gérald Kerma <devel@cybermind.fr>
**Module** : `secubox-profiles` (Phase 1 déjà livrée : manifests, `scan`, `status`, `diff` — lecture seule)
---
## Objectif
Deux livrables sur le moteur de profils Phase 1 existant :
1. **Profils fonctionnels et optimisés** ancrés dans l'inventaire réel de gk2, pour
**exercer le switch** — aujourd'hui via `diff` (le seul actuateur de Phase 1),
`apply` restant la Phase 3.
2. **Export installateur** : une sous-commande **lecture seule** qui produit la liste
des paquets Debian correspondant aux **seuls modules actifs** d'un profil, pour
builder un installateur/image ne posant que ces modules.
Aucune Phase 3 (`apply`) ici : les deux livrables sont read-only et compatibles Phase 1.
## Données de terrain (gk2, scan 2026-07-17)
187 modules, **119 ON = 3182 Mo RSS**, 3 protégés (`aggregator`, `auth`, `core`).
142 paquets `secubox-*` pour 187 modules ⟹ mapping module→paquet **pas 1:1** (un paquet
peut porter plusieurs modules/units : `secubox-core` porte core+runtime+leds+…).
---
## ① Profils (livrable 1)
### Schéma (déjà défini par `state.load_profile`)
TOML plat : `name` (DOIT == nom de fichier sans extension), `label` optionnel, `on = [ids]`
**exhaustif** (ce qui n'est pas listé est éteint). La résolution `state.resolve` est stricte
et inchangée : `protected → ON` · `pin → valeur du pin` · `dans on → ON` · `sinon → OFF`.
### Les quatre tiers (membres = IDs réels validés contre les 187 manifests)
| Profil | Modules | ~RSS | Rôle |
|---|---|---|---|
| `full` | 119 | 3182 Mo | snapshot du loadout courant — baseline |
| `lite` | 38 | 1066 Mo | daily-driver : sécu complète + infra cœur + 1 dashboard, sans LXC lourds/media/AI |
| `secure-gateway` | 17 | 625 Mo | appliance durcie : firewall/waf/haproxy/crowdsec/auth/certs/dns/exposure/users — gros `diff`, test de switch fort |
| `media-lab` | 108 | 2541 Mo | box maison-média : `full` moins la lourde analytique sécu |
Les listes exactes sont figées dans le plan (générées depuis `status --json`, chaque ID
vérifié présent dans `modules.d/`).
**Précédence des pins (à documenter, comportement correct)** : `pins.toml` (12 modules
épinglés `off` sur gk2) l'emporte sur `on`. `diff full` peut donc afficher des `stop` pour
des modules épinglés-off encore en cours d'exécution — c'est le mécanisme de pin qui survit
aux bascules, pas un bug. Les vrais tests de bascule sont `lite`/`secure-gateway`.
### Livraison
Référence **read-only** dans le paquet : `profiles/*.toml``/usr/share/secubox/profiles/`.
`postinst` **sème** chaque profil manquant dans `/etc/secubox/profiles/` (jamais d'écrasement
— les éditions opérateur persistent). Versionné dans le repo (`packages/secubox-profiles/profiles/`).
## ② Export installateur (livrable 2)
### Cœur testable — `api/export.py`
```
resolve_packages(manifests, profile, pins, *, run=_run) -> ExportResult
```
- Ensemble désiré ON = `{ id | resolve(m, profile, pins) == ON }` (réutilise `state.resolve`, pur).
- Pour chaque module ON → paquet propriétaire via ses `units` : `dpkg -S <unit>` → paquet.
- **Repli** si aucune unit ou `dpkg -S` échoue : candidat `secubox-<id>`, vérifié présent
(`dpkg -l`) ; sinon le module part dans **`unresolved`** (jamais abandonné en silence).
- `run` injecté (mêmes conventions que `cli._run` : `rc=None` = n'a pas pu s'exécuter) →
testable sans board.
- `ExportResult` : `profile`, `on_ids: list[str]`, `packages: list[str]` (dédupliqués, triés),
`unresolved: list[str]`, `rss_estimate_mo: int`.
**Sûreté** : un installateur qui omet un module en silence produit une image cassée. Les
`unresolved` sont donc toujours **remontés** (stderr + champ JSON), jamais masqués.
### Surface CLI — `secubox-profilectl export <profil> [--format pkglist|apt|json]`
- `pkglist` (défaut) : un paquet par ligne sur **stdout** ; `unresolved` en warning sur **stderr**.
- `apt` : une ligne `apt-get install -y <paquets>` prête à consommer.
- `json` : `ExportResult` sérialisé.
- Exit 0 même avec `unresolved` (warning), pour rester scriptable. (`--strict` → exit non-nul
si `unresolved` non vide : YAGNI pour l'instant, non implémenté.)
Read-only, JWT non requis en CLI (root local) ; pas de route web dans ce lot (le panel
d'export viendra si besoin, hors périmètre ici).
---
## Tests
- **Profils** : chaque `profiles/*.toml` charge via `load_profile` sans erreur (`name`==stem) ;
un test de cohérence vérifie que **tout ID listé existe** parmi un ensemble de manifests de
référence (garde-fou anti-typo — sinon un ID mort serait silencieusement OFF).
- **resolve** (déjà couvert Phase 1) : re-vérifier qu'un profil donné produit le bon désiré-état
pour protected / pin / listé / absent.
- **export** (pur, `run` injecté) : units→paquet dédupliqué+trié ; repli `secubox-<id>` quand
pas d'unit ; module réellement introuvable → `unresolved` (pas dans `packages`) ; `dpkg -S`
qui échoue (rc=None) → repli puis `unresolved`, jamais un paquet fabriqué ; les 3 formats ;
`rss_estimate_mo` sommé. Chaque test doit pouvoir échouer (mutation).
Couverture ≥ 80 % (CSPN).
## Hors périmètre (YAGNI)
- `apply` (Phase 3 : snapshot 4R, séquencement, réconciliation boot).
- Axe d'activation riche (alwayson/ondemand/sleeper/disabled/uninstalled/broken) — modèle
distinct, nécessiterait sa propre conception.
- Sync de profils via le mesh ; panel web d'export ; build automatique de l'image.
- `--strict` sur l'export.

View File

@ -7,8 +7,40 @@ Inventaire et profils des modules SecuBox. **Phase 1 : lecture seule.**
secubox-profilectl scan [--force] # dérive les manifestes du réel
secubox-profilectl status [--json] # état, taxonomie et coût RAM par module
secubox-profilectl diff --profile <nom> [--json] # ce qu'un profil changerait
secubox-profilectl export <nom> [--format pkglist|apt|json] # paquets d'un profil
`apply` n'existe pas encore (Phase 3) : rien n'est jamais allumé ni éteint ici.
`export` aussi est lecture seule : il lit dpkg + la config, ne touche à rien.
## Profils & export
Quatre tiers livrés (seedés dans `/etc/secubox/profiles/` à l'install, sans
jamais écraser un fichier déjà édité par l'opérateur) :
| Profil | Usage |
|---|---|
| `full` | tous les modules |
| `lite` | socle minimal (sécurité + admin) |
| `secure-gateway` | passerelle durcie (firewall/WAF/IDS, pas de media) |
| `media-lab` | modules media (peertube, lyrion, photoprism…) en plus du socle |
**Piège pins** : un module pinné `off` reste listé par `diff` (le pin est une
surcharge d'état désiré, pas une suppression du module de l'inventaire) — il
n'apparaît simplement plus dans le futur état ON. Ne pas confondre "absent du
profil" et "pinné off" en lisant une sortie `diff`.
`export <nom>` résout l'ensemble ON du profil (profil + pins, comme `diff`),
mappe chaque module vers son paquet Debian (`dpkg -S` sur les units, repli
`secubox-<id>`), et imprime :
| `--format` | Sortie |
|---|---|
| `pkglist` (défaut) | un paquet par ligne, triés |
| `apt` | `apt-get install -y <pkg> <pkg> …` prêt à coller |
| `json` | `{profile, on_ids, packages, unresolved, rss_estimate_mo}` |
Un module dont le paquet reste introuvable part dans `unresolved` (imprimé sur
stderr, `⚠️`) — jamais silencieusement omis de l'installateur.
## Fichiers

View File

@ -20,7 +20,9 @@ import subprocess
import sys
from pathlib import Path
from . import export
from .diff import ProtectedViolation, plan_changes
from .export import format_apt, format_json, format_pkglist, resolve_packages
from .manifest import ManifestError, load_all
from .observe import Actual, is_on, load_routes, observe_all
from .scan import discover, write_drafts
@ -111,6 +113,28 @@ def _cmd_diff(args) -> int:
return 0
def _cmd_export(args) -> int:
root = Path(args.root)
mod_dir, _, pins_file, _ = _paths(root)
manifests = load_all(mod_dir)
profile = _load_profile_or_none(root, args.profile) # StateError -> rc 2 if unknown
pins = load_pins(pins_file)
actuals = _observe_all(manifests, load_routes())
rss_kb = {mid: (a.rss_kb or 0) for mid, a in actuals.items()}
# run=export._run (attribut, pas la valeur par défaut de resolve_packages)
# pour que le monkeypatch de export._run dans les tests (et un futur run
# alternatif) soit bien pris en compte : le défaut de resolve_packages a
# été figé à l'import de export.py, une réaffectation de export._run après
# coup ne le changerait pas si on ne passait pas explicitement l'attribut.
result = resolve_packages(manifests, profile, pins, run=export._run, rss_kb=rss_kb)
if result.unresolved:
print("⚠️ paquet introuvable pour: " + ", ".join(result.unresolved)
+ " — exclus de la liste (installateur incomplet).", file=sys.stderr)
fmt = {"pkglist": format_pkglist, "apt": format_apt, "json": format_json}[args.format]
print(fmt(result))
return 0
def _running_as_root() -> bool:
return os.geteuid() == 0
@ -222,6 +246,11 @@ def main(argv: list[str] | None = None) -> int:
help="écraser les manifestes existants (ils font autorité par défaut)")
sp.set_defaults(func=_cmd_scan)
sp = sub.add_parser("export", help="liste des paquets des modules actifs d'un profil")
sp.add_argument("profile", help="nom du profil à exporter")
sp.add_argument("--format", choices=["pkglist", "apt", "json"], default="pkglist")
sp.set_defaults(func=_cmd_export)
args = p.parse_args(argv)
try:
return args.func(args)

View File

@ -0,0 +1,106 @@
# 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 export installateur (lecture seule)
CyberMind https://cybermind.fr
Résout l'ensemble désiré ON d'un profil (via state.resolve, pur) et le mappe
sur les paquets Debian propriétaires (units -> dpkg -S, repli secubox-<id>).
Un module dont le paquet reste introuvable part dans `unresolved` JAMAIS un
paquet fabriqué : un installateur qui omet un module en silence casse l'image.
"""
from __future__ import annotations
import json
import subprocess
from dataclasses import dataclass
from .manifest import Manifest
from .state import ON, Profile, resolve
_UNIT_DIRS = ("/usr/lib/systemd/system/", "/lib/systemd/system/",
"/etc/systemd/system/")
@dataclass(frozen=True)
class ExportResult:
profile: str
on_ids: list[str]
packages: list[str]
unresolved: list[str]
rss_estimate_mo: int
def _run(argv: list[str]) -> tuple[int | None, str]:
"""rc=None => la commande n'a PAS pu s'exécuter (à distinguer d'un rc!=0,
réponse authentique). Même contrat que cli._run / observe._run_cmd."""
try:
p = subprocess.run(argv, capture_output=True, text=True, timeout=15)
return p.returncode, p.stdout
except (OSError, subprocess.SubprocessError):
return None, ""
def _package_for_unit(unit: str, run) -> str | None:
for base in _UNIT_DIRS:
rc, out = run(["dpkg", "-S", base + unit])
if rc == 0 and ":" in out:
return out.splitlines()[0].split(":", 1)[0].strip()
rc, out = run(["dpkg", "-S", unit]) # bare name (dpkg matches the path)
if rc == 0 and ":" in out:
return out.splitlines()[0].split(":", 1)[0].strip()
return None
def _installed(pkg: str, run) -> bool:
rc, out = run(["dpkg-query", "-W", "-f=${Status}", pkg])
return rc == 0 and "install ok installed" in out
def resolve_packages(manifests: dict[str, Manifest], profile: Profile | None,
pins: dict[str, str], *, run=_run,
rss_kb: dict[str, int] | None = None) -> ExportResult:
rss_kb = rss_kb or {}
on = [m for _, m in sorted(manifests.items()) if resolve(m, profile, pins) == ON]
packages: set[str] = set()
unresolved: list[str] = []
for m in on:
pkg = None
for u in m.units:
pkg = _package_for_unit(u, run)
if pkg:
break
if not pkg:
candidate = f"secubox-{m.id}"
if _installed(candidate, run):
pkg = candidate
if pkg:
packages.add(pkg)
else:
unresolved.append(m.id)
rss_mo = int(sum(rss_kb.get(m.id, 0) for m in on) / 1024)
return ExportResult(
profile=profile.name if profile else "",
on_ids=[m.id for m in on],
packages=sorted(packages),
unresolved=sorted(unresolved),
rss_estimate_mo=rss_mo,
)
def format_pkglist(r: ExportResult) -> str:
return "\n".join(r.packages)
def format_apt(r: ExportResult) -> str:
return "apt-get install -y" + ("" if not r.packages else " " + " ".join(r.packages))
def format_json(r: ExportResult) -> str:
return json.dumps({
"profile": r.profile, "on_ids": r.on_ids, "packages": r.packages,
"unresolved": r.unresolved, "rss_estimate_mo": r.rss_estimate_mo,
}, ensure_ascii=False, indent=2)

View File

@ -1,3 +1,14 @@
secubox-profiles (0.3.0-1~bookworm1) bookworm; urgency=medium
* Ship four functional profile tiers: full, lite, secure-gateway, media-lab
(seeded into /etc/secubox/profiles on install, never overwriting edits).
* New read-only CLI: secubox-profilectl export <profile> [--format
pkglist|apt|json] — the Debian package list of a profile's active modules,
for building a slim installer/image. Unresolved modules are surfaced, never
silently dropped.
-- Gerald KERMA <devel@cybermind.fr> Fri, 18 Jul 2026 12:00:00 +0200
secubox-profiles (0.2.1-1~bookworm1) bookworm; urgency=medium
* perf : /status batché — une seule commande `systemctl show` pour toutes

View File

@ -1,4 +1,5 @@
api/*.py usr/lib/secubox/profiles/api/
profiles/*.toml usr/share/secubox/profiles/
sbin/secubox-profilectl usr/sbin/
www/profiles/index.html usr/share/secubox/www/profiles/
menu.d/11-profiles.json usr/share/secubox/menu.d/

View File

@ -15,6 +15,16 @@ case "$1" in
# Phase 1 (scan) and is left untouched here.
install -d -o secubox -g secubox -m 755 /etc/secubox/profiles
# Seed reference profiles into the operator-editable dir on first
# install only — never overwrite an edited profile on upgrade.
for f in /usr/share/secubox/profiles/*.toml; do
[ -e "$f" ] || continue
dest="/etc/secubox/profiles/$(basename "$f")"
if [ ! -e "$dest" ]; then
install -o secubox -g secubox -m 644 "$f" "$dest"
fi
done
# Own dedicated socket, own service — never served by the aggregator
# (the profile engine must not depend on something it may later
# restart). Restart (not just enable) so an upgrade picks up new code.

View File

@ -0,0 +1,24 @@
name = "full"
label = "Full — loadout courant (baseline)"
on = [
"ad-guard", "admin", "aggregator", "annuaire", "appstore", "auth",
"avatar", "backup", "billets", "c3box", "cdn", "certs",
"cloner", "config-advisor", "cookies", "core", "crowdsec", "cs-bridge",
"cve-triage", "cyberfeed", "dns", "dns-guard", "dns-provider", "dpi",
"dpi-flowcap", "droplet", "exposure", "eye-remote", "eye-remote-dhcp", "fmrelay",
"frigate", "gitea", "glances", "grafana", "haproxy", "hardening",
"health-doctor", "health-prober", "hub", "identity", "interceptor", "ipblock",
"ksm", "led-heartbeat", "led-trigger", "leds", "localrecall", "lyrion",
"mail", "mcp-server", "mesh", "meshname", "metablogizer", "metabolizer",
"metacatalog", "metoblizer", "metrics", "mirror", "mitmproxy", "modem",
"module-prober", "mqtt", "nac", "ndpid", "netboot", "netdata",
"netdiag", "netifyd", "netmodes", "nettweak", "network-anomaly", "network-ready",
"nextcloud", "p2p", "peertube", "photoprism", "podcaster", "portal",
"proxypac", "publish", "qos", "reality", "repo", "reporter",
"rezapp", "roadmap", "routes", "rtty", "runtime", "rustdesk",
"saas-relay", "security-posture", "soc", "streamforge", "streamlit", "streamlit-routes",
"system", "threat-analyst", "threatmesh", "threats", "toolbox", "toolbox-mitm",
"tor", "traffic", "turn", "users", "vault", "vhost",
"vm", "vortex-dns", "vortex-firewall", "waf", "waf-ng", "waf-ratelimit",
"watchdog", "wireguard", "yacy", "zigbee", "zkp",
]

View File

@ -0,0 +1,11 @@
name = "lite"
label = "Lite — daily-driver optimisé"
on = [
"ad-guard", "admin", "aggregator", "annuaire", "auth", "backup",
"cdn", "certs", "core", "crowdsec", "cs-bridge", "dns",
"dns-guard", "dns-provider", "exposure", "haproxy", "hardening", "health-doctor",
"hub", "ipblock", "led-trigger", "leds", "mesh", "netdata",
"network-ready", "p2p", "portal", "routes", "runtime", "system",
"users", "vhost", "vortex-firewall", "waf", "waf-ng", "waf-ratelimit",
"watchdog", "wireguard",
]

View File

@ -0,0 +1,22 @@
name = "media-lab"
label = "Media Lab — box maison-média"
on = [
"ad-guard", "admin", "aggregator", "annuaire", "appstore", "auth",
"avatar", "backup", "billets", "c3box", "cdn", "certs",
"cloner", "config-advisor", "cookies", "core", "crowdsec", "cs-bridge",
"cyberfeed", "dns", "dns-guard", "dns-provider", "dpi", "dpi-flowcap",
"droplet", "exposure", "eye-remote", "eye-remote-dhcp", "fmrelay", "frigate",
"gitea", "glances", "grafana", "haproxy", "hardening", "health-doctor",
"health-prober", "hub", "identity", "ipblock", "ksm", "led-heartbeat",
"led-trigger", "leds", "localrecall", "lyrion", "mail", "mcp-server",
"mesh", "meshname", "metablogizer", "metacatalog", "metrics", "mirror",
"mitmproxy", "modem", "module-prober", "mqtt", "nac", "ndpid",
"netboot", "netdata", "netdiag", "netifyd", "netmodes", "nettweak",
"network-ready", "nextcloud", "p2p", "peertube", "photoprism", "podcaster",
"portal", "proxypac", "publish", "qos", "reality", "repo",
"reporter", "rezapp", "roadmap", "routes", "rtty", "runtime",
"rustdesk", "saas-relay", "streamforge", "streamlit", "streamlit-routes", "system",
"threats", "tor", "traffic", "turn", "users", "vault",
"vhost", "vm", "vortex-dns", "vortex-firewall", "waf", "waf-ng",
"waf-ratelimit", "watchdog", "wireguard", "yacy", "zigbee", "zkp",
]

View File

@ -0,0 +1,7 @@
name = "secure-gateway"
label = "Secure Gateway — appliance durcie"
on = [
"aggregator", "auth", "certs", "core", "crowdsec", "dns",
"dns-guard", "dns-provider", "exposure", "haproxy", "hardening", "ipblock",
"users", "vortex-firewall", "waf", "waf-ng", "waf-ratelimit",
]

View File

@ -154,6 +154,44 @@ def test_scan_handles_genuinely_empty_lxc_ls_without_false_alarm(root, capsys, m
assert m.runtime == "native"
def test_export_pkglist_from_tmp_root(tmp_path, monkeypatch, capsys):
import api.cli as cli
import api.export as export
root = tmp_path / "etc-secubox"
(root / "modules.d").mkdir(parents=True)
(root / "profiles").mkdir(parents=True)
(root / "modules.d" / "waf.toml").write_text(
'id="waf"\ncategory="security"\nruntime="native"\nexposure="lan"\n'
'units=["secubox-waf.service"]\nprotected=false\n')
(root / "modules.d" / "auth.toml").write_text(
'id="auth"\ncategory="security"\nruntime="native"\nexposure="lan"\n'
'units=["secubox-auth.service"]\nprotected=true\n')
(root / "profiles" / "p.toml").write_text('name="p"\non=["waf"]\n')
def fake_run(argv):
if argv[:2] == ["dpkg", "-S"]:
if "secubox-waf.service" in argv[2]:
return 0, "secubox-waf: " + argv[2] + "\n"
if "secubox-auth.service" in argv[2]:
return 0, "secubox-auth: " + argv[2] + "\n"
return 1, ""
return None, ""
monkeypatch.setattr(export, "_run", fake_run)
rc = cli.main(["--root", str(root), "export", "p", "--format", "pkglist"])
out = capsys.readouterr().out.strip().splitlines()
assert rc == 0
assert out == ["secubox-auth", "secubox-waf"] # protected auth + listed waf
def test_export_unknown_profile_errors(tmp_path):
import api.cli as cli
root = tmp_path / "etc-secubox"
(root / "modules.d").mkdir(parents=True)
(root / "profiles").mkdir(parents=True)
assert cli.main(["--root", str(root), "export", "nope"]) == 2 # StateError -> rc 2
def test_scan_refuses_when_not_root(root, capsys, monkeypatch):
# lxc-ls non-root répond rc=0 avec une sortie vide, indistinguable d'une
# box sans conteneur : sur les 24 conteneurs de cette box, ça dériverait

View File

@ -0,0 +1,112 @@
# 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 json
from api.export import (ExportResult, format_apt, format_json, format_pkglist,
resolve_packages)
from api.manifest import Manifest
from api.state import Profile
def _m(mid, units=(), protected=False):
return Manifest(id=mid, category="infra", runtime="native",
exposure="lan", units=tuple(units), protected=protected)
def _fake_run(mapping):
"""mapping: unit-or-path substring -> package name (dpkg -S hit).
dpkg-query -W install-check succeeds for packages in `mapping.values()`."""
pkgs = set(mapping.values())
def run(argv):
if argv[:2] == ["dpkg", "-S"]:
target = argv[2]
for key, pkg in mapping.items():
if key in target:
return 0, f"{pkg}: {target}\n"
return 1, ""
if argv[:1] == ["dpkg-query"]:
pkg = argv[-1]
return (0, "install ok installed") if pkg in pkgs else (1, "")
return None, ""
return run
def test_resolve_maps_units_to_packages_deduped_sorted():
manifests = {
"auth": _m("auth", ["secubox-auth.service"], protected=True),
"waf": _m("waf", ["secubox-waf.service"]),
"off1": _m("off1", ["secubox-off1.service"]),
}
profile = Profile(name="p", label="p", on=frozenset({"waf"})) # off1 not listed
run = _fake_run({"secubox-auth.service": "secubox-auth",
"secubox-waf.service": "secubox-waf"})
r = resolve_packages(manifests, profile, {}, run=run)
assert r.on_ids == ["auth", "waf"] # protected auth + listed waf, off1 excluded
assert r.packages == ["secubox-auth", "secubox-waf"]
assert r.unresolved == []
def test_fallback_to_secubox_id_when_no_unit():
# core is protected (always ON) and has no units → must fall back to the
# secubox-<id> package name, verified installed via dpkg-query.
manifests = {"core": _m("core", units=(), protected=True)}
def run(argv):
if argv[:1] == ["dpkg-query"]:
return (0, "install ok installed") if argv[-1] == "secubox-core" else (1, "")
if argv[:2] == ["dpkg", "-S"]:
return 1, "" # no unit → no dpkg -S hit
return None, ""
r = resolve_packages(manifests, None, {}, run=run)
assert r.packages == ["secubox-core"]
assert r.unresolved == []
def test_truly_unknown_module_is_unresolved_never_fabricated():
manifests = {"ghost": _m("ghost", ["secubox-ghost.service"])}
profile = Profile(name="p", label="p", on=frozenset({"ghost"}))
def run(argv):
if argv[:2] == ["dpkg", "-S"]:
return 1, "" # dpkg -S: not found
if argv[:1] == ["dpkg-query"]:
return 1, "" # secubox-ghost NOT installed
return None, ""
r = resolve_packages(manifests, profile, {}, run=run)
assert r.packages == []
assert r.unresolved == ["ghost"]
def test_dpkg_cannot_run_degrades_to_unresolved_not_a_package():
manifests = {"waf": _m("waf", ["secubox-waf.service"])}
profile = Profile(name="p", label="p", on=frozenset({"waf"}))
def run(argv):
return None, "" # rc=None everywhere: command could not run
r = resolve_packages(manifests, profile, {}, run=run)
assert r.packages == []
assert r.unresolved == ["waf"]
def test_rss_estimate_summed_over_on_set():
manifests = {"a": _m("a", ["secubox-a.service"]),
"b": _m("b", ["secubox-b.service"], protected=True)}
profile = Profile(name="p", label="p", on=frozenset({"a"}))
run = _fake_run({"secubox-a.service": "secubox-a", "secubox-b.service": "secubox-b"})
r = resolve_packages(manifests, profile, {}, run=run,
rss_kb={"a": 20480, "b": 10240}) # 20 + 10 Mo
assert r.rss_estimate_mo == 30
def test_formatters():
r = ExportResult(profile="lite", on_ids=["auth", "waf"],
packages=["secubox-auth", "secubox-waf"],
unresolved=["ghost"], rss_estimate_mo=42)
assert format_pkglist(r) == "secubox-auth\nsecubox-waf"
assert format_apt(r) == "apt-get install -y secubox-auth secubox-waf"
doc = json.loads(format_json(r))
assert doc["profile"] == "lite" and doc["packages"] == ["secubox-auth", "secubox-waf"]
assert doc["unresolved"] == ["ghost"] and doc["rss_estimate_mo"] == 42

View File

@ -0,0 +1,34 @@
# 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.
from pathlib import Path
import pytest
from api.state import load_profile
PROFILES_DIR = Path(__file__).resolve().parent.parent / "profiles"
PROFILE_FILES = sorted(PROFILES_DIR.glob("*.toml"))
EXPECTED = {"full", "lite", "secure-gateway", "media-lab"}
def test_all_four_profiles_ship():
assert {p.stem for p in PROFILE_FILES} == EXPECTED
@pytest.mark.parametrize("path", PROFILE_FILES, ids=lambda p: p.stem)
def test_profile_loads_and_name_matches_stem(path):
prof = load_profile(path) # raises StateError if name != stem or bad 'on'
assert prof.name == path.stem
assert prof.on, f"{path.stem}: empty on-list"
for mid in prof.on:
assert mid and mid == mid.strip() and " " not in mid, f"bad id {mid!r}"
def test_protected_core_present_in_every_profile():
# aggregator/auth/core are protected (always ON) but every functional tier
# lists them explicitly for readability; guard that nobody drops them.
for path in PROFILE_FILES:
on = load_profile(path).on
assert {"core", "auth", "aggregator"} <= on, f"{path.stem} missing protected"