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>
138 lines
3.8 KiB
Go
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
|
|
}
|