banger/internal/installmeta/installmeta.go
Thales Maciel 91af367208
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>
2026-04-29 12:30:22 -03:00

138 lines
3.8 KiB
Go

package installmeta
import (
"fmt"
"os"
"os/user"
"path/filepath"
"strconv"
"strings"
"time"
toml "github.com/pelletier/go-toml"
)
const (
DefaultDir = "/etc/banger"
DefaultPath = DefaultDir + "/install.toml"
DefaultService = "bangerd.service"
DefaultRootHelperService = "bangerd-root.service"
DefaultSocketPath = "/run/banger/bangerd.sock"
DefaultRootHelperRuntimeDir = "/run/banger-root"
DefaultRootHelperSocketPath = DefaultRootHelperRuntimeDir + "/bangerd-root.sock"
)
type Metadata struct {
OwnerUser string `toml:"owner_user"`
OwnerUID int `toml:"owner_uid"`
OwnerGID int `toml:"owner_gid"`
OwnerHome string `toml:"owner_home"`
InstalledAt time.Time `toml:"installed_at"`
Version string `toml:"version,omitempty"`
Commit string `toml:"commit,omitempty"`
BuiltAt string `toml:"built_at,omitempty"`
}
func LookupOwner(name string) (Metadata, error) {
name = strings.TrimSpace(name)
if name == "" {
return Metadata{}, fmt.Errorf("owner username is required")
}
entry, err := user.Lookup(name)
if err != nil {
return Metadata{}, err
}
uid, err := strconv.Atoi(entry.Uid)
if err != nil {
return Metadata{}, fmt.Errorf("parse owner uid %q: %w", entry.Uid, err)
}
gid, err := strconv.Atoi(entry.Gid)
if err != nil {
return Metadata{}, fmt.Errorf("parse owner gid %q: %w", entry.Gid, err)
}
home := strings.TrimSpace(entry.HomeDir)
if home == "" || !filepath.IsAbs(home) {
return Metadata{}, fmt.Errorf("owner %q has invalid home directory %q", name, entry.HomeDir)
}
return Metadata{
OwnerUser: name,
OwnerUID: uid,
OwnerGID: gid,
OwnerHome: home,
}, nil
}
func Load(path string) (Metadata, error) {
if strings.TrimSpace(path) == "" {
path = DefaultPath
}
data, err := os.ReadFile(path)
if err != nil {
return Metadata{}, err
}
var meta Metadata
if err := toml.Unmarshal(data, &meta); err != nil {
return Metadata{}, err
}
if err := meta.Validate(); err != nil {
return Metadata{}, err
}
return meta, nil
}
func Save(path string, meta Metadata) error {
if strings.TrimSpace(path) == "" {
path = DefaultPath
}
if err := meta.Validate(); err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
data, err := toml.Marshal(meta)
if err != nil {
return err
}
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")
}
if m.OwnerUID < 0 {
return fmt.Errorf("install metadata has invalid owner_uid %d", m.OwnerUID)
}
if m.OwnerGID < 0 {
return fmt.Errorf("install metadata has invalid owner_gid %d", m.OwnerGID)
}
if strings.TrimSpace(m.OwnerHome) == "" || !filepath.IsAbs(m.OwnerHome) {
return fmt.Errorf("install metadata has invalid owner_home %q", m.OwnerHome)
}
return nil
}