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 "" }