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:
parent
fb6d2b1dae
commit
91af367208
5 changed files with 746 additions and 0 deletions
107
internal/updater/stage.go
Normal file
107
internal/updater/stage.go
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
package updater
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// expectedReleaseEntries is the canonical set of files a release
|
||||
// tarball must contain. Anything missing OR anything extra is
|
||||
// rejected — banger update should not unpack arbitrary files into
|
||||
// the staging dir.
|
||||
var expectedReleaseEntries = []string{
|
||||
"banger",
|
||||
"bangerd",
|
||||
"banger-vsock-agent",
|
||||
}
|
||||
|
||||
// StagedRelease describes the result of unpacking a release tarball
|
||||
// into a staging directory.
|
||||
type StagedRelease struct {
|
||||
BangerPath string
|
||||
BangerdPath string
|
||||
VsockAgentPath string
|
||||
}
|
||||
|
||||
// StageTarball reads the gzipped tar at tarballPath and extracts the
|
||||
// expected three banger binaries into stagingDir. Any extra entries,
|
||||
// any path-traversal members, any non-regular-file members, and any
|
||||
// missing required entry are rejected.
|
||||
//
|
||||
// The extracted binaries are mode 0o755 regardless of what the
|
||||
// tarball claims — banger update is a privileged operation; we
|
||||
// don't honour weird modes from the wire.
|
||||
func StageTarball(tarballPath, stagingDir string) (StagedRelease, error) {
|
||||
if err := os.MkdirAll(stagingDir, 0o700); err != nil {
|
||||
return StagedRelease{}, err
|
||||
}
|
||||
f, err := os.Open(tarballPath)
|
||||
if err != nil {
|
||||
return StagedRelease{}, err
|
||||
}
|
||||
defer f.Close()
|
||||
gz, err := gzip.NewReader(f)
|
||||
if err != nil {
|
||||
return StagedRelease{}, fmt.Errorf("open gzip: %w", err)
|
||||
}
|
||||
defer gz.Close()
|
||||
|
||||
expected := map[string]struct{}{}
|
||||
for _, name := range expectedReleaseEntries {
|
||||
expected[name] = struct{}{}
|
||||
}
|
||||
seen := map[string]string{}
|
||||
|
||||
tr := tar.NewReader(gz)
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return StagedRelease{}, fmt.Errorf("read tar: %w", err)
|
||||
}
|
||||
rel := filepath.Clean(hdr.Name)
|
||||
if rel == "." || rel == string(filepath.Separator) {
|
||||
continue
|
||||
}
|
||||
if filepath.IsAbs(rel) || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return StagedRelease{}, fmt.Errorf("unsafe path in tarball: %q", hdr.Name)
|
||||
}
|
||||
if _, ok := expected[rel]; !ok {
|
||||
return StagedRelease{}, fmt.Errorf("unexpected entry in release tarball: %q (allowed: %v)", hdr.Name, expectedReleaseEntries)
|
||||
}
|
||||
if hdr.Typeflag != tar.TypeReg {
|
||||
return StagedRelease{}, fmt.Errorf("entry %q is not a regular file (typeflag %d)", hdr.Name, hdr.Typeflag)
|
||||
}
|
||||
dst := filepath.Join(stagingDir, rel)
|
||||
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755)
|
||||
if err != nil {
|
||||
return StagedRelease{}, err
|
||||
}
|
||||
if _, err := io.Copy(out, tr); err != nil {
|
||||
_ = out.Close()
|
||||
return StagedRelease{}, err
|
||||
}
|
||||
if err := out.Close(); err != nil {
|
||||
return StagedRelease{}, err
|
||||
}
|
||||
seen[rel] = dst
|
||||
}
|
||||
|
||||
for _, want := range expectedReleaseEntries {
|
||||
if _, ok := seen[want]; !ok {
|
||||
return StagedRelease{}, fmt.Errorf("release tarball is missing required entry %q", want)
|
||||
}
|
||||
}
|
||||
return StagedRelease{
|
||||
BangerPath: seen["banger"],
|
||||
BangerdPath: seen["bangerd"],
|
||||
VsockAgentPath: seen["banger-vsock-agent"],
|
||||
}, nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue