mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-28 21:17:36 +00:00
docs(plan): profiles tiers + installer export — 3 tasks TDD
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
This commit is contained in:
parent
7803235ec7
commit
9581e0edd6
|
|
@ -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é.
|
||||
Loading…
Reference in New Issue
Block a user