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
+41 -2
View File
@@ -32,11 +32,24 @@ type fakeBox struct {
healthErrs int // remaining failures to serve
healthy bool
healthChecks int
// healthFn overrides the scripted behaviour entirely, for tests whose
// verdict depends on what the last restart actually deployed.
healthFn func() error
// deployedAtRestart records the installed bytes each time restart runs, so a
// test can prove the rollback put the old bytes back BEFORE restarting.
deployedAtRestart []string
// builtFromSource records the source the restart would have built an image
// from, each time it runs.
builtFromSource []string
ran []string
// The git half, for the deployment whose restart rebuilds from the tree.
// commit is HEAD; byCommit is what each commit's source says; dirty makes
// `git status --porcelain` report uncommitted work.
commit string
byCommit map[string]string
dirty bool
}
func newFakeBox(t *testing.T) *fakeBox {
@@ -52,7 +65,11 @@ func newFakeBox(t *testing.T) *fakeBox {
write(t, filepath.Join(root, "install", "mavend.json"), `{"tick_interval":"60s"}`)
// The source tree already contains a stale binary; `make build` overwrites it.
write(t, filepath.Join(root, "src", "mavend"), "STALE")
return &fakeBox{t: t, root: root, newBytes: "NEW-BUILD", healthy: true}
return &fakeBox{
t: t, root: root, newBytes: "NEW-BUILD", healthy: true,
commit: "cafebabecafebabecafebabecafebabecafebabe",
byCommit: map[string]string{},
}
}
func (b *fakeBox) cfg() Config {
@@ -86,19 +103,41 @@ func (b *fakeBox) run(ctx context.Context, dir string, argv []string) (string, e
}
return "ok", nil
case "git rev-parse HEAD":
return "cafebabecafebabecafebabecafebabecafebabe\n", nil
return b.commit + "\n", nil
case "git status --porcelain":
if b.dirty {
return " M internal/router/router.go\n", nil
}
return "", nil
case "restart-the-thing":
b.deployedAtRestart = append(b.deployedAtRestart, read(b.t, filepath.Join(b.root, "install", "mavend")))
// The docker shape: the restart rebuilds the image from the tree, so
// what it deploys is the source, not any binary on the host.
if src, err := os.ReadFile(filepath.Join(b.root, "src", "source.go")); err == nil {
b.builtFromSource = append(b.builtFromSource, string(src))
}
if b.restartErr != nil {
return "no such container", b.restartErr
}
return "restarted", nil
}
if len(argv) == 4 && argv[0] == "git" && argv[1] == "checkout" && argv[2] == "--force" {
content, ok := b.byCommit[argv[3]]
if !ok {
return "error: pathspec did not match", errors.New("exit status 1")
}
b.commit = argv[3]
write(b.t, filepath.Join(b.root, "src", "source.go"), content)
return "HEAD is now at " + argv[3], nil
}
return "", errors.New("unexpected command: " + strings.Join(argv, " "))
}
func (b *fakeBox) health(ctx context.Context, socket string) error {
b.healthChecks++
if b.healthFn != nil {
return b.healthFn()
}
if b.healthErrs > 0 {
b.healthErrs--
return errors.New("connection refused")