updater: download/stage/swap/rollback flow steps

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>
This commit is contained in:
Thales Maciel 2026-04-29 12:30:22 -03:00
parent fb6d2b1dae
commit 91af367208
No known key found for this signature in database
GPG key ID: 33112E6833C34679
5 changed files with 746 additions and 0 deletions

View file

@ -97,6 +97,30 @@ func Save(path string, meta Metadata) error {
return os.WriteFile(path, data, 0o644)
}
// UpdateBuildInfo refreshes only the Version / Commit / BuiltAt
// fields on the install metadata, preserving everything else
// (OwnerUser/UID/GID/Home and the original InstalledAt timestamp).
// Used by `banger update` to record what's running after a
// successful binary swap; the install identity is unchanged so
// re-running `banger system install` is not required.
//
// Errors when path doesn't exist or can't be parsed — `banger
// update` runs in system mode where install.toml IS the source of
// truth; a missing file means we shouldn't be updating at all.
func UpdateBuildInfo(path, version, commit, builtAt string) error {
if strings.TrimSpace(path) == "" {
path = DefaultPath
}
meta, err := Load(path)
if err != nil {
return err
}
meta.Version = strings.TrimSpace(version)
meta.Commit = strings.TrimSpace(commit)
meta.BuiltAt = strings.TrimSpace(builtAt)
return Save(path, meta)
}
func (m Metadata) Validate() error {
if strings.TrimSpace(m.OwnerUser) == "" {
return fmt.Errorf("install metadata missing owner_user")