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.
This commit is contained in:
kami
2026-08-01 14:06:00 +04:00
parent 7f42cc73be
commit 810076451f
18 changed files with 683 additions and 37 deletions
+36
View File
@@ -122,3 +122,39 @@ func TestLockedCheckDefaultDenies(t *testing.T) {
t.Error("UnlockFn never ran")
}
}
// A locked daemon has to be able to say it is alive. Every CoreAPI method is
// refused before unlock, so a health check built on one of those cannot tell a
// daemon waiting for a passkey apart from a daemon that failed to start. That
// is what turned a good update into the manual-recovery case in
// internal/update. MethodPing reaches no store, so it answers either way.
func TestPingAnswersWhileLocked(t *testing.T) {
_, srv, cli, _ := newServerWithStore(t)
srv.LockedFn = func() bool { return true }
locked := errors.New("daemon locked")
srv.Check = func(_ context.Context, m Method, _ json.RawMessage) error {
switch m {
case MethodAssertStepUp, MethodUnlock, MethodPing:
return nil
default:
return locked
}
}
ctx := context.Background()
p, err := cli.Ping(ctx)
if err != nil {
t.Fatalf("Ping while locked: %v", err)
}
if !p.Alive || !p.Locked {
t.Errorf("Ping = %+v; want alive and locked", p)
}
// And the read it replaces is still refused, which is the whole point.
if _, err := cli.Presence(ctx); err == nil {
t.Error("Presence answered while locked")
}
srv.LockedFn = func() bool { return false }
if p, err := cli.Ping(ctx); err != nil || p.Locked {
t.Errorf("Ping after unlock = %+v, %v; want alive and not locked", p, err)
}
}