810076451f
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.
194 lines
6.4 KiB
Go
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
|
|
}
|