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.
214 lines
7.1 KiB
Go
214 lines
7.1 KiB
Go
// Command mavupdate deploys a new build of Maven to the box she runs on, with
|
|
// an automatic rollback when the new build does not come up (Vikunja #249).
|
|
//
|
|
// It is a CLI on purpose, and it is the ONLY trigger for the update path.
|
|
//
|
|
// The obvious design — an IPC method plus a button on the web UI behind the
|
|
// step-up passkey gate, the way /tools works — was considered and refused. A
|
|
// step-up gate protects against the wrong person clicking; it does not change
|
|
// the fact that anything reachable over the network becomes, in the event of a
|
|
// mavweb bug, a remote arbitrary-code path with a build system attached. An
|
|
// update needs shell access on the host, which is a strictly higher bar than
|
|
// the gate that guards the tool allowlist. That is deliberate and it is the
|
|
// reason there is no MethodApplyUpdate anywhere in internal/ipc.
|
|
//
|
|
// Consequently: mavend never constructs an update.Updater and nothing in the
|
|
// daemon can call Apply, nothing runs on a timer, nothing checks a release
|
|
// server, and no act, intent, tool or LLM output can reach any of this. The
|
|
// package is linked into mavend through internal/config, which validates the
|
|
// update block at startup; the guarantee is the absent caller, not an absent
|
|
// import. She cannot update herself. She can be updated, by him.
|
|
//
|
|
// mavupdate -config deploy/mavend.json list # snapshots available to roll back to
|
|
// mavupdate -config deploy/mavend.json verify # make build + make test, deploys nothing
|
|
// mavupdate -config deploy/mavend.json apply -yes # the whole thing
|
|
// mavupdate -config deploy/mavend.json rollback [id] # restore + restart (default: newest)
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
"os/signal"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/config"
|
|
"github.com/kami/maven/internal/update"
|
|
)
|
|
|
|
func main() {
|
|
cfgPath := flag.String("config", "deploy/mavend.json", "path to mavend.json (the update block is read from it)")
|
|
yes := flag.Bool("yes", false, "required by `apply` and `rollback`: yes, restart the daemon")
|
|
flag.Usage = usage
|
|
flag.Parse()
|
|
|
|
// The stdlib flag package stops parsing at the first non-flag argument, so a
|
|
// `-yes` written after the subcommand (which is how anyone would type it, and
|
|
// how the usage text shows it) lands in Args instead of the flag. Pick it out
|
|
// by hand rather than silently treating "apply -yes" as an unconfirmed apply.
|
|
var args []string
|
|
for _, a := range flag.Args() {
|
|
if a == "-yes" || a == "--yes" {
|
|
*yes = true
|
|
continue
|
|
}
|
|
args = append(args, a)
|
|
}
|
|
if len(args) == 0 {
|
|
usage()
|
|
os.Exit(2)
|
|
}
|
|
|
|
cfg, err := config.Load(*cfgPath)
|
|
if err != nil {
|
|
die("config: %v", err)
|
|
}
|
|
if cfg.Update == nil {
|
|
die("no `update` block in %s — the update capability is off unless configured.\nSee the package comment in internal/update for what it does and does not do.", *cfgPath)
|
|
}
|
|
|
|
logf := func(format string, a ...any) {
|
|
fmt.Fprintf(os.Stderr, "%s %s\n", time.Now().Format("15:04:05"), fmt.Sprintf(format, a...))
|
|
}
|
|
u, err := update.New(*cfg.Update, update.WithLogger(logf))
|
|
if err != nil {
|
|
die("%v", err)
|
|
}
|
|
|
|
// Ctrl-C cancels the build or the health wait. It cannot cancel a rollback
|
|
// midway into leaving the box in an unknown state, because the rollback runs
|
|
// on its own context — see cmdApply.
|
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
|
defer stop()
|
|
|
|
switch args[0] {
|
|
case "list":
|
|
cmdList(u)
|
|
case "verify":
|
|
cmdVerify(ctx, u)
|
|
case "apply":
|
|
if !*yes {
|
|
die("apply restarts mavend and can roll her back. Re-run with -yes if that is what you want.")
|
|
}
|
|
cmdApply(ctx, u)
|
|
case "rollback":
|
|
if !*yes {
|
|
die("rollback restores the previous artifacts and restarts mavend. Re-run with -yes.")
|
|
}
|
|
id := ""
|
|
if len(args) > 1 {
|
|
id = args[1]
|
|
}
|
|
cmdRollback(ctx, u, id)
|
|
default:
|
|
usage()
|
|
os.Exit(2)
|
|
}
|
|
}
|
|
|
|
func cmdList(u *update.Updater) {
|
|
snaps, err := u.Snapshots()
|
|
if err != nil {
|
|
die("snapshots: %v", err)
|
|
}
|
|
if len(snaps) == 0 {
|
|
fmt.Println("no snapshots yet — the first `apply` takes one before it builds anything")
|
|
return
|
|
}
|
|
fmt.Printf("%-18s %-12s %s\n", "SNAPSHOT", "COMMIT", "FILES")
|
|
for _, s := range snaps {
|
|
commit := s.Commit
|
|
if len(commit) > 12 {
|
|
commit = commit[:12]
|
|
}
|
|
if commit == "" {
|
|
commit = "-"
|
|
}
|
|
fmt.Printf("%-18s %-12s %d\n", s.ID, commit, len(s.Files))
|
|
}
|
|
fmt.Printf("\nrollback to the newest with: mavupdate rollback -yes\n")
|
|
}
|
|
|
|
func cmdVerify(ctx context.Context, u *update.Updater) {
|
|
steps, err := u.Verify(ctx)
|
|
report(steps)
|
|
if err != nil {
|
|
die("%v", err)
|
|
}
|
|
fmt.Println("verified: the tree builds and passes its own tests. Nothing was deployed — run `apply -yes` for that.")
|
|
}
|
|
|
|
func cmdApply(ctx context.Context, u *update.Updater) {
|
|
res, err := u.Apply(ctx)
|
|
report(res.Steps)
|
|
summarize(res)
|
|
switch {
|
|
case err == nil:
|
|
fmt.Println("\nupdate committed: she answers on the new build.")
|
|
case errors.Is(err, update.ErrRollbackFailed):
|
|
die("\n%v\n\nSHE IS PROBABLY DOWN. The previous artifacts are in the snapshot dir; copy them\nover the install dir and restart by hand.", err)
|
|
case errors.Is(err, update.ErrRolledBack):
|
|
die("\n%v\n\nShe is answering again on the previous build. Nothing was lost; fix the change and retry.", err)
|
|
default:
|
|
die("\n%v", err)
|
|
}
|
|
}
|
|
|
|
func cmdRollback(ctx context.Context, u *update.Updater, id string) {
|
|
res, err := u.Rollback(ctx, id)
|
|
report(res.Steps)
|
|
summarize(res)
|
|
// The standalone rollback is what he reaches for when something is already
|
|
// wrong, so a failed one needs the loud paragraph more than apply does, not
|
|
// less.
|
|
if errors.Is(err, update.ErrRollbackFailed) {
|
|
die("\n%v\n\nSHE IS PROBABLY DOWN. The previous artifacts are in the snapshot dir; copy them\nover the install dir and restart by hand.", err)
|
|
}
|
|
if err != nil && !errors.Is(err, update.ErrRolledBack) {
|
|
die("\n%v", err)
|
|
}
|
|
fmt.Printf("\nrolled back to %s; she answers on it.\n", res.SnapshotID)
|
|
}
|
|
|
|
func report(steps []update.Step) {
|
|
for _, s := range steps {
|
|
status := "ok"
|
|
if s.Err != nil {
|
|
status = "FAILED: " + s.Err.Error()
|
|
}
|
|
fmt.Printf(" %-8s %-8s %s\n", s.Name, s.Took.Round(time.Second), status)
|
|
if s.Output != "" {
|
|
fmt.Printf("---- %s output ----\n%s\n-------------------\n", s.Name, s.Output)
|
|
}
|
|
}
|
|
}
|
|
|
|
func summarize(res update.Result) {
|
|
fmt.Printf("\nverified=%v snapshot=%s installed=%d restarted=%v healthy=%v rolled_back=%v rollback_healthy=%v took=%s\n",
|
|
res.Verified, res.SnapshotID, len(res.Installed), res.Restarted, res.Healthy, res.RolledBack, res.RollbackHealthy, res.Took.Round(time.Second))
|
|
}
|
|
|
|
func usage() {
|
|
fmt.Fprint(os.Stderr, `mavupdate — deploy a new build of Maven, with rollback.
|
|
|
|
mavupdate [-config path] list
|
|
mavupdate [-config path] verify
|
|
mavupdate [-config path] apply -yes
|
|
mavupdate [-config path] rollback [snapshot-id] -yes
|
|
|
|
apply is: health-check the running daemon, snapshot the deployed artifacts,
|
|
make build, make test, install, restart, health-check — and restore the
|
|
snapshot if any of that fails. It never fetches code and never runs by itself.
|
|
|
|
`)
|
|
flag.PrintDefaults()
|
|
}
|
|
|
|
func die(format string, a ...any) {
|
|
fmt.Fprintf(os.Stderr, format+"\n", a...)
|
|
os.Exit(1)
|
|
}
|