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 /selfupdate/felhom-agent- 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 }