controller v0.264.0: the household is told when an update is undone or held, in its language
gates / gates (push) Successful in 25s
gates / gates (push) Successful in 25s
app_update_undone / app_update_held events (09 decision 15), on by default and seeded once on existing boxes; R-606 update sentences as key+args rendered per reader; R-646 startup applied-meta backfill for apps current with the catalog; R-620 a disabled notifier WARNs once per event type. Needs hub v0.120.0. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -401,6 +401,7 @@ func (m *Manager) tryUndo(ctx context.Context, name, dir, why string, entry *upd
|
||||
_ = m.RefreshStatus()
|
||||
m.clearJournal(name)
|
||||
m.finishUpdate(name, UpdatePhaseUndone, "")
|
||||
m.emitUpdateEvent(UpdateEventUndone, name, entry, UpdateRestorePoint{}, true)
|
||||
m.logger.Printf("[INFO] [stacks] update %s: UNDONE in %s — the previous version is running on the data from before the update (%s)", name, m.now().Sub(start).Round(time.Second), detail)
|
||||
return ""
|
||||
}
|
||||
@@ -424,3 +425,86 @@ func (m *Manager) recordUpdateUndone(name, dir string, u *UpdateUndone) {
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// ── The household and the operator are TOLD (v0.264.0, `09` §3 decision 15, §6.4 part 2) ─────────
|
||||
|
||||
// Update event kinds — the hub event types, one register for both repos (hub allowedEventTypes).
|
||||
const (
|
||||
UpdateEventUndone = "app_update_undone" // the box put the previous version back — warning
|
||||
UpdateEventHeld = "app_update_held" // the update (or its undo) failed and the app is HELD — error
|
||||
)
|
||||
|
||||
// UpdateEvent is what the update job hands to the notifier. The stacks package composes no sentence
|
||||
// for it: the notifier renders the undone line from a bundle key, and the held line is the hold's own
|
||||
// sentence, rendered by the backup side in each language (main.go wires both).
|
||||
type UpdateEvent struct {
|
||||
Kind string
|
||||
App string
|
||||
From, To map[string]string
|
||||
At time.Time
|
||||
CopyTier int
|
||||
CopyDate time.Time
|
||||
// HoldRecorded is false when the hold could not be persisted — the event still goes (the operator
|
||||
// must hear about exactly that), and the household sentence is then update.error.hold_unsaved.
|
||||
HoldRecorded bool
|
||||
}
|
||||
|
||||
// SetUpdateEventSink wires the notifier (main.go). INIT-ONLY. Nil = no events (tests, setup mode).
|
||||
// Pinned by TestUpdateEventSinkIsWiredAtStartup (an AST walk of main.go).
|
||||
func (m *Manager) SetUpdateEventSink(fn func(UpdateEvent)) {
|
||||
m.mu.Lock()
|
||||
m.updateEventSink = fn
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// emitUpdateEvent sends ONE event for an undone or held update. Called exactly once per outcome by
|
||||
// tryUndo (undone) and failAndHold (held) — the job never retries (decision 15), so one outcome is one
|
||||
// event; the hub's per-app cooldown is the second belt against a storm (R-629).
|
||||
func (m *Manager) emitUpdateEvent(kind, name string, entry *updateJournalEntry, rp UpdateRestorePoint, holdRecorded bool) {
|
||||
m.mu.RLock()
|
||||
sink := m.updateEventSink
|
||||
m.mu.RUnlock()
|
||||
if sink == nil {
|
||||
return
|
||||
}
|
||||
ev := UpdateEvent{Kind: kind, App: name, At: m.now(), CopyTier: rp.Tier, CopyDate: rp.ProvenAt, HoldRecorded: holdRecorded}
|
||||
if entry != nil {
|
||||
ev.From, ev.To = entry.PrevPin, entry.NewPin
|
||||
}
|
||||
m.logger.Printf("[INFO] [stacks] update %s: event %s (hold recorded: %v)", name, kind, holdRecorded)
|
||||
sink(ev)
|
||||
}
|
||||
|
||||
// BackfillAppliedMeta is R-646's startup pass (v0.264.0), beside AdoptPins. An app pinned before
|
||||
// v0.263.2 has no applied-meta record, so its FIRST undo would judge the old version with whatever
|
||||
// .felhom.yml the catalog sync last put in place. For an app that is CURRENT with the catalog, that file
|
||||
// IS the pinned version's own — so it is recorded now. An app that is Behind or Unknown is skipped and
|
||||
// NAMED: its pinned version's file is already gone, and guessing it would be worse than saying so.
|
||||
// Idempotent; never overwrites an existing record. Returns (recorded, skipped) app names.
|
||||
func (m *Manager) BackfillAppliedMeta() (recorded, skipped []string) {
|
||||
for _, st := range m.GetStacks() {
|
||||
if !st.Deployed || st.AppConfig == nil || len(st.AppConfig.PinnedImages) == 0 {
|
||||
continue
|
||||
}
|
||||
dir := filepath.Dir(st.ComposePath)
|
||||
if _, err := os.Stat(filepath.Join(dir, appliedMetaDir, ".felhom.yml")); err == nil {
|
||||
continue // already recorded — never overwritten
|
||||
}
|
||||
if CatalogOrder(st) != UpdateOrderCurrent {
|
||||
skipped = append(skipped, st.Name)
|
||||
continue
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(dir, ".felhom.yml"))
|
||||
if err == nil {
|
||||
err = storeAppliedMeta(dir, b)
|
||||
}
|
||||
if err != nil {
|
||||
m.logger.Printf("[WARN] [stacks] applied-meta backfill: %s: %v", st.Name, err)
|
||||
skipped = append(skipped, st.Name)
|
||||
continue
|
||||
}
|
||||
recorded = append(recorded, st.Name)
|
||||
}
|
||||
m.logger.Printf("[INFO] [stacks] applied-meta backfill (R-646): recorded %d %v; skipped %d %v — not current with the catalog, their pinned version's .felhom.yml is no longer on the box", len(recorded), recorded, len(skipped), skipped)
|
||||
return recorded, skipped
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user