Plan: hook action lists + runtime refs (full migration, no legacy)
Status: IMPLEMENTED (#79 + phase 8; ref:// + dns:// runtime refs still unbuilt).
Some lifecycle work is more than “run a job after the service is up.” The
driving case is keycloak admin self-heal: when the stored admin password
has diverged from vault, the only supported, no-internal-schema fix is to take
keycloak down, create a temp admin with kc.sh bootstrap-admin (which
refuses to run against a live server), bring it back up, then reset the real
admin with kcadm — i.e. an ordered sequence that scales the service down
and back up around a job, and only on a real mismatch.
SHC already has every primitive for this — the job runner, and the
StackController/x-shc-scale scale window the backup module uses to stop
a service, do work, and restore its previous replica count. What’s missing is a
way for an app’s hooks to express that orchestration. Today a hook is just a
list of job-name strings (hooks.post_install: [keycloak-init],
internal/modules/app/hooks.go), and a post-up job can’t stop/restart its own
service (install_runner.go:623).
This proposal makes a hook an ordered list of actions declared in
app.yaml, with a small extensible action vocabulary, a per-run snapshot so
actions can be stateful, failure-safe restore, and a ref+hook:// runtime
reference convention for dynamic values. It generalizes beyond keycloak:
any app can now declare “stop me, do exclusive offline work, start me back to
how I was.”
No legacy. The action-list schema is the only hook schema. The old
string-list form is removed (not shimmed), and every bundled app’s hooks:
block is migrated as part of this change.
The model
Section titled “The model”A hook phase is an ordered list of actions. Each action is a typed step the runner executes in declaration order, sharing one per-run context.
hooks: post_install: - job: keycloak-init # run a *.job.yaml - scale: { keycloak: 0 } # scale a service (snapshots prior count) - job: keycloak-bootstrap-admin - scale: { keycloak: "ref+hook://previous" } # back to the pre-hook count - job: keycloak-recover-adminAction vocabulary (initial)
Section titled “Action vocabulary (initial)”| action | shape | does |
|---|---|---|
job | { job: <name> } | run a *.job.yaml (the only thing hooks did before) |
scale | { scale: { <service>: <n | ref> } } | scale a service to n replicas via the shared scaler; captures the service’s prior count into the run snapshot the first time it’s touched |
halt | a job emits ::shc-hook:halt:: on its own line (the primary form); an explicit { halt: <when> } step is a secondary unconditional stop | stop the rest of the sequence successfully (the conditional primitive) |
The vocabulary is open: backup, wait-healthy, exec, conditionals, etc.
are future additions. Each new action is a thin wrapper over an existing
capability (backup orchestrator, healthcheck poll, …) plus a registry entry —
the runner + context are the real investment, the verbs are cheap to add. We
ship job/scale/halt now and grow on demand (YAGNI).
Per-run snapshot + failure-safe restore
Section titled “Per-run snapshot + failure-safe restore”When the hook fires, the runner opens a snapshot. The first time a scale
action touches a service, the service’s current replica count is captured
into the snapshot (this is exactly what the backup EnterScaleWindow does with
Normal). ref+hook://previous resolves against that snapshot — the value at
hook entry, not “the previous step,” so it’s unambiguous no matter how many
scale steps ran.
Restoration is failure-safe, mirroring the backup window’s deferred
scale-back: any service captured during a run is auto-restored to its snapshot
count when the hook ends, including on a mid-sequence failure, with a loud log
if the restore itself fails. So scale: ref+hook://previous is a convenience
for restoring mid-sequence (keycloak must come back up before kcadm); the
safety net does not depend on the author writing it.
Conditional execution (the “only on mismatch” property)
Section titled “Conditional execution (the “only on mismatch” property)”A job step can halt the remaining sequence. The signal is a
GitHub-Actions-style marker the job emits on its own line — echo '::shc-hook:halt::' — chosen because that ::command:: form is the
recognized convention for a step signaling its runner over captured stdout
(GHA workflow commands), so it reads as a convention, not a magic token. The
namespace (shc-hook:) makes a collision with real output a non-issue, and the
command shape leaves room for future signals (::shc-hook:set name=x::v).
This gives the runner a clean 3-outcome RunJob contract, matched on a
whole (trimmed) line, not a substring:
| job result | runner does |
|---|---|
exit 0, no marker | continue to the next step |
exit 0 + a ::shc-hook:halt:: line | halt — stop the sequence, success |
| non-zero exit | abort — fail the hook + failure-safe restore |
That’s how keycloak stays zero-downtime in the common case: the first job logs
in with the vault value; on a match it seeds the realm and emits the halt
marker, so the scale-down + bootstrap-admin never run. On a 401 it exits 0
with no marker → the runner continues into the scale/reset steps. On an
unreachable server it exits non-zero → abort, no reset.
The marker is the curated halt vocabulary — one constant + doc, not scattered greps.
Runtime references (ref+hook://…)
Section titled “Runtime references (ref+hook://…)”These are not “magic strings.” By using the ref+<scheme>:// form they are
first-class runtime references — the same family as ref+vault:// secret
refs (internal/core/config/secrets.go), distinguished by when and who
resolves them:
| scheme | resolved by | resolved when | |
|---|---|---|---|
| secret ref | ref+vault://key?autogen | secret resolver | render time (install) |
| runtime ref | ref+hook://previous | the hook runner | run time (step execution) |
- Syntax: reuse the existing
ref+<scheme>://<path>parser. - Scheme-keyed registry (general, not hook-only): one registry keyed by
scheme, each scheme a resolver namespace. The hook runner registers
hook; future schemes (node,cluster,dns) register their own resolvers without reworking anything. A resolver gets the path + the surrounding context (which service is being scaled, which node a deploy targets) and returns the value. - Naming convention (so it stays consistent):
ref+<scheme>://<path>, everything lowercase kebab-case, with the entity as the scheme and the attribute as the path — so paths stay short and there’s nonodeid-vs-mesh-ipdrift:ref+hook://previous— the snapshot replica count of the scaled serviceref+node://ip/ref+node://id/ref+node://mesh-ip— the node in context (e.g. the node a stack is deploying onto)ref+cluster://leader-ip/ref+cluster://node-countKebab (notnode_id) because these are URI paths and a curated SHC vocabulary — URI-idiomatic and uniform. (This differs fromref+vault://keys, which are snake_case because they mirror operator-defined env/secret names, not a fixed vocabulary.)
- Context-dispatch: the scheme’s resolver uses the surrounding context to
pick which entity —
previousknows which service,ref+node://ipknows which node — exactly likeref+vault://keys are scoped by tenant/stack/env. - Resolution ordering (important):
ref+<runtime-scheme>://values must survive the install-time render + secret-resolution pass untouched and be resolved only at run time. The render/secret resolver must treat unknown (runtime) schemes as opaque (not “unknown provider → error”). This is the one cross-cutting wiring detail to get right.
The first scheme + token we build is ref+hook://previous. node/cluster/
dns schemes are real future candidates (see Extension points) and slot into
the same scheme-keyed registry as ~20-line resolvers.
Worked example: keycloak admin self-heal
Section titled “Worked example: keycloak admin self-heal”hooks: post_install: &reconcile - job: keycloak-init # UP: login with vault value # match → seed realm, HALT # mismatch → continue ↓ - scale: { keycloak: 0 } # snapshot taken; keycloak DOWN - job: keycloak-bootstrap-admin # kc.sh bootstrap-admin user tmpadmin (works: server down) - scale: { keycloak: "ref+hook://previous" } # back to pre-hook count - job: keycloak-recover-admin # UP: kcadm as tmpadmin → set admin = vault → delete tmpadmin → seed realm post_update: *reconcile post_restore: *reconcileCommon case: one cheap login check, halt, done — no scaling, no downtime.
Divergence: the only time keycloak briefly scales to zero, using supported
tooling, no CREDENTIAL-table writes. This is the “scale-to-0 → CLI →
scale-back, only on mismatch” flow, expressed declaratively.
Terminology — two “down” primitives at two granularities:
- scale (service-level):
--scale svc=N— brings ONE service’s replicas to 0. The hookscaleaction uses this.- stop/start (deployment-level):
docker compose stop/start(swarm: scale the whole stack’s services to 0) — halts the whole deployment (StackController.Stop/Start). This is a real internal primitive:restoreuses it (stop deployment → replay → start), and it’s exposed as theshc stack stop/startoperation.The keycloak self-heal scales the keycloak service to 0 (service-level) — it does not stop the whole deployment. Both are primitives; neither fires hooks (see Operations vs. primitives).
Canonical hook phases
Section titled “Canonical hook phases”Hooks fire on operations (user / API-initiated lifecycle actions), each with
a pre_ and post_ phase, all declared in app.yaml hooks: under the one
action schema. The full set:
| operation | hooks | status |
|---|---|---|
| install | pre_install / post_install | exists |
| update | pre_update / post_update | exists |
| uninstall | pre_uninstall / post_uninstall | exists |
| backup | pre_backup / post_backup | exists — unify out of backup.yaml |
| restore | pre_restore / post_restore | exists — unify out of backup.yaml |
| recover | pre_recover / post_recover | add (restore-from-file; wraps restore, so its pre/post_restore fire inside) |
| move | pre_move / post_move | add |
| clone | pre_clone / post_clone | add (sanitize the clone — fresh identity, scrub creds) |
| scale | pre_scale / post_scale | add |
| rollback | pre_rollback / post_rollback | add |
Plus the existing post_deployed.
Operations vs. primitives (why scale is hooked but start/stop aren’t)
Section titled “Operations vs. primitives (why scale is hooked but start/stop aren’t)”Hooks fire on operations, never on the internal primitives operations are built from:
- Primitives — the scaler (
--scale svc=N, service-level) andStackController.Stop/Start(compose stop/start, deployment-level). Called by operations and by the hookscaleaction. They do not fire hooks. - The hook
scaleaction drives the scaler primitive directly, so ascalestep inside a hook does not re-firepre/post_scale— no recursion. Only the user-facingshc stack scaleoperation fires them.
start/stop get no pre/post_start/pre/post_stop phases — but not
because they’re “just CLI/HUD.” They’re a real deployment-level primitive that
restore uses internally; since hooks fire on operations and never on the
internal primitive, restore’s stop/start would not trigger them anyway. We
simply don’t see a side-effect use for hooking the operation (the keycloak
case uses a service-level scale, not a deployment stop), so we leave the
phases out. (restart = stop+start, also no own hook.)
Migration (no legacy)
Section titled “Migration (no legacy)”- Replace the hook schema/parser:
hooks.<phase>is[]Action, not[]string. Remove the string-list code path entirely. - Unify backup/restore hooks: move
pre/post_backup+pre/post_restorefrombackup.yaml jobs:+ theloadBackupHookJobsloader intoapp.yaml hooks:under the action schema. Kill the second parser. - Add the new phases to
AllHooks+ wirepre/post_{recover,move,clone,scale, rollback}call sites at their operations. - Convert every bundled app’s
app.yamlhooks:block to the action form (the common case is a one-liner:- keycloak-init→- job: keycloak-init). - No dual-format acceptance, no deprecation window. The action form is the schema.
Implementation phases
Section titled “Implementation phases”-
Schema + parser —
Actiondiscriminated type (job/scale/halt), replace the[]stringhook field; reject the old form. Unit tests. -
Shared scaler seam — lift the scaler primitive (
StackScaler/EnterScaleWindow,--scale svc=N) out ofinternal/modules/backup(or its daemon adapter) into a place the hook runner and backup both call. Hooks get scale-to-N + snapshot/restore. (This is the replica scaler, NOT theStackControllercompose stop/startprimitive — that one stays internal to the restore/start/stop operations.) -
Hook runner —
runAppHookJobsexecutes actions in order over a per-run snapshot;scalecaptures/sets; failure-safe auto-restore;haltshort-circuits. -
ref+hook://resolver — registry +previous; ensure render/secret pass leaves thehookscheme opaque. -
Keycloak rewire — split
keycloak-initinto check(halt-on-match) +keycloak-bootstrap-admin+keycloak-recover-admin; wire the sequence. Also teachvalidateHooksthe action shapes (it currently stringifies hook entries, so it must understand{job|scale|halt}maps). -
Hook-phase coverage — add
pre/post_{recover,move,clone,scale,rollback}toAllHooks+ wire their call sites; unifypre/post_backup+pre/post_restoreout ofbackup.yaml/loadBackupHookJobsinto the action schema (kill the second parser).Shipped (#79): The backup/restore UNIFICATION is done — pre/post_backup
- pre/post_restore fire through the app action runner (app.RunStackHook,
wired in the daemon + CLI), every app’s jobs migrated to
app.yaml hooks: <name>.job.yaml. Deviation: therestore:file-prep PHASE stays a backup primitive inbackup.yaml(it owns the @converter anti-wipe staging coupled toconverters:, and is absent from the operations table above) — soloadBackupHookJobsnarrows to that one phase rather than being deleted. The five operation phases are inAllHooks+AllowedHookContexts+appHooksDoc(declared, validated, parseable) AND their firing is now wired at every operation, viaapp.RunStackOperationHook(self-building deployed-stack ctx over the sharedrunAppHookJobs):
- scale —
Service.Scalefires pre_scale before the scaler, post_scale after the replica write (success-only). In-module. Thescale:hook ACTION drives the scaler primitive directly and does NOT re-fire (no recursion). - rollback —
rollbackFailedInstallfires pre_rollback after its install-only gate, post_rollback BEFORE artifact removal. In-module. - clone / move — fired from the clone module through a new
clone.SetStackHookFirerseam (B3: clone can’t import app), wired in the daemon + CLI. pre on the source, post on the recovered/renamed target; fast-path rename fires pre/post_move only. - recover — fired from the backup module: the sync
RecoverFromFilepath viafireStackHookPhase, the async orchestrator via the daemon’s RecoveryActions adapter (app.RunStackOperationHook). Once per recovered stack; wraps the inner install + restore hooks. pre/post wrap the outer operation; inner install/uninstall/restore hooks keep firing as before (verified no double-fire). pre_recover/pre_rollback no-op when the stack isn’t deployed yet (http/container contexts only).
- pre/post_restore fire through the app action runner (app.RunStackHook,
wired in the daemon + CLI), every app’s jobs migrated to
-
Migrate all apps — convert every
hooks:block (incl. the backup.yaml ones moved in phase 5). -
Verify — unit + full e2e; a keycloak divergence test if feasible on the harness.
Extension points (later, not now)
Section titled “Extension points (later, not now)”-
Actions:
backup(wrap the backup orchestrator),wait-healthy,exec,if/conditional steps. -
Runtime ref schemes (the scheme-keyed registry pays off here):
ref+node://ip/ref+node://mesh-ip/ref+node://id— the node in context, e.g. the IP of the node a stack is being deployed onto — a genuinely useful contextual value an app can template without knowing it at render time.ref+cluster://leader-ip/ref+cluster://node-count— cluster-wide live facts (node count → “one replica per node” scale targets).ref+dns://…— DNS already has a bespoke runtime resolver (dns.Service.ResolveTarget,service.go:63) that classifies a--hosttarget into live node IPs / advertise address / LB target. It could fold under this convention later (cosmetic, not a need).
These are real-but-later: most config is fine at render time (a redeploy re-renders). Runtime refs earn their keep only for values that track live state without a redeploy or are needed mid-operation. The scheme-keyed registry (phase 3) keeps each new scheme a small isolated resolver — so we build
hooknow and addnode/clusterwhen something needs them, not speculatively.Shipped (phase 8):
ref+node://{ip,id,mesh-ip,internal-ip,hostname}andref+cluster://{leader-ip,node-count}are now registered resolvers (runtime_refs.go). The daemon pre-resolves them off the node repository + clusterLeaderInfoand passes them downComposeRunOptions.{NodeRefs, ClusterRefs}; the app module does pure map lookups (no DB/node/cluster import for the resolver itself). They resolve in two positions: ascale:target (e.g.scale: { app: ref+cluster://node-count }) and — newly — a hook job’senvironment:value (a value that is a wholeref+…://…resolves through the registry; sibling jinja values render as before). A known key with no live value (daemonless CLI install, election in progress) resolves to""; an unknown key (author typo) fails the hook loudly.ref+dns://…remains unbuilt.