Files
Maven/internal/update/source_test.go
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

194 lines
6.4 KiB
Go

package update
import (
"context"
"errors"
"path/filepath"
"strings"
"testing"
)
// The deployment the README documents builds the image from the working tree:
// source_dir and install_dir are the same path, the Dockerfile copies cmd/ and
// internal/ and runs the build inside the builder stage, and .dockerignore
// keeps the host binaries out. Restoring binaries there restores bytes nothing
// reads. These tests pin the two acceptable outcomes: the source goes back, or
// the config is refused.
// dockerBox — a fakeBox wired the way the README wires the docker deployment.
func dockerBox(t *testing.T, sourceRollback string) (*fakeBox, Config) {
t.Helper()
b := newFakeBox(t)
// The source IS the deployment. The old commit's source is what the running
// image was built from.
b.byCommit["old-commit"] = "GOOD-SOURCE"
b.commit = "old-commit"
write(t, filepath.Join(b.root, "src", "source.go"), "BAD-SOURCE")
cfg := b.cfg()
cfg.InstallDir = cfg.SourceDir
cfg.ConfigFiles = nil
cfg.SourceRollback = sourceRollback
// The artifacts the docker shape snapshots live in the tree.
write(t, filepath.Join(b.root, "src", "mavend"), "OLD-BUILD")
return b, cfg
}
func TestValidate_RefusesABuildFromSourceDeploymentThatCannotRollBack(t *testing.T) {
_, cfg := dockerBox(t, "")
err := cfg.Validate()
if !errors.Is(err, ErrSourceRollback) {
t.Fatalf("Validate = %v; want ErrSourceRollback — a binary snapshot rolls back nothing when the restart rebuilds from the tree", err)
}
if _, err := New(cfg); !errors.Is(err, ErrSourceRollback) {
t.Fatalf("New = %v; want the same refusal", err)
}
}
func TestApply_RollbackPutsTheSourceBackBeforeTheRestart(t *testing.T) {
b, cfg := dockerBox(t, "git")
u, err := New(cfg, WithRunner(b.run), WithHealth(b.health))
if err != nil {
t.Fatal(err)
}
// The new build compiles and passes, and then does not come up — the case
// this whole package exists for.
first := true
b.healthFn = func() error {
if first {
first = false
return nil // preflight
}
if len(b.builtFromSource) > 0 && b.builtFromSource[len(b.builtFromSource)-1] == "GOOD-SOURCE" {
return nil // she answers again once the good source is deployed
}
return errors.New("she does not answer")
}
res, err := u.Apply(context.Background())
if !errors.Is(err, ErrRolledBack) {
t.Fatalf("Apply = %v; want ErrRolledBack", err)
}
if !res.RollbackHealthy {
t.Fatalf("result = %+v; want a healthy rollback", res)
}
if len(b.builtFromSource) != 2 {
t.Fatalf("restarts = %v; want the bad one and the rolled-back one", b.builtFromSource)
}
if b.builtFromSource[1] != "GOOD-SOURCE" {
t.Errorf("the rollback restarted on %q; want the previous commit's source — restoring binaries alone redeploys the bad commit", b.builtFromSource[1])
}
// And the checkout came before the restart, not after it.
var checkoutAt, restartAt = -1, -1
for i, c := range b.ran {
if strings.HasPrefix(c, "git checkout") {
checkoutAt = i
}
if c == "restart-the-thing" {
restartAt = i
}
}
if checkoutAt < 0 || restartAt < checkoutAt {
t.Errorf("commands ran = %v; want the checkout before the last restart", b.ran)
}
}
func TestApply_RefusesADirtyTreeWhenTheSourceIsTheRollbackTarget(t *testing.T) {
b, cfg := dockerBox(t, "git")
b.dirty = true
u, err := New(cfg, WithRunner(b.run), WithHealth(b.health))
if err != nil {
t.Fatal(err)
}
if _, err := u.Apply(context.Background()); !errors.Is(err, ErrDirtyTree) {
t.Fatalf("Apply on a dirty tree = %v; want ErrDirtyTree", err)
}
for _, c := range b.ran {
if strings.HasPrefix(c, "make") || c == "restart-the-thing" {
t.Errorf("a refused update still ran %q", c)
}
}
}
func TestApply_VerifyFailureDoesNotClaimARollback(t *testing.T) {
b := newFakeBox(t)
b.testErr = errors.New("exit status 1")
res, err := b.updater(t).Apply(context.Background())
if !errors.Is(err, ErrVerifyFailed) {
t.Fatalf("Apply = %v; want ErrVerifyFailed", err)
}
if res.RolledBack {
t.Error("a compile error reported rolled_back=true; nothing was installed and nothing was restarted")
}
}
func TestRollback_LeavesHisConfigAlone(t *testing.T) {
b := newFakeBox(t)
if _, err := b.updater(t).Apply(context.Background()); err != nil {
t.Fatalf("setup Apply: %v", err)
}
// He edits the config after the update — a phraser.model_path change, say.
cfgPath := filepath.Join(b.root, "install", "mavend.json")
write(t, cfgPath, `{"tick_interval":"90s"}`)
if _, err := b.updater(t).Rollback(context.Background(), ""); err != nil && !errors.Is(err, ErrRolledBack) {
t.Fatalf("Rollback: %v", err)
}
if got := read(t, cfgPath); !strings.Contains(got, "90s") {
t.Errorf("config after rollback = %q; a rollback must not revert his config", got)
}
if got := b.deployed(); got != "OLD-BUILD" {
t.Errorf("deployed binary = %q; want the binaries rolled back", got)
}
}
func TestVerify_RefusesToBuildHisTreeAsRoot(t *testing.T) {
b := newFakeBox(t)
u := b.updater(t, WithIDs(func(string) (int, uint32, error) { return 0, 1000, nil }))
if _, err := u.Verify(context.Background()); !errors.Is(err, ErrRootOnHisTree) {
t.Fatalf("Verify as root over a uid-1000 tree = %v; want ErrRootOnHisTree", err)
}
if len(b.ran) != 0 {
t.Errorf("the refusal still ran %v — root-owned artifacts would break his next make", b.ran)
}
// Root's own tree is fine, and so is a normal user.
for _, ids := range []func(string) (int, uint32, error){
func(string) (int, uint32, error) { return 0, 0, nil },
func(string) (int, uint32, error) { return 1000, 1000, nil },
} {
if _, err := b.updater(t, WithIDs(ids)).Verify(context.Background()); err != nil {
t.Errorf("Verify refused a legitimate build: %v", err)
}
}
}
func TestValidate_RefusesSnapshotsInsideTheSourceTree(t *testing.T) {
cfg := (&fakeBox{root: t.TempDir()}).cfg()
cfg.SnapshotDir = filepath.Join(cfg.SourceDir, "snaps")
if err := cfg.Validate(); err == nil {
t.Error("snapshot_dir inside source_dir was accepted; it lands in the docker build context")
}
cfg = (&fakeBox{root: t.TempDir()}).cfg()
cfg.SourceRollback = "svn"
if err := cfg.Validate(); err == nil {
t.Error("an unknown source_rollback was accepted")
}
}
func TestTail_CutsOnARuneBoundary(t *testing.T) {
s := strings.Repeat("x", 20) + "--- FAIL: TestПривет"
got := tail(s, 10)
if !utf8ValidString(got) {
t.Errorf("tail(%q) = %q; a cut mid-rune shows as a replacement character", s, got)
}
}
func utf8ValidString(s string) bool {
for _, r := range s {
if r == 0xFFFD {
return false
}
}
return true
}