Files
kami 810076451f update: roll back what the restart actually deploys
On the deployment deploy/README.md documents, source_dir and install_dir are
the same tree and the restart command rebuilds the image from it. The
Dockerfile builds from cmd/ and internal/ and .dockerignore keeps the host
binaries out, so restoring the snapshotted binaries restored bytes nothing
reads. A bad commit therefore cost two health timeouts and two image builds
and ended in ErrRollbackFailed with an instruction to copy files back by hand,
which would not have helped either.

A deployment that rebuilds from source now has to say how the source is put
back. source_rollback "git" records the commit before the update and checks it
back out before the rollback restart. It refuses a dirty tree, because the
recorded commit does not describe one and a forced checkout would delete his
work. A build-from-source config that says nothing is refused by Validate, at
startup, rather than at the one rollback that mattered.

Also in this change, all from the same review:

  - MethodPing, the one method a locked daemon answers. Preflight passed on an
    unlocked daemon and the post-restart Presence read failed on a locked one,
    so a good update read as SHE IS PROBABLY DOWN once the env key is gone.
  - A dial failure is reported apart from a read failure. The documented
    socket is under /var/lib/docker, which a non-root operator cannot
    traverse, and "she is not answering" was the wrong diagnosis.
  - Verify refuses to run as root over a tree owned by someone else. It runs
    make build and make test in place, and root-owned artifacts break his next
    ordinary make.
  - A rollback no longer reverts config_files. That undid every config edit
    since the last apply, phraser.model_path among them.
  - The verify-failure path no longer reports rolled_back for a compile error.
  - waitHealthy caps each attempt at the remaining budget, so a 90s timeout
    cannot run to 99s.
  - tail cuts on a rune boundary. Russian test names showed the seam.
  - The claim that mavend does not import internal/update is replaced with
    what is enforced: mavend constructs no Updater and nothing can call Apply.
  - snapshot_dir inside source_dir is refused. It landed in the build context.

Found in review of #69.
2026-08-01 14:06:00 +04:00

101 lines
3.7 KiB
Go

package update
import (
"context"
"errors"
"fmt"
"time"
"github.com/kami/maven/internal/ipc"
)
// The health check is the whole basis for rolling back, so it has to mean
// something. "The process is running" does not: mavend can be up with a dead
// store, a socket it never bound, or a config it failed to parse. What is
// checked instead is that she answers a real read over the real IPC socket —
// which exercises the socket, the dispatch table and the store in one call.
//
// Presence is the method used because it is read-only (safe to retry), needs no
// arguments, and touches the store. It cannot write anything, so a health check
// never leaves a trace in her memory.
//
// A locked daemon is healthy. With a passkey enrolled and no env key mavend
// boots locked and refuses every CoreAPI method until an assertion arrives, so
// a Presence read there fails for a daemon that came up perfectly. Rolling back
// on that would turn a good update into the manual-recovery case, and the
// rollback would boot locked too. MethodPing reaches no store and answers in
// locked mode, so it is asked first: an answer of "locked" is proof of life and
// is where the check stops.
// ErrHealthDial — the socket could not be opened at all. Kept apart from a
// failed read because the two have different causes and different fixes: on the
// docker deployment the socket lives under /var/lib/docker, which is
// drwx--x--- root root, so a non-root operator gets EACCES before reaching
// mavend. "She is not answering" would be the wrong thing to tell him.
var ErrHealthDial = errors.New("update: cannot open the health socket")
// DialHealth connects to the mavend socket and proves someone is serving it.
func DialHealth(ctx context.Context, socket string) error {
c, err := ipc.Dial(socket)
if err != nil {
return fmt.Errorf("%w %s: %v", ErrHealthDial, socket, err)
}
defer c.Close()
// Liveness first, because it is the only question a locked daemon can
// answer. ErrUnknownMethod means an older mavend on the other end, which is
// exactly the case during a rollback to a build from before ping existed —
// fall through to the store read rather than calling that a failure.
switch p, perr := c.Ping(ctx); {
case perr == nil && p.Locked:
return nil
case perr != nil && !errors.Is(perr, ipc.ErrUnknownMethod):
return fmt.Errorf("update: health ping: %w", perr)
}
if _, err := c.Presence(ctx); err != nil {
return fmt.Errorf("update: health read: %w", err)
}
return nil
}
// waitHealthy retries the health check until it passes or the timeout elapses.
// A restart is not instantaneous — she loads a 1.7B on boot — so the first few
// failures are expected and are not a reason to roll back.
func (u *Updater) waitHealthy(ctx context.Context, timeout time.Duration) error {
deadline := u.now().Add(timeout)
delay := 500 * time.Millisecond
var last error
for {
// Cap the attempt at whatever is left of the budget, not a flat 10s: an
// attempt starting at 89s of a 90s timeout would otherwise run to 99s,
// and the caller asked for 90.
attempt := 10 * time.Second
if left := deadline.Sub(u.now()); left < attempt {
attempt = left
}
if attempt <= 0 {
if last == nil {
last = context.DeadlineExceeded
}
return fmt.Errorf("update: not healthy after %s: %w", timeout, last)
}
attemptCtx, cancel := context.WithTimeout(ctx, attempt)
err := u.health(attemptCtx, u.cfg.HealthSocket)
cancel()
if err == nil {
return nil
}
last = err
if u.now().After(deadline) {
return fmt.Errorf("update: not healthy after %s: %w", timeout, last)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(delay):
}
if delay < 5*time.Second {
delay *= 2
}
}
}