feat(D53): the hub learns which apps are held — app_held / app_hold_cleared, daily repeat
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:
2026-10-26 18:23:54 +01:00
parent 20cf4bccfd
commit 5bd03ad152
7 changed files with 412 additions and 16 deletions
+10 -16
View File
@@ -40,6 +40,7 @@ import (
"gitea.dooplex.hu/admin/felhom-controller/internal/crashboot"
"gitea.dooplex.hu/admin/felhom-controller/internal/crypto"
"gitea.dooplex.hu/admin/felhom-controller/internal/fillwatch"
"gitea.dooplex.hu/admin/felhom-controller/internal/holdreport"
"gitea.dooplex.hu/admin/felhom-controller/internal/infra"
"gitea.dooplex.hu/admin/felhom-controller/internal/integrations"
"gitea.dooplex.hu/admin/felhom-controller/internal/mailrelay"
@@ -1063,6 +1064,14 @@ func main() {
go func() { defer aubBusy.Unlock(); aubRunner.Tick(time.Now()) }() // a run can take minutes; never block the scheduler
return nil
})
// D53 (felhom.eu `08` §10): tell the hub which apps are held — app_held for a new or changed hold, app_hold_cleared
// when one is gone, every hold again once a day and after each start. One observer of the hold store, so every
// writer is covered. Pinned by internal/holdreport.
holdReporter := holdreport.New(sett, notifier.PushEvent, logger)
sched.Every("hold-report", time.Minute, func(ctx context.Context) error {
holdReporter.Tick(time.Now())
return nil
})
sched.Every("deadapp-check", 30*time.Second, func(ctx context.Context) error {
if bootGrace.Within(time.Now()) {
return nil // still inside the startup settle window (90 s, or ~15 min after a crash boot)
@@ -2562,22 +2571,7 @@ type driveStartGate struct {
// stop read as „held after a failed restore whose rollback also failed" for 11 days on demo-hp, so the operator could
// not tell a crash loop from a broken restore). Pinned by TestR1286_EveryHoldReasonSaysWhatItIs.
func holdWhy(h settings.RestoreHold) string {
switch h.Reason {
case settings.HoldReasonUpdateFailed:
return "held after a failed update (" + h.At + ") — restore it from its backup to start it"
case settings.HoldReasonUnhealthyStop:
kind := h.UnhealthyKind
if kind == "" {
kind = "unhealthy"
}
return "stopped by the box for a " + strings.ReplaceAll(kind, "_", " ") + " (" + h.At + ") — the household's Start gives it one more try"
case settings.HoldReasonRestoreMixed:
return "held after an off-site restore failed its database replay (" + h.At + ") — clear the hold to start it"
case settings.HoldReasonPutBackPending:
return "held until the pre-restore copy is poured back (" + h.At + ") — the put-back starts it"
default:
return "held after a failed restore whose rollback also failed (" + h.At + ") — clear the hold to start it"
}
return h.Why() // one sentence per reason, shared with the hub's „Held apps" list (D53)
}
func (g driveStartGate) MayStart(stackName string) (bool, string) {