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.
283 lines
12 KiB
Go
283 lines
12 KiB
Go
package update
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Result — the full account of one Apply. Every field is filled in on the
|
|
// failure paths too, because "what state is my box in" is the only question that
|
|
// matters after a failed update.
|
|
type Result struct {
|
|
Verified bool
|
|
SnapshotID string // the rollback target; named even when the rollback failed
|
|
Installed []string
|
|
Restarted bool
|
|
Healthy bool
|
|
RolledBack bool
|
|
// RollbackHealthy — whether she answered again after the restore. False with
|
|
// RolledBack true is the manual-recovery case.
|
|
RollbackHealthy bool
|
|
Steps []Step
|
|
Took time.Duration
|
|
}
|
|
|
|
// Apply is the whole update, in the only order that is safe.
|
|
//
|
|
// It is called by a human running cmd/mavupdate on the box. Nothing else calls
|
|
// it: no timer, no IPC method, no web route, no act. See the package comment.
|
|
func (u *Updater) Apply(ctx context.Context) (Result, error) {
|
|
start := u.now()
|
|
res := Result{}
|
|
defer func() { res.Took = u.now().Sub(start) }()
|
|
|
|
// 0. She has to be answering before we start. Otherwise a failed update and
|
|
// a box that was already broken look identical afterwards, and the rollback
|
|
// has no baseline to prove itself against.
|
|
u.log("preflight: checking the running daemon")
|
|
if err := u.health(ctx, u.cfg.HealthSocket); err != nil {
|
|
if errors.Is(err, ErrHealthDial) {
|
|
// Not the daemon's fault and not fixed by fixing the daemon. The
|
|
// usual cause is the socket path: under a docker volume it sits in
|
|
// /var/lib/docker, which the operator's account cannot traverse.
|
|
return res, err
|
|
}
|
|
return res, fmt.Errorf("%w: %v", ErrUnhealthyBefore, err)
|
|
}
|
|
|
|
// 0b. If the source is part of what a rollback has to put back, it must be
|
|
// in a state that can be described and restored. A dirty tree is neither:
|
|
// the commit recorded in the snapshot does not say what is deployed, and a
|
|
// forced checkout on the way back would delete his uncommitted work.
|
|
commit := u.gitHead(ctx)
|
|
if u.cfg.SourceRollback == "git" {
|
|
if commit == "" {
|
|
return res, fmt.Errorf("%w: source_rollback is \"git\" but %s has no readable git HEAD", ErrSourceRollback, u.cfg.SourceDir)
|
|
}
|
|
if dirty, err := u.gitDirty(ctx); err != nil {
|
|
return res, fmt.Errorf("%w: %v", ErrDirtyTree, err)
|
|
} else if dirty {
|
|
return res, fmt.Errorf("%w: %s", ErrDirtyTree, u.cfg.SourceDir)
|
|
}
|
|
}
|
|
|
|
// 1. Snapshot what is deployed now, BEFORE the build.
|
|
//
|
|
// The order matters and it is not the obvious one. `make build` writes its
|
|
// binaries into the working tree, and on the docker deployment the working
|
|
// tree IS the install dir — so snapshotting after the build would snapshot
|
|
// the new artifacts and leave nothing to roll back to. The snapshot is the
|
|
// only thing standing between a bad build and a box that needs a screwdriver,
|
|
// so it is taken first, while the deployed bytes are still the old ones.
|
|
names := append(append([]string{}, u.cfg.Binaries...), u.cfg.ConfigFiles...)
|
|
snap, err := u.store.Save(u.cfg.InstallDir, names, commit, "pre-update")
|
|
if err != nil {
|
|
return res, err
|
|
}
|
|
res.SnapshotID = snap.ID
|
|
u.log("snapshot: %s (%d files) in %s", snap.ID, len(snap.Files), snap.Dir())
|
|
|
|
// 2. Build and test before anything is deployed. A broken tree costs time
|
|
// and nothing else — but `make build` has already overwritten the binaries in
|
|
// the tree, so restore them: otherwise a later restart by hand would deploy
|
|
// code that failed its own tests. Nothing has been restarted, so this is a
|
|
// file restore with no restart and no health check.
|
|
steps, err := u.Verify(ctx)
|
|
res.Steps = append(res.Steps, steps...)
|
|
if err != nil {
|
|
// RolledBack stays false here on purpose. Nothing was installed and
|
|
// nothing was restarted, so there is no rollback to report; a compile
|
|
// error printing rolled_back=true sends the operator looking for a
|
|
// restart that never happened. The log line carries what was done.
|
|
if rerr := u.restoreBinaries(snap); rerr != nil {
|
|
u.log("verify failed and the artifacts could not be put back: %v — the previous ones are in %s", rerr, snap.Dir())
|
|
} else {
|
|
u.log("verify failed; the previously deployed artifacts are back in place, she was never restarted")
|
|
}
|
|
return res, err
|
|
}
|
|
res.Verified = true
|
|
|
|
// 3. Install. Per-file temp+rename, so an interruption leaves whole files.
|
|
// Config is snapshotted but never overwritten — an update does not get to
|
|
// replace the operator's config.
|
|
installed, err := u.install()
|
|
res.Installed = installed
|
|
if err != nil {
|
|
// Files may be half-swapped across the set, so restore before returning
|
|
// even though nothing has been restarted yet.
|
|
u.log("install failed: %v — restoring", err)
|
|
return u.rollback(ctx, snap, res, err)
|
|
}
|
|
u.log("install: %d artifact(s) into %s", len(installed), u.cfg.InstallDir)
|
|
|
|
// 4. Restart, then 5. prove she answers.
|
|
if err := u.restart(ctx, &res); err != nil {
|
|
return u.rollback(ctx, snap, res, err)
|
|
}
|
|
u.log("restart: ok, waiting for her to answer (up to %s)", u.cfg.healthTimeout())
|
|
if err := u.waitHealthy(ctx, u.cfg.healthTimeout()); err != nil {
|
|
return u.rollback(ctx, snap, res, err)
|
|
}
|
|
res.Healthy = true
|
|
u.log("health: she answers on %s — update committed", u.cfg.HealthSocket)
|
|
|
|
if err := u.store.Prune(u.cfg.KeepSnapshots); err != nil {
|
|
u.log("prune: %v (harmless)", err)
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
// Rollback restores a snapshot by id (empty = the newest) and restarts. Exposed
|
|
// separately so the operator can undo an update that verified, restarted and
|
|
// answered a Presence call but is wrong in a way no health check can see.
|
|
func (u *Updater) Rollback(ctx context.Context, id string) (Result, error) {
|
|
var snap Snapshot
|
|
var err error
|
|
if id == "" {
|
|
snaps, lerr := u.store.List()
|
|
if lerr != nil {
|
|
return Result{}, lerr
|
|
}
|
|
if len(snaps) == 0 {
|
|
return Result{}, errors.New("update: no snapshots to roll back to")
|
|
}
|
|
snap = snaps[0]
|
|
} else if snap, err = u.store.Load(id); err != nil {
|
|
return Result{}, err
|
|
}
|
|
res := Result{SnapshotID: snap.ID}
|
|
return u.rollback(ctx, snap, res, errors.New("operator asked for a rollback"))
|
|
}
|
|
|
|
// rollback restores the snapshot and restarts, then reports whether that worked.
|
|
// It depends on nothing that the update changed: file copies out of the snapshot
|
|
// dir and the same restart command. No build, no migration, no cooperation from
|
|
// the code being replaced.
|
|
func (u *Updater) rollback(ctx context.Context, snap Snapshot, res Result, cause error) (Result, error) {
|
|
// A rollback interrupted halfway is the one outcome worse than the failure
|
|
// that triggered it, so it does not inherit the caller's cancellation: a
|
|
// Ctrl-C during the health wait must not abandon the restore mid-restart.
|
|
ctx = context.WithoutCancel(ctx)
|
|
res.RolledBack = true
|
|
u.log("rollback: restoring snapshot %s over %s", snap.ID, u.cfg.InstallDir)
|
|
if err := u.restoreBinaries(snap); err != nil {
|
|
u.log("rollback: RESTORE FAILED: %v", err)
|
|
return res, fmt.Errorf("%w: %v (after %v); the previous artifacts are in %s — copy them back by hand", ErrRollbackFailed, err, cause, snap.Dir())
|
|
}
|
|
// On a deployment that rebuilds from source, putting the binaries back is
|
|
// the part that changes nothing. The source is what the restart deploys, so
|
|
// it goes back too, and it goes back before the restart that reads it.
|
|
if err := u.restoreSource(ctx, snap); err != nil {
|
|
u.log("rollback: SOURCE CHECKOUT FAILED: %v", err)
|
|
return res, fmt.Errorf("%w: %v (after %v); the tree is still on the new commit, so a restart would redeploy it — `git -C %s checkout --force %s` by hand", ErrRollbackFailed, err, cause, u.cfg.SourceDir, snap.Commit)
|
|
}
|
|
// A restore with no restart leaves the failed process running, so a failed
|
|
// restart here is still the manual-recovery case.
|
|
if err := u.restart(ctx, &res); err != nil {
|
|
u.log("rollback: RESTART FAILED: %v", err)
|
|
return res, fmt.Errorf("%w: restored %s but the restart failed: %v (after %v)", ErrRollbackFailed, snap.ID, err, cause)
|
|
}
|
|
if err := u.waitHealthy(ctx, u.cfg.healthTimeout()); err != nil {
|
|
u.log("rollback: she still does not answer: %v", err)
|
|
return res, fmt.Errorf("%w: restored %s and restarted but she does not answer: %v (after %v)", ErrRollbackFailed, snap.ID, err, cause)
|
|
}
|
|
res.RollbackHealthy = true
|
|
u.log("rollback: she answers again on the previous build (%s)", snap.ID)
|
|
return res, fmt.Errorf("%w to %s: %v", ErrRolledBack, snap.ID, cause)
|
|
}
|
|
|
|
// install copies the freshly built binaries from SourceDir into InstallDir.
|
|
//
|
|
// When the two are the same directory — the docker deployment builds the image
|
|
// from the working tree — this is a no-op by design rather than by accident: the
|
|
// artifacts are already where they belong and the restart command rebuilds the
|
|
// image from them.
|
|
func (u *Updater) install() ([]string, error) {
|
|
if filepath.Clean(u.cfg.SourceDir) == filepath.Clean(u.cfg.InstallDir) {
|
|
return u.cfg.Binaries, nil
|
|
}
|
|
var done []string
|
|
for _, name := range u.cfg.Binaries {
|
|
src := filepath.Join(u.cfg.SourceDir, name)
|
|
fi, err := os.Stat(src)
|
|
if err != nil {
|
|
return done, fmt.Errorf("update: install %s: %w (did `make build` produce it?)", name, err)
|
|
}
|
|
if _, err := copyFile(src, filepath.Join(u.cfg.InstallDir, name), fi.Mode().Perm()); err != nil {
|
|
return done, fmt.Errorf("update: install %s: %w", name, err)
|
|
}
|
|
done = append(done, name)
|
|
}
|
|
return done, nil
|
|
}
|
|
|
|
func (u *Updater) restart(ctx context.Context, res *Result) error {
|
|
u.log("restart: %v", u.cfg.RestartCmd)
|
|
out, err := u.run(ctx, u.cfg.SourceDir, u.cfg.RestartCmd)
|
|
if err != nil {
|
|
res.Steps = append(res.Steps, Step{Name: "restart", Argv: u.cfg.RestartCmd, Err: err, Output: tail(out, 4000)})
|
|
return fmt.Errorf("update: restart %v: %w", u.cfg.RestartCmd, err)
|
|
}
|
|
res.Restarted = true
|
|
res.Steps = append(res.Steps, Step{Name: "restart", Argv: u.cfg.RestartCmd})
|
|
return nil
|
|
}
|
|
|
|
// restoreBinaries puts back the built artifacts and nothing else.
|
|
//
|
|
// ConfigFiles are snapshotted and deliberately not restored. The Config doc
|
|
// says an update never replaces the operator's config, and a rollback that
|
|
// quietly reverted deploy/mavend.json would undo edits made since the last
|
|
// apply — a phraser.model_path change among them, which is how the resident
|
|
// model gets swapped. The copies stay in the snapshot dir for him to take by
|
|
// hand if the config is what he wants back.
|
|
func (u *Updater) restoreBinaries(snap Snapshot) error {
|
|
return snap.RestoreOnly(u.cfg.InstallDir, u.cfg.Binaries)
|
|
}
|
|
|
|
// restoreSource puts the working tree back on the commit the snapshot was taken
|
|
// at, for the deployments where that is what the restart command deploys. A
|
|
// no-op for every other shape.
|
|
func (u *Updater) restoreSource(ctx context.Context, snap Snapshot) error {
|
|
if u.cfg.SourceRollback != "git" {
|
|
return nil
|
|
}
|
|
if snap.Commit == "" {
|
|
return fmt.Errorf("snapshot %s records no commit, so there is nothing to check out", snap.ID)
|
|
}
|
|
u.log("rollback: checking %s back out to %s", u.cfg.SourceDir, snap.Commit)
|
|
// --force because the failed build left artifacts in the tree. Safe only
|
|
// because Apply refused to start on a dirty tree, so nothing uncommitted of
|
|
// his is in reach.
|
|
out, err := u.run(ctx, u.cfg.SourceDir, []string{"git", "checkout", "--force", snap.Commit})
|
|
if err != nil {
|
|
return fmt.Errorf("git checkout %s: %v: %s", snap.Commit, err, tail(out, 1000))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// gitDirty reports whether the working tree has uncommitted changes.
|
|
func (u *Updater) gitDirty(ctx context.Context) (bool, error) {
|
|
out, err := u.run(ctx, u.cfg.SourceDir, []string{"git", "status", "--porcelain"})
|
|
if err != nil {
|
|
return false, fmt.Errorf("git status in %s: %v", u.cfg.SourceDir, err)
|
|
}
|
|
return strings.TrimSpace(out) != "", nil
|
|
}
|
|
|
|
// gitHead records which commit produced a snapshot, for the operator's benefit.
|
|
// Best-effort: a tree without git is not a reason to refuse to snapshot.
|
|
func (u *Updater) gitHead(ctx context.Context) string {
|
|
out, err := u.run(ctx, u.cfg.SourceDir, []string{"git", "rev-parse", "HEAD"})
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(out)
|
|
}
|