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) } // 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)) }