Hooks intercept resource actions before and after they execute.
Every resource action (ensure, delete, start, stop) can be intercepted with hooks:
Hooks receive the action context and can modify errors, abort actions, or add logging.
Two more hooks fire once per client lifecycle rather than per action:
func(ctx context.Context, action Action, r Resource, args Options, err error) error
Parameters:
ctx - The context for the actionaction - The action being performed (ensure, delete, start, stop)r - The resource being operated onargs - Action options (create, force, timeout)err - Error from previous hooks or the actionConnected and Done hooks use a smaller signature; see Lifecycle Hooks.
Return:
Before hooks run in FIFO order (first added runs first). Use them for validation, logging, or aborting actions.
client.AddHookBefore(func(_ context.Context, action Action, r Resource, args Options, err error) error {
if action == ActionDelete && !confirmed {
return errors.New("deletion not confirmed")
}
return err
})
logger := slog.Default()
client.AddHookBefore(func(_ context.Context, action Action, r Resource, args Options, err error) error {
logger.Info("Starting action",
"action", action,
"resource", r.Name(),
"kind", r.Kind())
return err
})
Before hooks can see errors from earlier before hooks in the chain:
client.AddHookBefore(validateResources) // Might return error
client.AddHookBefore(func(_ context.Context, action Action, r Resource, args Options, err error) error {
if err != nil {
// Previous validation failed, skip this hook
return err
}
// Proceed with additional checks
return nil
})
After hooks run in LIFO order (last added runs first). Use them for logging results, wrapping errors, or cleanup.
logger := slog.Default()
client.AddHookAfter(func(_ context.Context, action Action, r Resource, args Options, err error) error {
if err != nil {
logger.Error("Action failed",
"action", action,
"resource", r.Name(),
"kind", r.Kind(),
"error", err)
return err
}
logger.Info("Action completed",
"action", action,
"resource", r.Name(),
"kind", r.Kind())
return nil
})
client.AddHookAfter(func(_ context.Context, action Action, r Resource, args Options, err error) error {
if err != nil {
return fmt.Errorf("%s %s failed: %w", r.Kind(), r.Name(), err)
}
return nil
})
If your hook encounters its own error, append it to the action error:
client.AddHookAfter(func(_ context.Context, action Action, r Resource, args Options, err error) error {
if cleanupErr := cleanup(); cleanupErr != nil {
return errors.Join(err, cleanupErr)
}
return err
})
A long-lived after hook (for example the DNS watcher or the healthd reloader)
runs deep inside the LIFO chain, so a raw error it returns is hard to trace back
to the hook that produced it. Tag your failures with a per-hook sentinel so
errors.Is and logs point at the source:
var ErrDNSWatcher = NewError("DNSWatcher")
client.AddHookAfter(func(ctx context.Context, action Action, r Resource, _ Options, err error) error {
if err != nil || !r.IsEnsured() {
return err // pass through; not our failure
}
ips, ipErr := r.(*Instance).WaitIPs(ctx, timeout)
if ipErr != nil {
return ErrDNSWatcher.Wrap(ipErr) // tag a real failure
}
// ...
return nil
})
Now errors.Is(err, ErrDNSWatcher) tells you which hook failed.
Only wrap a non-nil error. Wrap(nil) returns a non-nil error (an *Error
with no cause), so wrapping the incoming err on a no-op or success path
fabricates a failure that aborts the action. Wrap only when you hold a real
error; otherwise return the incoming err (which may be nil):
This is the after-hook form of the "pass through errors" rule below: a hook must never invent an error where the action actually succeeded.
Connected and Done hooks bracket the client's whole run instead of a single action. They share a smaller signature, since there is no action or resource yet:
func(err error) error
They do not fire on their own. client.Open() fires the connected hooks (call it
once after registering all hooks, before running any stack actions), and
client.Done() fires the done hooks (call it when the client's work is complete,
usually deferred):
if err := client.Open(); err != nil {
return err
}
defer func() { _ = client.Done() }()
Connected hooks run in FIFO order (first added runs first). Use them for one-time setup: starting a progress renderer, acquiring a connection-scoped resource, or validating preconditions. Each hook starts a fresh chain (it always receives a nil error); the only propagation is abort-on-error.
client.AddHookConnected(func(err error) error {
return progress.Start() // Returning an error aborts Open()
})
If any connected hook returns an error, the remaining connected hooks are skipped
and Open() returns that error — mirroring how a before hook aborts an action.
Done hooks run in LIFO order (last added runs first). Use them for teardown: stopping a progress renderer, releasing resources, or wrapping the final error. Every done hook always runs (there is no short-circuit), so cleanup is never skipped. Each hook receives the current error and may transform it.
client.AddHookDone(func(err error) error {
progress.Stop()
return err
})
Unlike before and after hooks, connected and done hooks are not inherited from GlobalClient. Each project Client starts with no-op connected/done hooks, so register them on the specific project Client whose lifecycle you want to bracket.
Before hooks run first-in-first-out:
client.AddHookBefore(checkPermissions) // Runs 1st
client.AddHookBefore(validateConfig) // Runs 2nd
client.AddHookBefore(acquireLock) // Runs 3rd
Order: checkPermissions -> validateConfig -> acquireLock
If any before hook returns an error, the action is aborted.
After hooks run last-in-first-out:
client.AddHookAfter(logBasicInfo) // Runs 3rd (inner)
client.AddHookAfter(wrapWithContext) // Runs 2nd (middle)
client.AddHookAfter(sendToMonitoring) // Runs 1st (outer)
Order: sendToMonitoring -> wrapWithContext -> logBasicInfo
Lifecycle hooks follow the same ordering rules, fired once each:
client.AddHookConnected(openRenderer) // Open(): runs 1st
client.AddHookConnected(announceStart) // Open(): runs 2nd
client.AddHookDone(flushRenderer) // Done(): runs 2nd (inner)
client.AddHookDone(stopRenderer) // Done(): runs 1st (outer)
A connected hook that returns an error aborts Open(); done hooks always all run.
dryRun := true
client.AddHookBefore(func(_ context.Context, action Action, r Resource, args Options, err error) error {
if dryRun && (action == ActionEnsure || action == ActionDelete) {
log.Printf("Would %s %s %s", action, r.Kind(), r.Name())
return errors.New("dry run mode")
}
return err
})
var completed, total int
client.AddHookBefore(func(_ context.Context, action Action, r Resource, args Options, err error) error {
total++
return err
})
client.AddHookAfter(func(_ context.Context, action Action, r Resource, args Options, err error) error {
completed++
fmt.Printf("Progress: %d/%d\n", completed, total)
return err
})
For live, per-operation progress (image pulls, lifecycle), see Progress - the after-hook is what marks each line done.
client.AddHookAfter(func(_ context.Context, action Action, r Resource, args Options, err error) error {
if errors.Is(err, ErrNotFound) && action == ActionDelete {
// Already deleted, not an error
return nil
}
return err
})
Hooks registered on GlobalClient are inherited by all projects:
globalClient.AddHookBefore(globalLogger) // All projects see this
Hooks on a project Client only affect that project:
projectA, _ := globalClient.EnsureProject("projectA", true)
projectA.AddHookBefore(projectALogger) // Only projectA
Connected and Done hooks are the exception: they are never inherited from GlobalClient and must be registered on the project Client directly (see Lifecycle Hooks).
Before and After hooks run inside the WorkerPool — multiple hooks may fire concurrently for different resources. Any state shared between hook invocations must be protected:
var mu sync.Mutex
counts := map[string]int{}
client.AddHookAfter(func(_ context.Context, action Action, r Resource, _ Options, err error) error {
mu.Lock()
defer mu.Unlock()
counts[r.Kind()]++
return err
})
Read-only closures that capture only immutable values (string literals, slices built before registration) are safe without a mutex.
Connected and Done hooks do not run in the WorkerPool. Open() and Done() fire
them synchronously on the calling goroutine, once each, so they need no mutex for
their own state.
Keep hooks simple - Complex logic should be in separate functions.
Pass through errors - If you don't modify the error, return it unchanged:
return err // Not: return nil
Append, don't replace - When adding your own error:
return errors.Join(err, myErr) // Not: return myErr
Check action type - Not all hooks need to run for all actions:
if action != ActionEnsure {
return err // Skip for other actions
}