feat(profiles): nginx @waker snippet generator + nginx-sync verb (ref #896)

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
This commit is contained in:
CyberMind-FR 2026-07-20 12:38:46 +02:00
parent 39fa581e66
commit 8bf5152899
4 changed files with 133 additions and 0 deletions

View File

@ -0,0 +1,73 @@
# 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 génération des snippets nginx @waker (réveil-sur-accès)
CyberMind https://cybermind.fr
Un vhost sleepable routé (portal_domain) reçoit un error_page 502/504 -> @waker
(le waker sert le splash + tire le réveil). always-on/manual/sans-portal : rien
(un 502 reste un 502). Écrit atomiquement, purge les snippets orphelins.
"""
from __future__ import annotations
import os
import tempfile
from pathlib import Path
from .lifecycle import effective_lifecycle
from .manifest import Manifest
_SNIPPET = """# generated by secubox-wakectl nginx-sync — do not edit
error_page 502 504 = @waker;
location @waker {{
internal;
proxy_pass http://unix:{sock}:/_wake/{domain};
proxy_set_header Host $host;
}}
"""
def render_snippet(m: Manifest, *, waker_sock: str = "/run/secubox/waker.sock") -> str | None:
"""Snippet nginx pour `m`, ou None si `m` n'est pas un vhost sleepable routé
(portal_domain absent, ou effective_lifecycle hors eager/on-demand)."""
if m.portal_domain is None or effective_lifecycle(m) not in ("eager", "on-demand"):
return None
return _SNIPPET.format(sock=waker_sock, domain=m.portal_domain)
def _write_atomic(path: Path, text: str) -> None:
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(text)
os.replace(tmp, path)
except BaseException:
try:
os.unlink(tmp)
except FileNotFoundError:
pass
raise
def sync_snippets(*, manifests: dict[str, Manifest], out_dir: Path,
waker_sock: str = "/run/secubox/waker.sock") -> list[str]:
"""Écrit `<domain>.waker.conf` pour chaque module sleepable routé, purge les
`*.waker.conf` orphelins (dont le domaine ne correspond plus à aucun module
voulu jamais les autres fichiers du répertoire), retourne les domaines
écrits triés."""
out_dir = Path(out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
want: dict[str, str] = {}
for m in manifests.values():
snip = render_snippet(m, waker_sock=waker_sock)
if snip is not None and m.portal_domain is not None:
want[m.portal_domain] = snip
for dom, snip in want.items():
_write_atomic(out_dir / f"{dom}.waker.conf", snip)
for p in out_dir.glob("*.waker.conf"):
if p.name[: -len(".waker.conf")] not in want:
p.unlink()
return sorted(want)

View File

@ -89,7 +89,15 @@ def main(argv: list[str] | None = None) -> int:
sp = sub.add_parser("wake")
sp.add_argument("module")
sp.add_argument("--json", action="store_true")
sp2 = sub.add_parser("nginx-sync")
sp2.add_argument("--out", default="/etc/nginx/secubox-waker.d")
args = p.parse_args(argv)
if args.cmd == "nginx-sync":
from . import nginxgen
doms = nginxgen.sync_snippets(manifests=load_all(Path(args.root) / "modules.d"),
out_dir=Path(args.out))
print(f"nginx-sync: {len(doms)} vhost(s) — {', '.join(doms) or 'aucun'}")
return 0
if not _running_as_root():
print("wake doit être lancé en root (il pilote systemd/LXC).", file=sys.stderr)
return 1

View File

@ -0,0 +1,37 @@
# 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 tests génération snippets nginx @waker
CyberMind https://cybermind.fr
"""
from __future__ import annotations
def test_render_only_for_sleepable_portal(tmp_path):
from api.nginxgen import render_snippet
from api.manifest import Manifest
on_demand = Manifest(id="d", category="infra", runtime="native", exposure="public",
units=("d.service",), portal_domain="d.gk2", lifecycle="on-demand")
always = Manifest(id="a", category="infra", runtime="native", exposure="public",
units=("a.service",), portal_domain="a.gk2", lifecycle="always-on")
noportal = Manifest(id="n", category="infra", runtime="native", exposure="lan",
units=("n.service",), lifecycle="on-demand")
assert render_snippet(on_demand) is not None and "@waker" in render_snippet(on_demand)
assert render_snippet(always) is None
assert render_snippet(noportal) is None
def test_sync_writes_and_prunes(tmp_path):
from api.nginxgen import sync_snippets
from api.manifest import Manifest
out = tmp_path / "snips"; out.mkdir()
(out / "stale.gk2.waker.conf").write_text("old") # should be pruned
manifests = {"d": Manifest(id="d", category="infra", runtime="native", exposure="public",
units=("d.service",), portal_domain="d.gk2", lifecycle="on-demand")}
written = sync_snippets(manifests=manifests, out_dir=out)
assert written == ["d.gk2"]
assert (out / "d.gk2.waker.conf").exists()
assert not (out / "stale.gk2.waker.conf").exists()

View File

@ -94,3 +94,18 @@ def test_main_woken_exit_0(monkeypatch, tmp_path):
monkeypatch.setattr(w, "_running_as_root", lambda: True)
monkeypatch.setattr(w, "wake", lambda *a, **k: {"status": "woken", "module": "demo"})
assert w.main(["--root", str(tmp_path), "wake", "demo"]) == 0
def test_main_nginx_sync_writes_snippet_without_root(tmp_path):
# nginx-sync est un aller-retour config->fichiers, pas un pilotage
# systemd/LXC : il ne doit pas exiger root (contrairement à `wake`).
import api.wake as w
(tmp_path / "modules.d").mkdir()
(tmp_path / "modules.d" / "demo.toml").write_text(
'id="demo"\ncategory="infra"\nruntime="native"\nexposure="public"\n'
'units=["demo.service"]\nlifecycle="on-demand"\n'
'portal.domain="demo.gk2"\n')
out = tmp_path / "snips"
rc = w.main(["--root", str(tmp_path), "nginx-sync", "--out", str(out)])
assert rc == 0
assert (out / "demo.gk2.waker.conf").exists()