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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user