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
+59
View File
@@ -232,6 +232,10 @@ type Settings struct {
// out-of-memory storm (RFC3339). Survives the hold being lifted by Start, so a second stop within
// 24 h is known to be a second one.
UnhealthyStops map[string]string `json:"unhealthy_stops,omitempty"`
// ReportedHolds (D53, `08` §10) — the holds last told to the hub as app_held: stack → „reason|at". The hold
// reporter (internal/holdreport) compares it with RestoreHolds to send app_held for a new or changed hold and
// app_hold_cleared for one that is gone — also one cleared while the controller was down, because it is kept here.
ReportedHolds map[string]string `json:"reported_holds,omitempty"`
// Cross-drive restic repo password (auto-generated on first use)
CrossDriveResticPassword string `json:"cross_drive_restic_password,omitempty"`
@@ -1998,6 +2002,35 @@ const (
HoldReasonPutBackPending = "putback_pending"
)
// WireReason is the hold's reason as the hub reads it (D53): the empty legacy value is named restore_failed.
func (h RestoreHold) WireReason() string {
if h.Reason == HoldReasonRestoreFailed {
return "restore_failed"
}
return h.Reason
}
// Why says which operation put a hold in place and what lifts it — one sentence per reason (R-1286). English,
// operator-facing: the controller's own -list-restore-holds output and the hub's „Held apps" list (D53) both use it.
func (h RestoreHold) Why() string {
switch h.Reason {
case HoldReasonUpdateFailed:
return "held after a failed update (" + h.At + ") — restore it from its backup to start it"
case 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 HoldReasonRestoreMixed:
return "held after an off-site restore failed its database replay (" + h.At + ") — clear the hold to start it"
case 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"
}
}
// SetRestoreHold records a hold. Modelled on SetDisconnected: a condition, plus what it is holding.
func (s *Settings) SetRestoreHold(h RestoreHold) error {
s.mu.Lock()
@@ -2031,6 +2064,32 @@ func (s *Settings) ListRestoreHolds() []RestoreHold {
return out
}
// GetReportedHolds returns a copy of the holds last reported to the hub (D53): stack → „reason|at".
func (s *Settings) GetReportedHolds() map[string]string {
s.mu.RLock()
defer s.mu.RUnlock()
out := make(map[string]string, len(s.ReportedHolds))
for k, v := range s.ReportedHolds {
out[k] = v
}
return out
}
// SetReportedHolds replaces the record of the holds reported to the hub (D53). An empty map removes the field.
func (s *Settings) SetReportedHolds(m map[string]string) error {
s.mu.Lock()
defer s.mu.Unlock()
if len(m) == 0 {
s.ReportedHolds = nil
} else {
s.ReportedHolds = make(map[string]string, len(m))
for k, v := range m {
s.ReportedHolds[k] = v
}
}
return s.save()
}
// ClearRestoreHold removes a hold. Returns false when there was none, so a caller can tell "cleared"
// from "there was nothing to clear" rather than reporting success either way.
func (s *Settings) ClearRestoreHold(stack string) (bool, error) {