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:
@@ -41,7 +41,8 @@ type Notifier struct {
|
||||
settings *settings.Settings
|
||||
|
||||
mu sync.Mutex
|
||||
prevHealthStatus string // tracks previous health check status for change detection
|
||||
prevHealthStatus string // tracks previous health check status for change detection
|
||||
droppedSeen map[string]bool // R-620: event types a DISABLED notifier has already said it dropped
|
||||
|
||||
// oomSeen (R-514) remembers container runs already reported as OOM-killed.
|
||||
oomSeen map[string]bool
|
||||
@@ -267,6 +268,7 @@ func (n *Notifier) pushEventBoth(eventType, severity, message, messageCustomer s
|
||||
return
|
||||
}
|
||||
if !n.enabled {
|
||||
n.dropped(eventType, severity)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -357,6 +359,7 @@ func (n *Notifier) pushEventBoth(eventType, severity, message, messageCustomer s
|
||||
// Detects both degradation (ok→warn, ok→fail, warn→fail) and recovery (fail→ok, warn→ok, fail→warn).
|
||||
func (n *Notifier) NotifyHealthChange(status string, issues, warnings []string) {
|
||||
if !n.enabled {
|
||||
n.dropped("health_change", status)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1056,6 +1059,7 @@ type notifyRequest struct {
|
||||
// No local cooldown — Hub handles cooldowns.
|
||||
func (n *Notifier) Notify(eventType, severity, message, details string) {
|
||||
if !n.enabled {
|
||||
n.dropped(eventType, severity)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1138,3 +1142,72 @@ func (n *Notifier) NotifyWholeGuestBackupRecovered(tier, message string) {
|
||||
n.PushEvent("whole_guest_backup_recovered", "info", message,
|
||||
WholeGuestBackupDetails{Tier: tier})
|
||||
}
|
||||
|
||||
// dropped is R-620: a DISABLED notifier (no hub configured) says what it drops. Once per event type per
|
||||
// process at WARN — naming the type and the severity — and DEBUG for every repeat, so a box whose hub
|
||||
// configuration is absent or broken leaves a greppable trace where the alarm vanished, instead of one
|
||||
// INFO line at its last start. Measured 2026-09-21 on guest 9202: an update night's whole event half
|
||||
// was unmeasurable because every event vanished without a word.
|
||||
func (n *Notifier) dropped(eventType, severity string) {
|
||||
n.mu.Lock()
|
||||
if n.droppedSeen == nil {
|
||||
n.droppedSeen = map[string]bool{}
|
||||
}
|
||||
first := !n.droppedSeen[eventType]
|
||||
n.droppedSeen[eventType] = true
|
||||
n.mu.Unlock()
|
||||
if n.logger == nil {
|
||||
return
|
||||
}
|
||||
if first {
|
||||
n.logger.Printf("[WARN] notifier disabled (no hub configured): DROPPED event %s (severity %s) — further %s events are logged at DEBUG only", eventType, severity, eventType)
|
||||
return
|
||||
}
|
||||
if n.debug {
|
||||
n.logger.Printf("[DEBUG] notifier disabled: dropped event %s (severity %s)", eventType, severity)
|
||||
}
|
||||
}
|
||||
|
||||
// AppUpdateDetails is the payload of app_update_undone / app_update_held (v0.264.0). `stack_name` is
|
||||
// what the hub's per-app cooldown keys on (R-389's register), so two apps on one night are two mails.
|
||||
type AppUpdateDetails struct {
|
||||
App string `json:"app"`
|
||||
StackName string `json:"stack_name"`
|
||||
From map[string]string `json:"from,omitempty"`
|
||||
To map[string]string `json:"to,omitempty"`
|
||||
At string `json:"at"`
|
||||
CopyTier int `json:"copy_tier,omitempty"`
|
||||
CopyDate string `json:"copy_date,omitempty"`
|
||||
CopyHolds string `json:"copy_holds,omitempty"`
|
||||
}
|
||||
|
||||
// budapestMinute renders a time as the household reads it everywhere else (the hold sentence, the
|
||||
// page): Europe/Budapest, minute precision.
|
||||
func budapestMinute(t time.Time) string {
|
||||
if loc, err := time.LoadLocation("Europe/Budapest"); err == nil {
|
||||
t = t.In(loc)
|
||||
}
|
||||
return t.Format("2006-01-02 15:04")
|
||||
}
|
||||
|
||||
// NotifyAppUpdateUndone (v0.264.0, `09` §3 decision 15): the box put a failed update back by itself.
|
||||
// Severity warning: nothing was lost and nothing needs doing, but the household asked for a new version
|
||||
// and did not get it. The sentence is ONE bundle key rendered twice (R-558).
|
||||
func (n *Notifier) NotifyAppUpdateUndone(app string, from, to map[string]string, at time.Time) {
|
||||
when := budapestMinute(at)
|
||||
n.pushEventMsg("app_update_undone", "warning", "event.app_update_undone",
|
||||
AppUpdateDetails{App: app, StackName: app, From: from, To: to, At: at.UTC().Format(time.RFC3339)}, app, when)
|
||||
}
|
||||
|
||||
// NotifyAppUpdateHeld (v0.264.0): the update — or its undo — failed and the app is HELD STOPPED.
|
||||
// `sentence` renders the hold's OWN sentence in a language (backup.RestoreHoldForLang, wired in
|
||||
// main.go), so the mail says exactly what the page says: what happened, which copy brings it back,
|
||||
// and what that copy holds. Severity error: the household must act (a restore).
|
||||
func (n *Notifier) NotifyAppUpdateHeld(d AppUpdateDetails, sentence func(lang string) string) {
|
||||
hu := sentence(i18n.Default)
|
||||
household := ""
|
||||
if lang := n.boxLang(); lang != i18n.Default {
|
||||
household = sentence(lang)
|
||||
}
|
||||
n.pushEventBoth("app_update_held", "error", hu, household, d)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
// v0.264.0 — the two update events on the wire, and R-620.
|
||||
|
||||
type pushRec struct {
|
||||
typ, sev, msg, cust string
|
||||
details interface{}
|
||||
}
|
||||
|
||||
func recNotifier(t *testing.T, lang string) (*Notifier, *[]pushRec) {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), log.New(&buf, "", 0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if lang != "hu" {
|
||||
if err := sett.SetLanguage(lang); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
n := &Notifier{settings: sett, logger: log.New(&buf, "", 0)}
|
||||
var got []pushRec
|
||||
n.pushFn = func(typ, sev, msg, cust string, d interface{}) { got = append(got, pushRec{typ, sev, msg, cust, d}) }
|
||||
return n, &got
|
||||
}
|
||||
|
||||
func TestUpdateEvents_UndoneWireText(t *testing.T) {
|
||||
at := time.Date(2026, 9, 23, 10, 3, 0, 0, time.UTC) // 12:03 Budapest
|
||||
for _, tc := range []struct{ lang, cust string }{
|
||||
{"hu", ""},
|
||||
{"en", "The update of docmost at 2026-09-23 12:03 did not work. The box put back the previous version and its data automatically — nothing was lost, and there is nothing you need to do."},
|
||||
} {
|
||||
n, got := recNotifier(t, tc.lang)
|
||||
n.NotifyAppUpdateUndone("docmost", map[string]string{"docmost": "a:1"}, map[string]string{"docmost": "a:2"}, at)
|
||||
if len(*got) != 1 {
|
||||
t.Fatalf("%s: %d events", tc.lang, len(*got))
|
||||
}
|
||||
r := (*got)[0]
|
||||
if r.typ != "app_update_undone" || r.sev != "warning" {
|
||||
t.Errorf("%s: type/severity = %s/%s", tc.lang, r.typ, r.sev)
|
||||
}
|
||||
if r.msg != "A(z) docmost frissítése 2026-09-23 12:03-kor nem sikerült. A doboz automatikusan visszaállította az előző változatot és az adatokat — semmi nem veszett el, nincs teendőd." {
|
||||
t.Errorf("%s: the wire message must be the Hungarian, got %q", tc.lang, r.msg)
|
||||
}
|
||||
if r.cust != tc.cust {
|
||||
t.Errorf("%s: household copy = %q", tc.lang, r.cust)
|
||||
}
|
||||
if d, ok := r.details.(AppUpdateDetails); !ok || d.StackName != "docmost" || d.App != "docmost" || d.From["docmost"] != "a:1" {
|
||||
t.Errorf("%s: details must carry stack_name (the hub's per-app cooldown key) and the step, got %+v", tc.lang, r.details)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateEvents_HeldCarriesTheHoldSentenceInBothLanguages(t *testing.T) {
|
||||
sentence := func(lang string) string { return "HOLD-" + lang }
|
||||
n, got := recNotifier(t, "en")
|
||||
n.NotifyAppUpdateHeld(AppUpdateDetails{App: "romm", StackName: "romm", CopyTier: 1}, sentence)
|
||||
r := (*got)[0]
|
||||
if r.typ != "app_update_held" || r.sev != "error" || r.msg != "HOLD-hu" || r.cust != "HOLD-en" {
|
||||
t.Errorf("got %+v", r)
|
||||
}
|
||||
n2, got2 := recNotifier(t, "hu")
|
||||
n2.NotifyAppUpdateHeld(AppUpdateDetails{App: "romm", StackName: "romm"}, sentence)
|
||||
if r := (*got2)[0]; r.cust != "" {
|
||||
t.Errorf("a Hungarian household sends no second copy (R-558), got %q", r.cust)
|
||||
}
|
||||
}
|
||||
|
||||
// R-620. COMPANION RED-PROOF (REPORT.md): delete the n.dropped call in pushEventBoth — zero WARN
|
||||
// lines, and this test fails.
|
||||
func TestR620_DisabledNotifierSaysWhatItDrops(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
n := New("", "", "c1", nil, log.New(&buf, "", 0), false) // no hub → disabled
|
||||
buf.Reset()
|
||||
n.PushEvent("app_update_undone", "warning", "x", nil)
|
||||
n.PushEvent("app_update_undone", "warning", "y", nil)
|
||||
n.PushEvent("backup_failed", "error", "z", nil)
|
||||
warns := 0
|
||||
for _, l := range strings.Split(buf.String(), "\n") {
|
||||
if strings.Contains(l, "[WARN]") && strings.Contains(l, "DROPPED event") {
|
||||
warns++
|
||||
}
|
||||
}
|
||||
if warns != 2 {
|
||||
t.Fatalf("want exactly TWO WARN lines (one per event type), got %d:\n%s", warns, buf.String())
|
||||
}
|
||||
for _, want := range []string{"DROPPED event app_update_undone (severity warning)", "DROPPED event backup_failed (severity error)"} {
|
||||
if !strings.Contains(buf.String(), want) {
|
||||
t.Errorf("missing %q in:\n%s", want, buf.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user