Rotation safety
PreApply proves a rotated credential actually works before it becomes the
config your application serves. Reach for it when validation can only tell you
a value is well-formed, not that it is good.
Why OnChange is not enough
By the time OnChange runs, Get() already returns the new value:
mamori.OnChange(func(ev mamori.Change[Config]) {
pool.Rotate(ev.New.DBPassword.Reveal()) // too late if this password is wrong
})
A password the database rejects still passes validate:"required". What is
missing is a check that runs before the swap and can say no.
Quick start
w, err := mamori.Watch[Config](ctx,
mamori.PreApply(func(ctx context.Context, ev mamori.Change[Config]) error {
if !ev.Changed("DBPassword") {
return nil
}
return pool.Ping(ctx, ev.New.DBPassword.Reveal())
}),
mamori.OnError(func(err error) {
var perr *mamori.PreApplyError
if errors.As(err, &perr) {
metrics.Inc("config_rejected")
}
}),
)
Keep the ev.Changed guard. Without it the hook re-pings the database every
time an unrelated field changes, such as a log level.
What a rejection does
Get()keeps returning the last valid config.OnChangedoes not fire.OnErrorreceives a*PreApplyError.- The next upstream change produces a fresh candidate and the hook runs again. There is no periodic retry of the same rejected value, since polling only emits when the value actually changes.
The timeout is a rejection, not an acceptance
WithPreApplyTimeout defaults to 10 seconds and cannot be removed.
mamori.WithPreApplyTimeout(30 * time.Second)
The hook runs on the reconciler goroutine, so an unbounded hook stuck on a hanging backend would stall every field’s updates, not just its own check.
On timeout mamori does not know whether the candidate works, so it rejects. A hook that always times out stalls updates loudly rather than quietly serving unverified configuration.
Do not call back into the same Watcher
The hook runs on the reconciler goroutine, which is what lets it block the
swap. Pin, PinCurrent, Unpin, and Refresh are commands serviced by that
same goroutine, so calling one from inside the hook asks it to answer while it
is busy being your hook.
This applies to OnError too, and that is the one people hit, because
“the reload was rejected, retry it” is a natural thing to write there. mamori
refuses the call rather than hanging:
Called from inside PreApply or OnError | Result |
|---|---|
Get() | Works. The supported way to read from either. |
Pin(v) | ErrReentrantCall. Nothing is pinned. |
PinCurrent() | Returns 0, which never collides with a real version. Nothing is pinned. |
Unpin() | Does nothing. It has no error to return. |
Refresh(ctx) | ErrReentrantCall. Nothing is re-resolved, whatever ctx you pass. |
OnChange is the exception and is safe: it runs on its own goroutine, so all
of these work normally from there.
mamori.OnError(func(err error) {
// Refused, not hung: this callback is running ON the goroutine that
// would have to service the refresh.
if err := w.Refresh(context.Background()); errors.Is(err, mamori.ErrReentrantCall) {
// refresh from another goroutine, or let the next reconciliation carry it
}
})
A Pin from an unrelated goroutine that merely overlaps a running hook is
unaffected: it waits and is serviced normally.
It runs on the initial load too
PreApply gates Watch’s first resolve and every Load, not only later
rotations. A rejection there makes Watch or Load return the
*PreApplyError with no watcher started.
ev.Fields is populated on that first call, so ev.Changed("DBPassword") is
true for every field set at startup and the guard above verifies the very first
credential. ev.Old is the zero value of T, since nothing was serving yet.
A hook typed for a different config than the one passed to Watch[T] fails
loudly with an error wrapping ErrInvalid, rather than silently leaving the
gate open.
Accepting both credentials during a rotation window
A service that accepts credentials rather than presenting them needs both the
old and new one to work briefly. WithHistory(1) covers this:
w, _ := mamori.Watch[Config](ctx, mamori.WithHistory(1))
func accept(presented string) bool {
for _, s := range w.History() { // current, then previous
if subtle.ConstantTimeCompare(
[]byte(presented), []byte(s.Config.APIKey.Reveal())) == 1 {
return true
}
}
return false
}
The cost is worth stating plainly: a retained snapshot holds a full copy of
T, so a rotated-out credential stays in process memory for as long as you
retain it. That is why WithHistory defaults to 0, and why 1 is right
here: it is the smallest window covering the overlap.
See also
- Forcing a refresh -
Refresh, whichPreApplygates too. - Watch for changes -
Watch,Get,OnChange,OnError. - Snapshots and pinning -
WithHistoryand pinning.