Files
felhom-agent/internal/selfupdate/commit.go
T

132 lines
5.1 KiB
Go

package selfupdate
import (
"context"
"log/slog"
"time"
)
// Manager owns the post-restart COMMIT half of the A/B update (TASK D1 Scenario D). After a flip,
// the NEW binary boots with a pending marker on disk; once it has run cleanly for the dwell AND
// core init is done, it calls the wrapper's `commit` to clear the marker. A crash before that →
// systemd + the wrapper roll back to .prev (this Manager never rolls back).
type Manager struct {
stateDir string
runningVersion string
dwell time.Duration
runner WrapperRunner
logger *slog.Logger
now func() time.Time // injectable for tests
sleep func(context.Context, time.Duration)
// onCommitted runs once, right after a successful commit (R-1285: the bundle policy pass of the NEW agent — in the
// normal two-job delivery the bundle was installed by the OLD process, which has no such pass). nil = none.
onCommitted func(context.Context)
}
// SetOnCommitted wires the after-commit hook (R-1285). INIT-ONLY: call it before MaybeCommit's goroutine starts.
func (m *Manager) SetOnCommitted(fn func(context.Context)) { m.onCommitted = fn }
// ManagerConfig wires the commit manager.
type ManagerConfig struct {
StateDir string
RunningVersion string // this binary's own version (main.version)
Dwell time.Duration // 0 → 60s
Runner WrapperRunner
Logger *slog.Logger
}
// NewManager builds the commit manager.
func NewManager(cfg ManagerConfig) *Manager {
dwell := cfg.Dwell
if dwell == 0 {
dwell = 60 * time.Second
}
return &Manager{
stateDir: cfg.StateDir,
runningVersion: cfg.RunningVersion,
dwell: dwell,
runner: cfg.Runner,
logger: orDefaultLogger(cfg.Logger),
now: func() time.Time { return time.Now() },
sleep: func(ctx context.Context, d time.Duration) {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
case <-t.C:
}
},
}
}
// PendingStatus is the report-facing snapshot: whether an update is awaiting commit, and (when so)
// the target version. Read synchronously at report-build time — cheap (one stat+parse).
type PendingStatus struct {
Pending bool
Version string
}
// Status returns the current pending-marker state for the host report. A read error is treated as
// "not pending" for the report (the loud path is the commit goroutine's WARNs), never fatal.
func (m *Manager) Status() PendingStatus {
pm, err := readPending(m.stateDir)
if err != nil || pm == nil {
return PendingStatus{}
}
return PendingStatus{Pending: true, Version: pm.NewVersion}
}
// SelfUpdatePending satisfies hub.SelfUpdateReporter (the report seam): pending + awaited version.
func (m *Manager) SelfUpdatePending() (bool, string) {
s := m.Status()
return s.Pending, s.Version
}
// MaybeCommit runs the startup commit decision, blocking for the dwell when a matching update is
// pending. Call it in a goroutine AFTER core init (config parsed, local API up, control loop
// started) — never on the startup critical path. Behaviour (Scenario C5 + D):
// - no pending marker → nothing to do (the common case).
// - pending.new_version == this running version → dwell, then `commit` (clears the marker).
// - pending.new_version != running version → do NOT commit; loud WARN; leave the marker so the
// hub report shows pending=true (a human decides — the box is in a weird state).
func (m *Manager) MaybeCommit(ctx context.Context) {
pm, err := readPending(m.stateDir)
if err != nil {
m.logger.Error("selfupdate: cannot read pending marker — not committing", "err", err)
return
}
if pm == nil {
return // no update in flight
}
if pm.NewVersion != m.runningVersion {
// The running binary is NOT the one this pending marker describes. Do not commit (committing
// would bless a state we can't explain). Leave the marker → the report surfaces pending=true.
m.logger.Warn("selfupdate: pending marker version does not match the running binary — NOT committing (human review)",
"pending_new_version", pm.NewVersion, "running_version", m.runningVersion, "pending_old_version", pm.OldVersion)
return
}
m.logger.Warn("selfupdate: new version running — dwelling before commit",
"version", m.runningVersion, "prev", pm.OldVersion, "dwell", m.dwell)
m.sleep(ctx, m.dwell)
if ctx.Err() != nil {
// Shutting down before the dwell elapsed — leave pending; the next start re-dwells and
// commits. A restart is not a crash (no OnFailure), so this does not trigger rollback.
m.logger.Warn("selfupdate: shutdown before commit dwell elapsed — pending left for next start")
return
}
if m.runner == nil {
m.logger.Error("selfupdate: no wrapper runner — cannot commit (marker left)")
return
}
stdout, stderr, err := m.runner.Run(ctx, wrapperPath, "commit")
if err != nil {
m.logger.Error("selfupdate: commit failed (marker left; will retry next start)", "err", err, "stderr", string(stderr))
return
}
m.logger.Warn("selfupdate: update committed", "version", m.runningVersion, "wrapper", trim(stdout))
if m.onCommitted != nil {
m.onCommitted(ctx)
}
}