The pure-logic core of `banger update`. No CLI yet; this commit
ships the steps the next commit's command will orchestrate.
* download.go — DownloadRelease fetches SHA256SUMS, parses it,
looks up the tarball's basename, then streams the tarball
through download.FetchVerified so the hash is checked on the
fly. Returns the SHA256SUMS bytes alongside so a future
cosign-verification step can validate them against an embedded
public key before trusting the hashes inside.
Also: fetchBounded for small bounded GETs (manifest, sums file,
future signature), DefaultStagingDir, EnsureStagingDir,
PrepareCleanStaging.
* stage.go — StageTarball reads gzip+tar, validates the entry
set is exactly {banger, bangerd, banger-vsock-agent} (no
extras, no missing, no path traversal, no non-regular files),
extracts at mode 0755 regardless of what the tarball claims.
StagedRelease records the resulting paths.
* swap.go — InstallTargets pins the canonical install paths
(/usr/local/bin/banger, /usr/local/bin/bangerd,
/usr/local/lib/banger/banger-vsock-agent). Swap orders the
three replacements vsock → bangerd → banger so the most
impactful binary (the CLI) goes last; each step uses
system.AtomicReplace and accumulates a SwapResult so partial
failures can be rolled back cleanly. Rollback unwinds in
reverse, joining errors so a half-rolled-back state surfaces
enough info for an operator to fix manually. CleanupBackups
removes the .previous trail after `banger doctor` confirms
the new install is healthy.
* installmeta.UpdateBuildInfo — small helper that refreshes
Version/Commit/BuiltAt on /etc/banger/install.toml without
re-running the full system install. Preserves OwnerUser/UID/
GID/Home and the original InstalledAt timestamp.
Tests: stage rejects extra entries / missing entries / path
traversal / non-regular files; happy-path stages all three at 0755
with correct contents. Swap+Rollback covers the all-three-succeed
path (then verifies .previous backups exist + rollback restores
old contents) AND the partial-failure path (third swap blocked by
a non-dir parent → SwappedTargets = 2 → rollback unwinds those
two cleanly). DownloadRelease covers happy path, tarball-not-in-
SHA256SUMS, and propagated sha256 mismatch.
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 ""
|
|
}
|