gates / gates (push) Successful in 35s
Each good update left its staged binary in <state dir>/selfupdate for ever (49 files / 740 MB on demo-hp). After the wrapper's commit returned success, MaybeCommit now removes every staged binary but the running version's. Nothing is removed while a pending marker exists or cannot be read, at a plain start, or after a rollback. Only a regular file with the exact name Execute writes is removed. The rollback reads pending.json and felhom-agent.prev, no staged binary: pinned by a test on the wrapper's text and by a test that runs the wrapper's real commit and rollback verbs in a sandbox. Red seen three times. No version bump, no build, no vouch. /usr/local/bin/felhom-agent.bak-* is not reached (root-owned; R-1368 part 4). Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
97 lines
4.9 KiB
Go
97 lines
4.9 KiB
Go
package selfupdate
|
|
|
|
import (
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"sync"
|
|
)
|
|
|
|
// The self-update folder keeps what it needs and no more (R-1368 part 2; space list 2026-11-10, place H12).
|
|
//
|
|
// WHAT WAS WRONG. Execute downloads each new agent to <StateDir>/selfupdate/felhom-agent-<version> and removes that
|
|
// file only when the download or its checksum fails. A good update left it there for ever: measured 2026-11-10, 49 /
|
|
// 48 / 33 binaries = 740 / 724 / 509 MB on demo-hp, demo-felhom and Tester 1 — one for each version the box ever got.
|
|
//
|
|
// WHAT THE FOLDER IS NEEDED FOR. The root wrapper (felhom-os-apply, mode agent_update) reads the staged file ONCE,
|
|
// copies it to its own root-owned folder and flips from that copy. After that hand-off nothing reads the staged file:
|
|
// - the rollback (felhom-selfupdate-guarded `rollback`) needs two files and neither is a staged binary:
|
|
// `pending.json` in this folder, and `/usr/local/bin/felhom-agent.prev` outside it;
|
|
// - the commit needs `pending.json` only.
|
|
//
|
|
// So a staged binary is dead weight as soon as its update ended. Pinned by TestPrune_RollbackReadsNoStagedBinary (the
|
|
// wrapper's text) and TestPrune_RealRollbackStillWorksAfterNUpdates (the wrapper's rollback verb, run for real).
|
|
//
|
|
// THE RULE. After an update ended HEALTHY (the wrapper's `commit` returned success), remove every staged binary but
|
|
// the one of the running version. Nothing is removed:
|
|
// - while a pending marker exists or cannot be read — an update is in flight, and that is the only time a rollback
|
|
// can fire;
|
|
// - after a rollback, or at a plain start — only the commit path calls this, so the binary of a version that failed
|
|
// stays for the operator to look at until the next good update;
|
|
// - that is not a regular file with exactly the name Execute gives a staged binary. `pending.json`, a folder, a
|
|
// symlink, and any name this code does not know are never touched.
|
|
//
|
|
// NOT REACHED BY THIS: `/usr/local/bin/felhom-agent.bak-*` (space list H13). Those files are root-owned, in a folder
|
|
// the agent cannot write, and no code of the agent makes them (a person's copy before a hand deploy). R-1368 part 4.
|
|
|
|
// stagedNameRe is the exact name Execute gives a staged binary: "felhom-agent-" + a version that versionRe accepts.
|
|
// Kept in step with versionRe by TestPrune_NameShapeFollowsTheVersionRule.
|
|
var stagedNameRe = regexp.MustCompile(`^felhom-agent-([0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?)$`)
|
|
|
|
// stagingMu makes a download and a prune take turns. Without it a prune that runs while Execute downloads the NEXT
|
|
// version would remove that half-written file (its name is a staged binary's and it is not the running version).
|
|
var stagingMu sync.Mutex
|
|
|
|
// pruneStaged removes the staged binaries the folder no longer needs and returns the names it removed, sorted. It
|
|
// refuses (removes nothing, returns an error) while an update is in flight. A file it cannot remove is logged and
|
|
// skipped: a failed clean-up must never fail the commit that came before it.
|
|
func pruneStaged(stateDir, runningVersion string, logger *slog.Logger) ([]string, error) {
|
|
logger = orDefaultLogger(logger)
|
|
stagingMu.Lock()
|
|
defer stagingMu.Unlock()
|
|
|
|
if !versionRe.MatchString(runningVersion) {
|
|
return nil, fmt.Errorf("selfupdate prune: refusing — running version %q is not a version this code knows; it could not name the file to keep", runningVersion)
|
|
}
|
|
pm, err := readPending(stateDir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("selfupdate prune: refusing — the pending marker cannot be read: %w", err)
|
|
}
|
|
if pm != nil {
|
|
return nil, fmt.Errorf("selfupdate prune: refusing — an update to %s is pending (a rollback may still fire)", pm.NewVersion)
|
|
}
|
|
dir := stagingDir(stateDir)
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, nil
|
|
}
|
|
return nil, fmt.Errorf("selfupdate prune: read %s: %w", dir, err)
|
|
}
|
|
keep := "felhom-agent-" + runningVersion
|
|
var removed []string
|
|
for _, ent := range entries {
|
|
name := ent.Name()
|
|
if name == keep || !stagedNameRe.MatchString(name) {
|
|
continue // the running version's binary, pending.json, or a name this code does not know
|
|
}
|
|
if !ent.Type().IsRegular() {
|
|
logger.Warn("selfupdate prune: a staged-binary name that is not a regular file — left alone", "name", name, "type", ent.Type().String())
|
|
continue
|
|
}
|
|
// name came from ReadDir and matched stagedNameRe, so it holds no path separator: this is one exact file.
|
|
if err := os.Remove(filepath.Join(dir, name)); err != nil {
|
|
logger.Warn("selfupdate prune: cannot remove an old staged binary — left for the next good update", "name", name, "err", err)
|
|
continue
|
|
}
|
|
logger.Debug("selfupdate prune: removed an old staged binary", "name", name)
|
|
removed = append(removed, name)
|
|
}
|
|
sort.Strings(removed)
|
|
logger.Info("selfupdate prune: the self-update folder holds what it needs", "kept", keep, "removed", len(removed))
|
|
return removed, nil
|
|
}
|