banger/internal/updater/swap.go
Thales Maciel fae28e3d8b
update: docs + publish script for the self-update feature
README gets a top-level Updating section; docs/privileges.md gains
a step-by-step trust-model writeup of `banger update`. The new
scripts/publish-banger-release.sh drives the manual release cut:
build, tar, sha256sum, cosign sign-blob, verify against the embedded
public key, jq-merge into manifest.json, rclone upload to the R2
bucket. Refuses outright if the embedded key is still the placeholder
so we can't accidentally publish an unverifiable release. Also folds
in gofmt drift accumulated across the updater package and a few
sibling files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 12:43:46 -03:00

135 lines
4.4 KiB
Go

package updater
import (
"errors"
"fmt"
"os"
"banger/internal/system"
)
// previousSuffix is the filename suffix appended to the
// pre-swap binary so Rollback knows where to restore from.
// Pinned as a constant so the swap and rollback paths can't
// disagree on it.
const previousSuffix = ".previous"
// InstallTargets lists the absolute on-disk paths the updater
// writes during a swap. Hardcoded to the system-install layout —
// banger update is a system-mode operation; the developer non-
// system-mode flow doesn't go through this code path.
type InstallTargets struct {
Banger string // /usr/local/bin/banger
Bangerd string // /usr/local/bin/bangerd
VsockAgent string // /usr/local/lib/banger/banger-vsock-agent
}
// DefaultInstallTargets returns the canonical paths a system install
// uses (`banger system install` writes to these). Exposed for
// testability; production callers use it as-is.
func DefaultInstallTargets() InstallTargets {
return InstallTargets{
Banger: "/usr/local/bin/banger",
Bangerd: "/usr/local/bin/bangerd",
VsockAgent: "/usr/local/lib/banger/banger-vsock-agent",
}
}
// SwapResult records what was swapped, so Rollback knows what to
// undo. A nil SwapResult means no swap was attempted yet (nothing
// to roll back).
type SwapResult struct {
Targets InstallTargets
// SwappedTargets is the subset of Targets that were actually
// renamed into place. If the second of three Renames fails,
// SwappedTargets contains only the first; rollback unwinds in
// reverse order.
SwappedTargets []string
}
// Swap atomically replaces each of the three banger binaries with
// its staged counterpart. Order:
//
// 1. banger-vsock-agent (companion; not currently running, swap is safe)
// 2. bangerd (the to-be-restarted daemon binary)
// 3. banger (the CLI; least disruptive last)
//
// Each AtomicReplace leaves a `.previous` backup so Rollback can
// restore the prior install if a later step fails.
//
// Returns the SwapResult even on partial failure so the caller can
// drive Rollback against what HAS been swapped.
func Swap(staged StagedRelease, targets InstallTargets) (SwapResult, error) {
res := SwapResult{Targets: targets}
steps := []struct {
src, dst string
}{
{src: staged.VsockAgentPath, dst: targets.VsockAgent},
{src: staged.BangerdPath, dst: targets.Bangerd},
{src: staged.BangerPath, dst: targets.Banger},
}
for _, s := range steps {
if err := ensureParentDir(s.dst); err != nil {
return res, fmt.Errorf("prepare %s: %w", s.dst, err)
}
if err := system.AtomicReplace(s.src, s.dst, previousSuffix); err != nil {
return res, fmt.Errorf("swap %s: %w", s.dst, err)
}
res.SwappedTargets = append(res.SwappedTargets, s.dst)
}
return res, nil
}
// Rollback undoes a Swap by restoring each .previous backup in
// reverse order. Returns the joined errors of every individual
// rollback that failed; a half-rolled-back tree is the worst case
// and the operator gets enough information to fix it manually.
//
// Tolerant of partial input — passing a SwapResult that only
// recorded the first two of three swaps rolls back exactly those
// two.
func Rollback(res SwapResult) error {
var errs []error
for i := len(res.SwappedTargets) - 1; i >= 0; i-- {
dst := res.SwappedTargets[i]
if err := system.AtomicReplaceRollback(dst, previousSuffix); err != nil {
errs = append(errs, fmt.Errorf("rollback %s: %w", dst, err))
}
}
return errors.Join(errs...)
}
// CleanupBackups removes every .previous backup left behind by a
// successful update. Called after `banger doctor` confirms the new
// install is healthy — we don't keep ancient backups around forever.
func CleanupBackups(res SwapResult) error {
var errs []error
for _, dst := range res.SwappedTargets {
if err := os.Remove(dst + previousSuffix); err != nil && !os.IsNotExist(err) {
errs = append(errs, fmt.Errorf("remove %s%s: %w", dst, previousSuffix, err))
}
}
return errors.Join(errs...)
}
func ensureParentDir(p string) error {
parent := dirOf(p)
if parent == "" {
return nil
}
if _, err := os.Stat(parent); err == nil {
return nil
}
return os.MkdirAll(parent, 0o755)
}
// dirOf is a tiny path.Dir wrapper that returns "" for paths with
// no separator (so the ensure-parent logic doesn't try to mkdir(".")).
func dirOf(p string) string {
for i := len(p) - 1; i >= 0; i-- {
if p[i] == '/' {
return p[:i]
}
}
return ""
}