be066a4b04
internal/update applies a new build of Maven to the box she runs on and
undoes it when the new build does not come up. cmd/mavupdate is the only
trigger: a CLI the owner runs on the host.
Apply is health-check the running daemon, snapshot the deployed artifacts,
make build, make test, install, restart, health-check — and restore the
snapshot on any failure. The order is load-bearing:
- The preflight health check refuses to update a daemon that is already
not answering. Without a working baseline, a failed update and a box
that was already broken are indistinguishable, and the rollback has
nothing to prove itself against.
- The snapshot is taken BEFORE the build, because make build writes its
binaries into the working tree and on the docker deployment the tree
is the install dir — snapshotting afterwards would snapshot the new
artifacts and leave nothing to roll back to.
- Verification is make build plus make test, before anything is
deployed, so a broken tree costs time and nothing else. A failed
verify also puts the tree's artifacts back, so a later restart by
hand cannot deploy code that failed its own tests.
- The rollback depends on nothing that just changed: byte-for-byte
copies out of the snapshot dir, sha256-verified on the way in, and
the same restart command. No build, no migration, no cooperation from
the code being replaced. It also runs on an uncancellable context —
a rollback interrupted halfway is worse than the failure that caused
it. When the restore itself fails it says so and names the directory
to copy back by hand rather than reporting a tidy rollback.
Off unless configured, and the refusals are code, not documentation. The
daemon does not import this package: there is no IPC method, no web route,
no timer and no act that can start an update, so nothing Maven says or
routes reaches it. Nothing fetches code — the new version is whatever the
owner pulled into the tree. The plan's release checker, auto-update
channel and in-process crash-loop supervisor are deliberately absent; a
process cannot reliably notice that it keeps dying, and restart-on-crash
belongs to compose or systemd. The database is never snapshotted or rolled
back; schema compatibility stays store.Migrate's job.
The config is refused at load without a health socket, since an update
that cannot check its own result cannot roll back, and refused when the
snapshot dir is inside the install dir, since a restore must not read from
what the install writes.
Vikunja #249
205 lines
6.5 KiB
Go
205 lines
6.5 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 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)
|
|
}
|