Skip to content

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 hooks block, see manifest schema. For core’s internal architecture (stores, identity, orchestrator), see core architecture.


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.


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.

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 startup
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 operator
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 restarts

Modules 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: 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 operator
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
exit

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.

EventWhenBlocking
on-requirement-missingA 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
EventWhenBlocking
on-startsuctl starts while the module is already activated and its installed content is unchanged since activation — fires instead of pre-activateYes

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.

EventWhenBlocking
pre-activateBefore 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-activateAfter the process is up and registered, before the health monitor startsYes
on-activate-failActivation did not succeed for any reasonNo

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.

EventWhenBlocking
pre-deactivateBefore suctl sends SIGTERM or takes any deactivation actionYes
post-deactivateAfter 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.

EventWhenBlocking
on-stopAfter each module’s process exits during suctl shutdown — symmetric with on-startNo

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).

EventWhenBlocking
on-health-failAfter 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-recoverHealth check succeeds after a prior failure streak (resets the restart budget)No
on-crashProcess exited unexpectedly, before any restart attemptNo

on-requirement-missing ← blocking, startup only
on-start ← blocking, boot restart of already-activated module
pre-activate ← blocking, first activation / re-activation only
post-activate ← blocking
on-activate-fail
pre-deactivate ← blocking, with timeout
post-deactivate
on-stop ← suctl shutdown, symmetric with on-start
on-health-fail
on-health-recover
on-crash

Eleven events. A module declares only the hooks it needs. Undeclared hooks are silently skipped. There is no overhead for hooks not declared.


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 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.

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.

All hooks receive these environment variables:

  • SUCTL_MODULE — the module’s short name
  • SUCTL_EVENT — the hook event name
  • SUCTL_MODULE_DIR — absolute path to the module directory
  • SUCTL_CONF_DIR/etc/suctl/conf.d/

"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.


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.

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.

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-odoo installs a systemd drop-in (in pre-activate, once) that declares RuntimeDirectory=suctl/suctl-mod-odoo and ExecStartPost=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 no on-start hook are needed.
  • suctl-mod-fail2ban attaches 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.

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.

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.


A module that declares a hook is responsible for:

  1. Idempotency — hooks may be called multiple times. A pre-activate hook 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.

  2. 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.

  3. 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.

  4. 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:

  1. Event ordering — hooks are called at exactly the documented moment, in the documented order relative to suctl’s own actions.
  2. No concurrent hook calls — suctl will not call two hooks on the same module simultaneously.
  3. Environment variables — all declared environment variables are always present, regardless of event type.
  4. Non-blocking hooks do not block — suctl does not wait. A slow non-blocking hook does not hold up the system.