fix(profiles): default lifecycle is always-on, not eager (ref #896)

On a 184-module fleet, DEFAULT_LIFECYCLE="eager" made every module with
no manifest opinion idle-sleep-eligible, including core services (admin,
gitea, nextcloud) that never opted into scale-to-zero. Flip the default
to always-on: sleep is now a strict opt-in via lifecycle="eager" or
"on-demand" declared explicitly in the manifest. scan()-derived
manifests already relied on this same default (no code change needed
there), so they inherit the safer behavior too.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
This commit is contained in:
CyberMind-FR 2026-07-20 18:11:24 +02:00
parent ed45608e0c
commit 356f8293a8
7 changed files with 55 additions and 19 deletions

View File

@ -96,8 +96,9 @@ packages/secubox-<module>/
Every module known to `secubox-profiles` (its manifest under
`/etc/secubox/modules.d/<id>.toml`) declares a `lifecycle`: `always-on`
(never stopped — forced for any `protected` module regardless of what it
declares), `eager` (starts at boot, may be idled), `on-demand` (off by
default, woken on first access), or `manual` (operator-only, via the
declares, and the **default** when a manifest omits the field, fleet-safe on
a 184-module install), `eager` (starts at boot, may be idled), `on-demand`
(off by default, woken on first access), or `manual` (operator-only, via the
`/profiles/` panel). An optional `wake_class` (`normal`/`urgent`) tunes the
idle threshold and the wake-splash budget. This is a **module-wide**
guideline, not a `secubox-profiles`-internal detail: a module choosing

View File

@ -62,9 +62,15 @@ parsed by the existing `secubox-profiles` manifest loader:
| Field | Values | Default | Meaning |
|-------|--------|---------|---------|
| `lifecycle` | `always-on` \| `eager` \| `on-demand` \| `manual` | `eager` | see below |
| `lifecycle` | `always-on` \| `eager` \| `on-demand` \| `manual` | `always-on` | see below |
| `wake_class` | `normal` \| `urgent` | `normal` | wake priority / idle grace |
> **Default changed post-pilot (#896 follow-up):** the original design below
> defaulted `lifecycle` to `eager`, which on the real 184-module fleet made
> every existing module — including ones with no manifest opinion at all —
> idle-sleep eligible. Default `always-on` is fleet-safe; `eager`/`on-demand`
> are explicit opt-ins.
- **`always-on`** — never sleeps; excluded from the sleeper. Core/gateway/auth/
aggregator/WAF, and anything `protected`. (A `protected` module is treated as
`always-on` regardless of its declared `lifecycle`.)

View File

@ -99,7 +99,7 @@ Panel webui : `/profiles/` (hybrid-dark, cf. `.claude/WEBUI-PANEL-GUIDELINES.md`
priority = 40 # 0-100
protected = false # true = jamais éteignable
needs = ["auth"]
lifecycle = "on-demand" # always-on|eager|on-demand|manual (défaut: eager)
lifecycle = "on-demand" # always-on|eager|on-demand|manual (défaut: always-on)
wake_class = "normal" # normal|urgent (défaut: normal)
Un manifeste corrigé à la main fait autorité : `scan` ne l'écrase pas sans `--force`.
@ -111,11 +111,17 @@ Chaque module déclare un `lifecycle` dans son manifeste
| `lifecycle` | Démarre au boot ? | Rendormi si idle ? | Réveillé sur accès ? |
|---|---|---|---|
| `always-on` | oui, toujours | jamais | — (jamais éteint) |
| `always-on` **(défaut)** | oui, toujours | jamais | — (jamais éteint) |
| `eager` | oui | oui (si idle et sans réponse `/idle`) | oui (déjà up en général) |
| `on-demand` | non | oui | oui — `sbxwaf` proxy vers le waker |
| `manual` | non | non | non — opérateur uniquement (`/wake` panel) |
Le défaut est `always-on`, pas `eager` : sur une flotte de 184 modules, un
manifeste sans opinion propre (donc la majorité) ne doit jamais devenir
sleep-eligible par accident. Le sommeil est un **opt-in explicite**
`eager` ou `on-demand` doit être déclaré à la main dans le manifeste pour
qu'un module participe au sleeper/waker.
Un module `protected = true` est **toujours** `always-on`, quoi que déclare
son `lifecycle` (`effective_lifecycle`, `api/lifecycle.py`) — le cœur ne dort
jamais. `wake_class = "urgent"` multiplie le seuil d'inactivité avant sommeil

View File

@ -27,7 +27,13 @@ LIFECYCLES = ("always-on", "eager", "on-demand", "manual")
WAKE_CLASSES = ("normal", "urgent")
DEFAULT_PRIORITY = 50
DEFAULT_LIFECYCLE = "eager"
# Fleet-safe default (#896 follow-up): a manifest that declares no lifecycle
# must never become sleep-eligible by accident. On a 184-module fleet,
# `eager` as the silent default made EVERY existing module idle-sleep
# eligible, including core services with no manifest opinion of their own
# (admin, gitea, nextcloud...). Sleep is now strictly opt-in: a module only
# idles/wakes if its manifest explicitly declares `eager` or `on-demand`.
DEFAULT_LIFECYCLE = "always-on"
DEFAULT_WAKE_CLASS = "normal"
# Le noyau protégé : éteindre l'un de ceux-là retire à l'utilisateur le moyen

View File

@ -1,3 +1,11 @@
secubox-profiles (0.8.1-1~bookworm1) bookworm; urgency=medium
* default lifecycle is now always-on (fleet-safe; sleep is an explicit
opt-in via eager/on-demand) — a module is never auto-slept unless its
manifest declares it (ref #896).
-- Gerald KERMA <devel@cybermind.fr> Mon, 20 Jul 2026 18:09:33 +0200
secubox-profiles (0.8.0-1~bookworm1) bookworm; urgency=medium
* Scale-to-zero (ref #896): ships the lifecycle/wake_class manifest policy

View File

@ -158,9 +158,14 @@ def _write(tmp_path, body):
return p
def test_lifecycle_defaults_eager_normal(tmp_path):
def test_lifecycle_defaults_always_on_normal(tmp_path):
# Fleet-safe default (#896 follow-up): a manifest that declares no
# lifecycle must NEVER become sleep-eligible by accident — on a
# 184-module fleet, "eager" as the silent default would eventually idle
# out core services (admin, gitea, nextcloud...) that never opted in to
# sleep. Sleep is an explicit opt-in via eager/on-demand.
m = load_manifest(_write(tmp_path, ""))
assert m.lifecycle == "eager" and m.wake_class == "normal"
assert m.lifecycle == "always-on" and m.wake_class == "normal"
def test_lifecycle_and_wake_class_parse(tmp_path):

View File

@ -34,12 +34,13 @@ from api.manifest import PROTECTED_IDS # noqa: E402
from api.state import load_profile, load_pins # noqa: E402
MANIFEST_LYRION = """
id = "lyrion"
category = "media"
runtime = "native"
exposure = "lan"
units = ["secubox-lyrion.service"]
priority = 30
id = "lyrion"
category = "media"
runtime = "native"
exposure = "lan"
units = ["secubox-lyrion.service"]
priority = 30
lifecycle = "eager"
"""
MANIFEST_AUTH = """
@ -280,11 +281,14 @@ def test_set_pin_invalid_value_400(client):
# lifecycle / wake_class / sleep_state (Task 10) — status payload extension.
# `auth` is protected (PROTECTED_IDS) => effective_lifecycle forces
# "always-on" regardless of its declared value => sleep_state "n/a". `lyrion`
# declares no lifecycle/wake_class => defaults "eager"/"normal", observed on
# (client fixture) => sleep_state "up". A third module declared "on-demand"/
# "urgent" and left UNOBSERVED by the client fixture's _observe_all stub
# proves the None -> False -> "asleep" coalescing (same philosophy as
# observe.is_on, see its docstring).
# declares lifecycle="eager" explicitly (the fleet-safe default is now
# "always-on" — see test_manifest.test_lifecycle_defaults_always_on_normal —
# so this test opts lyrion into "eager" on purpose to exercise the sleepable
# path) with no wake_class => defaults "normal", observed on (client
# fixture) => sleep_state "up". A third module declared "on-demand"/"urgent"
# and left UNOBSERVED by the client fixture's _observe_all stub proves the
# None -> False -> "asleep" coalescing (same philosophy as observe.is_on,
# see its docstring).
# ---------------------------------------------------------------------------
def test_status_includes_lifecycle_wake_class_and_sleep_state(client, root):