feat(D53): the hub learns which apps are held — app_held / app_hold_cleared, daily repeat
gates / gates (push) Successful in 54s
gates / gates (push) Successful in 54s
internal/holdreport: one observer of the hold store, ticked every minute; app_held for a new or changed hold (stack_name, reason, since, why), app_hold_cleared when gone (also while the controller was down: settings.reported_holds), every hold again daily and at start. Severity info. RestoreHold.Why/WireReason (holdWhy calls it). 8 tests, red-proved (inert Tick; unwired scheduler line). Commit only (felhom.eu 08 §10).
This commit is contained in:
1 parent
20cf4bccfd
commit
5bd03ad152
7 files changed
+412
-16
No files matched your search
@@ -0,0 +1,139 @@
|
||||
// Package holdreport tells the hub which apps are held (D53, felhom.eu `08` §10).
|
||||
//
|
||||
// A held app is stopped on purpose and stays stopped until a person acts. Until D53 the hub knew nothing about it:
|
||||
// the crash-loop stop sent one event, the other holds sent none, and demo-hp's Kimai sat held for 11 days unseen
|
||||
// (R-1291). The legacy report is frozen, so the event channel carries the holds:
|
||||
//
|
||||
// - app_held when a hold appears or changes (stack, reason, since, the hold's sentence);
|
||||
// - app_hold_cleared when it is gone — also when it went while the controller was down, because the set last
|
||||
// reported is kept in settings (ReportedHolds), not in memory;
|
||||
// - every hold still in place is sent again once a day and at every controller start, because an event can be
|
||||
// lost. The hub treats a repeat as idempotent.
|
||||
//
|
||||
// ONE OBSERVER, NOT A HOOK PER WRITER. Seven code paths write a hold and four clear one. A send in each of them is
|
||||
// the shape where the one path nobody found is the next silent hold; this package reads the hold store itself, so a
|
||||
// new writer is covered the day it is added. Both events are severity info: the hub mails nothing on receipt. Its
|
||||
// reminder after the grace per reason is the operator's mail, and it never goes to the household (D53).
|
||||
//
|
||||
// Pinned by holdreport_test.go.
|
||||
package holdreport
|
||||
|
||||
import (
|
||||
"log"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
// Event types — the hub's allowedEventTypes carries both (the coupling rule).
|
||||
const (
|
||||
EventHeld = "app_held"
|
||||
EventCleared = "app_hold_cleared"
|
||||
)
|
||||
|
||||
// ResendEvery is how often every hold still in place is sent again.
|
||||
const ResendEvery = 24 * time.Hour
|
||||
|
||||
// Store is what the reporter needs of the settings.
|
||||
type Store interface {
|
||||
ListRestoreHolds() []settings.RestoreHold
|
||||
GetReportedHolds() map[string]string
|
||||
SetReportedHolds(map[string]string) error
|
||||
}
|
||||
|
||||
// Push sends one event to the hub (notify.Notifier.PushEvent).
|
||||
type Push func(eventType, severity, message string, details interface{})
|
||||
|
||||
// Details is the payload of both events.
|
||||
type Details struct {
|
||||
StackName string `json:"stack_name"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Since string `json:"since,omitempty"`
|
||||
Why string `json:"why,omitempty"`
|
||||
Repeat bool `json:"repeat,omitempty"` // a daily or start-up re-send, not a new hold
|
||||
}
|
||||
|
||||
// Reporter compares the hold store with what was last reported. Tick is called by the scheduler.
|
||||
type Reporter struct {
|
||||
store Store
|
||||
push Push
|
||||
logger *log.Logger
|
||||
lastFull time.Time
|
||||
}
|
||||
|
||||
// New builds a reporter. The first Tick re-sends every hold (a controller start).
|
||||
func New(store Store, push Push, logger *log.Logger) *Reporter {
|
||||
return &Reporter{store: store, push: push, logger: logger}
|
||||
}
|
||||
|
||||
func key(h settings.RestoreHold) string { return h.WireReason() + "|" + h.At }
|
||||
|
||||
// Tick sends what changed since the last tick, and every hold again when a day has passed.
|
||||
func (r *Reporter) Tick(now time.Time) {
|
||||
if r.store == nil || r.push == nil {
|
||||
return
|
||||
}
|
||||
holds := r.store.ListRestoreHolds()
|
||||
sort.Slice(holds, func(i, j int) bool { return holds[i].Stack < holds[j].Stack })
|
||||
prev := r.store.GetReportedHolds()
|
||||
full := r.lastFull.IsZero() || now.Sub(r.lastFull) >= ResendEvery
|
||||
|
||||
cur := make(map[string]string, len(holds))
|
||||
sent, cleared := 0, 0
|
||||
for _, h := range holds {
|
||||
k := key(h)
|
||||
cur[h.Stack] = k
|
||||
known, seen := prev[h.Stack]
|
||||
if seen && known == k && !full {
|
||||
continue
|
||||
}
|
||||
repeat := seen && known == k
|
||||
r.push(EventHeld, "info", "App "+h.Stack+" is held: "+h.Why(),
|
||||
Details{StackName: h.Stack, Reason: h.WireReason(), Since: h.At, Why: h.Why(), Repeat: repeat})
|
||||
sent++
|
||||
if !repeat {
|
||||
r.logf("[INFO] [holdreport] %s held (%s since %s) — reported to the hub", h.Stack, h.WireReason(), h.At)
|
||||
}
|
||||
}
|
||||
gone := make([]string, 0)
|
||||
for stack := range prev {
|
||||
if _, ok := cur[stack]; !ok {
|
||||
gone = append(gone, stack)
|
||||
}
|
||||
}
|
||||
sort.Strings(gone)
|
||||
for _, stack := range gone {
|
||||
r.push(EventCleared, "info", "App "+stack+" is no longer held", Details{StackName: stack})
|
||||
cleared++
|
||||
r.logf("[INFO] [holdreport] %s hold cleared — reported to the hub", stack)
|
||||
}
|
||||
if full {
|
||||
r.lastFull = now
|
||||
r.logf("[DEBUG] [holdreport] full re-send: %d hold(s) sent, %d cleared", sent, cleared)
|
||||
}
|
||||
if !sameMap(prev, cur) {
|
||||
if err := r.store.SetReportedHolds(cur); err != nil {
|
||||
// Not fatal: the next tick sends the same difference again, which the hub reads as idempotent.
|
||||
r.logf("[WARN] [holdreport] could not save the reported holds: %v — the next tick repeats the difference", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sameMap(a, b map[string]string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for k, v := range a {
|
||||
if b[k] != v {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (r *Reporter) logf(format string, args ...interface{}) {
|
||||
if r.logger != nil {
|
||||
r.logger.Printf(format, args...)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user