Module Lifecycle and Hooks
This document defines the module lifecycle hook system. For module architecture and process model, see module system. For what a module author must implement, see module author contract. For manifest field definitions including the
hooksblock, see manifest schema. For core’s internal architecture (stores, identity, orchestrator), see core architecture.
The Boundary
Section titled “The Boundary”The module knows best. suctl orchestrates.
A module knows what preparation it requires before it can serve requests. It knows what cleanup is needed when it shuts down. It knows how to reconcile state when suctl restarts after an outage. It knows what to do when a dependency it relies on disappears.
suctl knows none of this. suctl knows when events happen in the orchestration lifecycle.
Hooks are the contract at this boundary. A module declares hooks in its manifest — one per event it cares about. suctl calls declared hooks at the right moment and does not call hooks that were not declared. Neither side crosses the boundary.
Complete Execution Sequence
Section titled “Complete Execution Sequence”The ordered sequence of everything suctl does from startup through shutdown. Hook callout points are marked inline — → hook: event-name. Hooks not declared by the module are silently skipped at that step.
Startup
Section titled “Startup”core starts:
read /etc/suctl/suctl.conf determine module_paths
phase 1 — discovery: scan each module_path for directories containing manifest.json load all manifests from disk into the module index
phase 2 — previously activated, now missing: for each module in activation state not found in phase 1: mark missing remove capabilities from active surface notify operator
phase 3a — evaluate requirements (two-phase, cascade-aware): pass 1 — system requirements: for each discovered module: check binaries, paths, sockets against the live system any check fails → mark unavailable, record reason pass 2 — capability requirements: build ReadySurface from modules that survived pass 1 (a capability is "available" only when its providing module is not unavailable — failures cascade: if certbot's binary is missing, nginx requiring certbot.cert.provision is also marked unavailable, with a reason naming the provider and its state) for each discovered module not yet unavailable: check requires.capabilities against ReadySurface any check fails → mark unavailable, record reason naming provider
phase 3b — activation of previously-activated modules: for each discovered module:
check for module name conflict: conflict found → mark the already-indexed record unavailable, record a warning naming both paths, do not index the duplicate, stop
requirement missing → → hook: on-requirement-missing (blocking) env: SUCTL_REQ_TYPE, SUCTL_REQ_VALUE → if hook exits 0: re-check that requirement → if still missing: mark unavailable, record reason → next module
requirements met, not previously activated → mark ready, stop here
requirements met, previously activated: compare the checksum stored in the activation flag against a fresh checksum of the installed module directory (sorted relpath + mode + content): stored == current (or stored empty: legacy/unknown) → ordinary boot: → hook: on-start (blocking) ← per-boot reconciliation; NOT pre-activate stored != current → installed content changed since activation (an effective upgrade via reinstall) → run the pre-activate arm so the new version's one-time setup runs before the module goes live: → hook: pre-activate (blocking) ← same arm as first activation hook fails → → hook: on-activate-fail (non-blocking) → mark unavailable, record reason, stop here
create private socketpair; pass child end to module as SUCTL_BROKER_FD
launch process using entrypoint declared in manifest.json wait for handshake; validate live manifest protocol matches manifest.json timeout or mismatch → → hook: on-activate-fail (non-blocking) → mark unavailable, record reason, stop here
run BIST (protocol conformance over the broker wire); strict BIST fails → → hook: on-activate-fail (non-blocking) → mark unavailable, record reason, stop here
→ hook: post-activate (blocking) hook fails → → roll back through the deactivation sequence → hook: on-activate-fail (non-blocking) → mark unavailable, record reason, stop here
mark active promote capabilities from pending to active surface record the installed-content checksum in the activation flag (so the next boot compares against exactly what is now live)
core registers its system capabilities (including `system.module.inventory`) unconditionally at startupOperator-triggered activation
Section titled “Operator-triggered activation”operator: module activate {name}
verify module is in ready state verify requirements against live system (read now, not cached) any requirement missing → report to operator, stop on-requirement-missing does not fire here — the operator sees the failure directly
walk requires.capabilities transitively: any provider is ready but not active (D48) → return CONFIRMATION_REQUIRED to caller detail: {target, providers: [<ordered closure>]} REPL renders cascade-confirm page listing every module that will enter the active set; operator confirms or cancels on confirm → re-invoke with params.confirm: true; each provider is activated in dependency order before the target follows the rest of this sequence
→ hook: pre-activate (blocking) hook fails → → hook: on-activate-fail (non-blocking) → report failure to operator, stop
create private socketpair; pass child end as SUCTL_BROKER_FD
launch process wait for handshake; validate manifest timeout or mismatch → → hook: on-activate-fail (non-blocking) → report failure to operator, stop
run BIST (protocol conformance over the broker wire); strict BIST fails → → hook: on-activate-fail (non-blocking) → report failure to operator, stop
→ hook: post-activate (blocking) hook fails → → roll back through the deactivation sequence → hook: on-activate-fail (non-blocking) → report failure to operator, stop
mark active promote capabilities to active surface report success to operatorRuntime
Section titled “Runtime”health monitor (continuous, per active module):
send health check to module socket success after a failed streak → → hook: on-health-recover (non-blocking) reset failure counter and restart budget failure: increment failure counter counter reaches 3 → → hook: on-health-fail (non-blocking) reset counter (re-arms detection for the next streak) restart budget left (health_max_restarts, default 5) → restart the module process budget exhausted → mark failed, stop the monitor
crash detection:
module process exits unexpectedly: → hook: on-crash (non-blocking) attempt restart more than 3 restarts in 60 seconds → mark failed, no further restartsModules are always-on (D57): they start at core boot and stay running until core
stops. There is no idle monitor and no idle SIGTERM — the crash detector and the
health-failure escalation above are the only runtime triggers that touch the
process. A module a health check can never reach again ends in failed, distinct
from unavailable (a pre-activation verdict). A health-driven restart does not
count against the crash-loop guard (D58).
failed is terminal under auto-reconciliation: the activation flag is left in
place, but suctl never silently re-activates a failed module on the next rescan —
that would defeat the verdict and loop. The operator recovers it by deactivating
(which drops the flag and resets the module to ready, or unavailable if its
requirements are no longer met) and then re-activating (D58).
Operator-triggered deactivation
Section titled “Operator-triggered deactivation”operator: module deactivate {name}
verify module exists in the index (core capabilities are not modules and never appear here) → hook: pre-deactivate (blocking, timeout_seconds, default 30)
send SIGTERM to module process wait for process to exit
→ hook: post-deactivate (non-blocking)
unregister capabilities from active surface mark ready (or unavailable if requirements now unmet) report success to operatorShutdown
Section titled “Shutdown”core receives shutdown signal:
stop all health monitors
for each active module: send SIGTERM wait for process exit — the module honours its own SIGTERM graceful-stop contract → hook: on-stop (non-blocking) ← symmetric with on-start at boot
stop the broker last
exitLifecycle Events
Section titled “Lifecycle Events”These are the moments suctl owns. A module that declares a hook for an event will have that hook called at exactly that moment. A module that does not declare a hook for an event is unaffected by it.
Discovery phase
Section titled “Discovery phase”| Event | When | Blocking |
|---|---|---|
on-requirement-missing | A required binary, path, socket, or config key is not found during the requirements check. One call per missing requirement. SUCTL_REQ_TYPE and SUCTL_REQ_VALUE identify which. Exit 0 triggers a re-check of that requirement. | Yes |
Boot lifecycle
Section titled “Boot lifecycle”| Event | When | Blocking |
|---|---|---|
on-start | suctl starts while the module is already activated and its installed content is unchanged since activation — fires instead of pre-activate | Yes |
on-start is the per-boot reconciliation hook (D67). It runs every time suctl starts for a module that is already in the persisted activation list, as long as the installed content has not changed. One-time setup (installing a systemd drop-in, restarting an external service) belongs in pre-activate; state that must be refreshed at every suctl boot (recreating a /run/ directory cleared on reboot, verifying an external socket is up) belongs here.
When the installed content has changed since activation — an effective upgrade via reinstall — boot runs the pre-activate arm instead of on-start (D72). Detection is by a checksum of the installed module directory recorded in the activation flag at the last successful activation and recompared against disk every boot; the verdict is never persisted (it is rederived from reality). Because the upgrade reuses the pre-activate arm, pre-activate must always leave the module in a cold-runnable state — it already does on first activation, where there is no prior /run/ either; on-start is the cheap shortcut for the unchanged case only.
Activation lifecycle
Section titled “Activation lifecycle”| Event | When | Blocking |
|---|---|---|
pre-activate | Before suctl starts the module process on true first activation, after explicit deactivation+reactivation, or at boot when the installed content changed since activation (upgrade, D72) | Yes |
post-activate | After the process is up and registered, before the health monitor starts | Yes |
on-activate-fail | Activation did not succeed for any reason | No |
pre-activate runs on true first activation, after the operator explicitly deactivates and re-activates the module (D67), and at boot when the installed content changed since activation (D72). It is the place for one-time setup: installing drop-ins, wiring external services, writing config that must persist. It does not run on every suctl boot — on-start does. Because it reruns on re-activation and on upgrade, it must be safe to rerun (idempotent) and must leave the module cold-runnable.
A failing post-activate aborts the activation: suctl rolls the module back through the deactivation sequence, marks it unavailable, and fires on-activate-fail.
Deactivation lifecycle
Section titled “Deactivation lifecycle”| Event | When | Blocking |
|---|---|---|
pre-deactivate | Before suctl sends SIGTERM or takes any deactivation action | Yes |
post-deactivate | After the module process has exited (explicit deactivation only) | No |
pre-deactivate is the drain signal. suctl holds here until the hook exits or the declared timeout expires. No in-flight operation should be interrupted.
Shutdown lifecycle
Section titled “Shutdown lifecycle”| Event | When | Blocking |
|---|---|---|
on-stop | After each module’s process exits during suctl shutdown — symmetric with on-start | No |
on-stop fires when suctl itself is shutting down, once per module, after that module’s process has been SIGTERM’d. Only the exec form is meaningful here — the module process is exiting. post-deactivate does NOT fire at shutdown; on-stop is the shutdown-time counterpart (D67).
Runtime
Section titled “Runtime”| Event | When | Blocking |
|---|---|---|
on-health-fail | After N consecutive health check failures (N = 3); fires again on each further streak while the module stays unhealthy, driving the restart escalation (up to health_max_restarts, then failed) | No |
on-health-recover | Health check succeeds after a prior failure streak (resets the restart budget) | No |
on-crash | Process exited unexpectedly, before any restart attempt | No |
The Full Event Set
Section titled “The Full Event Set”on-requirement-missing ← blocking, startup onlyon-start ← blocking, boot restart of already-activated modulepre-activate ← blocking, first activation / re-activation onlypost-activate ← blockingon-activate-failpre-deactivate ← blocking, with timeoutpost-deactivateon-stop ← suctl shutdown, symmetric with on-starton-health-failon-health-recoveron-crashEleven events. A module declares only the hooks it needs. Undeclared hooks are silently skipped. There is no overhead for hooks not declared.
Hook Execution Contract
Section titled “Hook Execution Contract”How suctl calls a hook
Section titled “How suctl calls a hook”Two forms, declared per hook in the manifest:
exec — suctl runs a script located in the module directory. The script is called with no arguments. The module directory is the working directory. Exit code 0 = success. Any non-zero exit = failure (suctl records reason and acts accordingly per blocking semantics).
capability — suctl invokes a capability declared by the same module. The capability must be declared in the module’s capabilities array. This form is only available when the module process is already running — it cannot be used for on-requirement-missing, on-start, pre-activate, on-stop, or any event that fires before or after the process runs.
Blocking semantics
Section titled “Blocking semantics”Blocking hooks: suctl waits for the hook to complete before proceeding. If the hook fails (non-zero exit or capability error), suctl records the failure. For activation hooks: activation is aborted (a failed post-activate also rolls back through the deactivation sequence). For deactivation hooks: deactivation proceeds anyway after timeout (the system must not be held hostage by a misbehaving hook).
Non-blocking hooks: suctl fires the hook and does not wait. Failures are logged but do not affect the triggering event.
Timeout
Section titled “Timeout”Every blocking hook has a timeout_seconds field. If the hook does not complete within the timeout, suctl proceeds as if the hook failed. Default timeout: 30 seconds. Authors that need longer (e.g., a pre-deactivate that drains a queue) declare it explicitly.
Environment
Section titled “Environment”All hooks receive these environment variables:
SUCTL_MODULE— the module’s short nameSUCTL_EVENT— the hook event nameSUCTL_MODULE_DIR— absolute path to the module directorySUCTL_CONF_DIR—/etc/suctl/conf.d/
Manifest Declaration
Section titled “Manifest Declaration”"hooks": { "pre-activate": { "exec": "hooks/pre-activate.sh", "timeout_seconds": 60 }, "post-activate": { "capability": "odoo.hook.post-activate" }, "pre-deactivate": { "exec": "hooks/pre-deactivate.sh", "timeout_seconds": 30 }, "on-crash": { "exec": "hooks/on-crash.sh" }, "post-deactivate": { "exec": "hooks/post-deactivate.sh" }, "on-health-fail": { "capability": "odoo.hook.health-fail" }}The hooks key is optional. A module with no hooks key behaves identically to any module that existed before this system — all hooks silently absent.
Patterns
Section titled “Patterns”These are behaviours that emerge naturally from the hook system. They are not modes. There is no manifest field that selects a pattern. The hooks a module declares and what those hooks do produce the pattern.
Preparation
Section titled “Preparation”A module that requires something to be installed or configured before it can activate declares a pre-activate hook that performs that preparation. suctl calls it before doing anything else. If the hook succeeds, activation continues. If it fails, activation is aborted with the hook’s exit reason recorded.
Example: a module that requires a Python package declares a pre-activate hook that calls pip install. On a fresh server, the first activation installs the dependency. On subsequent activations, the hook exits immediately because the package is already present. The hook reads reality — it does not cache whether it ran before.
External service sockets
Section titled “External service sockets”Every module is core-managed: core spawns the process and owns the wire. When a module’s domain work involves a socket inside an external service, creating or attaching to that socket is the module’s business — outside suctl’s boundary:
suctl-mod-odooinstalls a systemd drop-in (inpre-activate, once) that declaresRuntimeDirectory=suctl/suctl-mod-odooandExecStartPost=suctl-odoo-service.RuntimeDirectory=instructs systemd to create/run/suctl/suctl-mod-odoo/owned by the Odoo service user before every Odoo start — including after OS reboots — so no tmpfiles.d entry and noon-starthook are needed.suctl-mod-fail2banattaches to fail2ban’s existing socket directly — nothing to install.
suctl never sees these sockets: they are not declared in the manifest, not monitored, not health-checked. Core manages only the module process and its inherited socketpair.
Graceful drain
Section titled “Graceful drain”A module that handles long-running operations declares a pre-deactivate hook that waits for all in-flight work to complete before returning. suctl does not send SIGTERM until the hook exits (or times out). The hook reads live state — it does not consult a cached counter.
Reconciliation
Section titled “Reconciliation”A module that must refresh runtime state at every suctl boot declares an on-start hook. on-start runs at every suctl boot for already-activated modules — it is the reconciliation point. One-time setup that only needs to happen at first activation belongs in pre-activate, not here (D67). When systemd owns the runtime state (e.g. via RuntimeDirectory= in a drop-in), no on-start hook is needed — systemd handles it before the service starts.
Module Author Obligations
Section titled “Module Author Obligations”A module that declares a hook is responsible for:
-
Idempotency — hooks may be called multiple times. A
pre-activatehook that runs on the second activation must not fail because the first activation already did the work. Read live state; act only on what differs from intent. -
Exit code honesty — a hook that fails must exit non-zero. A hook that exits 0 despite not completing its work will cause suctl to proceed with incorrect assumptions.
-
Timeout respect — blocking hooks must complete within their declared timeout. If the hook does work that might take variable time, the timeout must be set conservatively. A hook that consistently exceeds its timeout is a broken hook.
-
No state assumptions — hooks run in fresh environments. Do not assume that a previous hook run’s in-memory state is accessible. Read from disk, from the system, from live sources.
suctl guarantees:
- Event ordering — hooks are called at exactly the documented moment, in the documented order relative to suctl’s own actions.
- No concurrent hook calls — suctl will not call two hooks on the same module simultaneously.
- Environment variables — all declared environment variables are always present, regardless of event type.
- Non-blocking hooks do not block — suctl does not wait. A slow non-blocking hook does not hold up the system.