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>
135 lines
4.4 KiB
Go
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 ""
|
|
}
|