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 }