mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-29 13:59:40 +00:00
feat(profiles): secubox-waker activator — splash + one-wake lock + rate cap (ref #896)
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
This commit is contained in:
parent
a88dd2beae
commit
ec02088607
99
packages/secubox-profiles/api/waker.py
Normal file
99
packages/secubox-profiles/api/waker.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.
|
||||
|
||||
"""
|
||||
SecuBox-Deb :: profiles — secubox-waker : activator (réveil-sur-accès + splash)
|
||||
CyberMind — https://cybermind.fr
|
||||
|
||||
nginx route un vhost on-demand DOWN vers /_wake/<vhost>. Le waker : résout
|
||||
vhost->module, prend un verrou par-module (UN seul réveil pour N requêtes
|
||||
concurrentes), et soit signale « up » (nginx re-proxifie), soit tire le réveil
|
||||
(sudo->secubox-wakectl) et sert le splash 503+Retry-After. Cap de fréquence de
|
||||
réveil contre les tempêtes. Ne pilote JAMAIS le système en direct (webui->ctl).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import HTMLResponse, Response
|
||||
|
||||
from .lifecycle import effective_lifecycle, wake_budget
|
||||
from .manifest import Manifest, load_all
|
||||
from .observe import is_on
|
||||
from .observe import observe as _observe_one
|
||||
|
||||
DEFAULT_ROOT = Path("/etc/secubox")
|
||||
_TEMPLATE = Path(__file__).resolve().parent.parent / "templates" / "waking.html"
|
||||
_WAKE_MIN_INTERVAL_S = 20.0
|
||||
|
||||
_locks: dict[str, asyncio.Lock] = {}
|
||||
_last_wake: dict[str, float] = {}
|
||||
|
||||
|
||||
def _root() -> Path:
|
||||
"""Racine de config — surchargeable en test via SECUBOX_PROFILES_ROOT
|
||||
(même motif que api/web.py::_root)."""
|
||||
return Path(os.environ.get("SECUBOX_PROFILES_ROOT", str(DEFAULT_ROOT)))
|
||||
|
||||
|
||||
def _lock(mid: str) -> asyncio.Lock:
|
||||
lk = _locks.get(mid)
|
||||
if lk is None:
|
||||
lk = asyncio.Lock()
|
||||
_locks[mid] = lk
|
||||
return lk
|
||||
|
||||
|
||||
def _resolve(vhost: str, manifests: dict[str, Manifest]) -> str | None:
|
||||
for mid, m in manifests.items():
|
||||
if m.portal_domain == vhost:
|
||||
return mid
|
||||
return None
|
||||
|
||||
|
||||
def _fire_wake(mid: str) -> None:
|
||||
# webui->ctl : le réveil privilégié passe par le ctl root, jamais en process.
|
||||
subprocess.Popen(["sudo", "-n", "/usr/sbin/secubox-wakectl", "wake", mid, "--json"],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
|
||||
|
||||
def _splash(module: str, budget: float, retry: int) -> HTMLResponse:
|
||||
html = _TEMPLATE.read_text(encoding="utf-8").format(
|
||||
module=module, budget=int(budget), retry=retry)
|
||||
return HTMLResponse(html, status_code=503,
|
||||
headers={"Retry-After": str(retry), "Cache-Control": "no-store"})
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
app = FastAPI(title="SecuBox Waker", docs_url=None, redoc_url=None)
|
||||
|
||||
@app.get("/_wake/{vhost}")
|
||||
async def wake_vhost(vhost: str) -> Response:
|
||||
manifests = load_all(_root() / "modules.d")
|
||||
mid = _resolve(vhost, manifests)
|
||||
if mid is None:
|
||||
return Response(status_code=404)
|
||||
m = manifests[mid]
|
||||
if effective_lifecycle(m) in ("always-on", "manual"):
|
||||
return Response(status_code=502) # not a sleepable vhost -> real error
|
||||
if is_on(_observe_one(m)):
|
||||
return Response(status_code=200, headers={"X-Sbx-Wake": "up"})
|
||||
async with _lock(mid):
|
||||
now = time.monotonic()
|
||||
if now - _last_wake.get(mid, 0.0) >= _WAKE_MIN_INTERVAL_S:
|
||||
_last_wake[mid] = now
|
||||
_fire_wake(mid)
|
||||
budget = wake_budget(m)
|
||||
return _splash(mid, budget, retry=max(3, int(budget / 5)))
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
12
packages/secubox-profiles/templates/waking.html
Normal file
12
packages/secubox-profiles/templates/waking.html
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<!-- 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. -->
|
||||
<!doctype html><html lang="fr"><head><meta charset="utf-8">
|
||||
<meta http-equiv="refresh" content="{retry}">
|
||||
<title>Démarrage — {module}</title>
|
||||
<style>body{{background:#0a0a0f;color:#00d4ff;font-family:"Courier Prime",monospace;
|
||||
text-align:center;padding-top:20vh}}.b{{color:#c9a84c}}</style></head>
|
||||
<body><h1>⏳ {module} se réveille…</h1>
|
||||
<p>Budget estimé : <span class="b">~{budget}s</span>. Cette page se recharge seule.</p>
|
||||
</body></html>
|
||||
52
packages/secubox-profiles/tests/test_waker.py
Normal file
52
packages/secubox-profiles/tests/test_waker.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
# 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 secubox-waker (activator : splash + one-wake lock + budget)
|
||||
CyberMind — https://cybermind.fr
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def test_waker_up_backend_signals_proxy(monkeypatch, tmp_path):
|
||||
import api.waker as waker
|
||||
from api.observe import Actual
|
||||
monkeypatch.setenv("SECUBOX_PROFILES_ROOT", str(tmp_path))
|
||||
(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]\ndomain="demo.gk2"\n')
|
||||
monkeypatch.setattr(waker, "_observe_one", lambda m: Actual(enabled=True, active=True))
|
||||
c = TestClient(waker.app)
|
||||
r = c.get("/_wake/demo.gk2")
|
||||
assert r.status_code == 200 and r.headers.get("X-Sbx-Wake") == "up"
|
||||
|
||||
|
||||
def test_waker_down_backend_serves_splash_and_fires_one_wake(monkeypatch, tmp_path):
|
||||
import api.waker as waker
|
||||
from api.observe import Actual
|
||||
monkeypatch.setenv("SECUBOX_PROFILES_ROOT", str(tmp_path))
|
||||
(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]\ndomain="demo.gk2"\n')
|
||||
monkeypatch.setattr(waker, "_observe_one", lambda m: Actual(enabled=False, active=False))
|
||||
fired = []
|
||||
monkeypatch.setattr(waker, "_fire_wake", lambda mid: fired.append(mid))
|
||||
c = TestClient(waker.app)
|
||||
r = c.get("/_wake/demo.gk2")
|
||||
assert r.status_code == 503
|
||||
assert "Retry-After" in r.headers and r.headers["Cache-Control"] == "no-store"
|
||||
assert fired == ["demo"] # exactly one wake fired
|
||||
|
||||
|
||||
def test_waker_unknown_vhost_404(monkeypatch, tmp_path):
|
||||
import api.waker as waker
|
||||
monkeypatch.setenv("SECUBOX_PROFILES_ROOT", str(tmp_path))
|
||||
(tmp_path / "modules.d").mkdir()
|
||||
c = TestClient(waker.app)
|
||||
assert c.get("/_wake/nope.gk2").status_code == 404
|
||||
Loading…
Reference in New Issue
Block a user