// 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 does not import internal/update, nothing runs on a timer, // nothing checks a release server, and no act, intent, tool or LLM output can // reach any of this. 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) 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) }