Hub v0.149.0: a box the household switched off is not an outage for 14 days (R-998)
gates / gates (push) Successful in 6m40s
gates / gates (push) Successful in 6m40s
hosts.deliberate_off_at follows the agent's deliberate_off field (set, then cleared by the first report of a new boot; one info event each way). While held, every silence alarm is held: host and node staleness, the 05:00 backup deadlines, off-site and restore-test freshness, the OS alarms and the kernel notice. After 14 days the usual alarms fire once, naming the power-off. Pages show KIKAPCSOLVA and the time. Consequence tests with controls; each guard red-proofed.
This commit is contained in:
1 parent
ed59bf3cbd
commit
50e47e29af
24 files changed
+638
-17
No files matched your search
@@ -430,7 +430,13 @@ func CheckBackupDeadlines(s *store.Store, staleness *StalenessChecker, onEvent E
|
||||
// to be quiet. That is R-195's shape exactly — a skip keyed off the wrong fact missing the
|
||||
// customer it would most obviously cover — which is why both doors are closed together.
|
||||
st := stalenessState(staleness, id)
|
||||
if st == StateDisabled {
|
||||
if st == StateDisabled || st == StateDeliberateOff { // R-998: a household power-off is not a missed backup
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
// R-998, read from the host row too: a box switched off minutes before 05:00 is not yet past the staleness
|
||||
// threshold, so its staleness state is still "ok" — the hold must not depend on that clock.
|
||||
if _, held := s.CustomerDeliberatelyOff(id, time.Now()); held {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
package monitor
|
||||
|
||||
// R-998 (doc 14) — A BOX THE HOUSEHOLD SWITCHED OFF IS NOT A BOX THAT DIED, FOR 14 DAYS.
|
||||
//
|
||||
// The consequence is asserted, not the mechanism: does the alarm fire? Each "held" case runs beside a CONTROL
|
||||
// with the same silence and no power-off, which must alarm — a quiet result alone could mean a broken fixture.
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"io"
|
||||
"log"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
)
|
||||
|
||||
type offRig struct {
|
||||
st *store.Store
|
||||
path string
|
||||
node *StalenessChecker
|
||||
host *HostStalenessChecker
|
||||
events []string
|
||||
msgs []string
|
||||
}
|
||||
|
||||
// newOffRig: one customer with one host, both reporting NOW, both checkers having seen them healthy.
|
||||
func newOffRig(t *testing.T) *offRig {
|
||||
t.Helper()
|
||||
st, path := seedStalenessCustomer(t, "ok", 0)
|
||||
if err := st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "k1"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.SaveHostReport("h1", "c1", []byte(`{}`), store.HostReportDenorm{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := &offRig{st: st, path: path}
|
||||
on := func(cid, et, sev, msg, det, src string) {
|
||||
r.events = append(r.events, et)
|
||||
r.msgs = append(r.msgs, msg)
|
||||
}
|
||||
r.node = NewStalenessChecker(st, 30*time.Minute, on, log.New(io.Discard, "", 0))
|
||||
r.host = NewHostStalenessChecker(st, 30*time.Minute, on, log.New(io.Discard, "", 0))
|
||||
r.node.Check()
|
||||
r.host.Check()
|
||||
if len(r.events) != 0 {
|
||||
t.Fatalf("setup emitted %v", r.events)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// silent ages both the controller's and the agent's last report by d.
|
||||
func (r *offRig) silent(t *testing.T, d time.Duration) {
|
||||
t.Helper()
|
||||
db, err := sql.Open("sqlite", r.path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
at := time.Now().UTC().Add(-d).Format("2006-01-02 15:04:05")
|
||||
if _, err := db.Exec(`UPDATE reports SET received_at = ? WHERE customer_id = 'c1'`, at); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Exec(`UPDATE hosts SET last_report_at = ? WHERE host_id = 'h1'`, at); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *offRig) offAt(t *testing.T, ago time.Duration) {
|
||||
t.Helper()
|
||||
at := time.Now().UTC().Add(-ago).Truncate(time.Second)
|
||||
if _, err := r.st.SetHostDeliberateOff("h1", &at); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *offRig) check() { r.node.Check(); r.host.Check() }
|
||||
|
||||
func hasEvent(evs []string, want string) bool {
|
||||
for _, e := range evs {
|
||||
if e == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Silent for 3 hours: the control alarms node_down + host_down; the switched-off box raises nothing.
|
||||
func TestDeliberateOff_NoSilenceAlarmDuringTheHold(t *testing.T) {
|
||||
ctl := newOffRig(t)
|
||||
ctl.silent(t, 3*time.Hour)
|
||||
ctl.check()
|
||||
if !hasEvent(ctl.events, "node_down") || !hasEvent(ctl.events, "host_down") {
|
||||
t.Fatalf("CONTROL: a box silent 3 h must alarm, got %v — the fixture is broken", ctl.events)
|
||||
}
|
||||
|
||||
r := newOffRig(t)
|
||||
r.offAt(t, 3*time.Hour)
|
||||
r.silent(t, 3*time.Hour)
|
||||
r.check()
|
||||
r.check()
|
||||
if len(r.events) != 0 {
|
||||
t.Fatalf("a box the household switched off alarmed: %v", r.events)
|
||||
}
|
||||
if r.node.GetState("c1") != StateDeliberateOff || r.host.GetState("h1") != "off" {
|
||||
t.Fatalf("states node=%q host=%q, want %q / off", r.node.GetState("c1"), r.host.GetState("h1"), StateDeliberateOff)
|
||||
}
|
||||
}
|
||||
|
||||
// After the 14-day hold, still silent → the usual alarms fire, and their text names the power-off.
|
||||
func TestDeliberateOff_AlarmsAfterFourteenDays(t *testing.T) {
|
||||
r := newOffRig(t)
|
||||
r.offAt(t, 13*24*time.Hour)
|
||||
r.silent(t, 13*24*time.Hour)
|
||||
r.check()
|
||||
if len(r.events) != 0 {
|
||||
t.Fatalf("day 13: %v", r.events)
|
||||
}
|
||||
r.offAt(t, 14*24*time.Hour+time.Minute) // the hold ran out
|
||||
r.silent(t, 14*24*time.Hour+time.Minute)
|
||||
r.check()
|
||||
if !hasEvent(r.events, "node_down") || !hasEvent(r.events, "host_down") {
|
||||
t.Fatalf("after 14 days: events %v, want node_down + host_down", r.events)
|
||||
}
|
||||
for _, m := range r.msgs {
|
||||
if !strings.Contains(m, "household") {
|
||||
t.Fatalf("the alarm does not say the household switched it off: %q", m)
|
||||
}
|
||||
}
|
||||
r.check() // once, not every minute
|
||||
if len(r.events) != 2 {
|
||||
t.Fatalf("repeated: %v", r.events)
|
||||
}
|
||||
}
|
||||
|
||||
// Switched back on inside the hold: reports resume → no recovery mail for an outage that never was.
|
||||
func TestDeliberateOff_BackOnIsQuiet(t *testing.T) {
|
||||
r := newOffRig(t)
|
||||
r.offAt(t, 2*time.Hour)
|
||||
r.silent(t, 2*time.Hour)
|
||||
r.check()
|
||||
r.silent(t, 0)
|
||||
if _, err := r.st.SetHostDeliberateOff("h1", nil); err != nil { // the new boot's first report
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.check()
|
||||
if len(r.events) != 0 {
|
||||
t.Fatalf("back on: %v", r.events)
|
||||
}
|
||||
if r.node.GetState("c1") != "ok" || r.host.GetState("h1") != "ok" {
|
||||
t.Fatalf("states node=%q host=%q, want ok", r.node.GetState("c1"), r.host.GetState("h1"))
|
||||
}
|
||||
}
|
||||
|
||||
// The 05:00 deadline: Tester 2's shape (R-872) alarms; the same box switched off by the household does not.
|
||||
func TestDeliberateOff_DeadlineSkips(t *testing.T) {
|
||||
st, _, sc := downBox(t, 5*24*time.Hour)
|
||||
if got := deadlineEvents(st, sc); got["expected_backup_missed"] != 1 {
|
||||
t.Fatalf("CONTROL: %v — the down box must raise its missed-backup alarm", got)
|
||||
}
|
||||
st2, _, sc2 := downBox(t, 5*24*time.Hour)
|
||||
at := time.Now().UTC().Add(-2 * time.Hour)
|
||||
if _, err := st2.SetHostDeliberateOff("h1", &at); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := deadlineEvents(st2, sc2); len(got) != 0 {
|
||||
t.Fatalf("a switched-off box got deadline alarms: %v", got)
|
||||
}
|
||||
}
|
||||
@@ -96,6 +96,9 @@ func (sc *HostStalenessChecker) Check() {
|
||||
age := time.Since(row.LastReportAt)
|
||||
var newState string
|
||||
switch {
|
||||
case age > sc.threshold && store.DeliberateOffHeld(row.DeliberateOffAt, time.Now()):
|
||||
// R-998: the household switched the box off on purpose — silent, for the 14-day hold.
|
||||
newState = "off"
|
||||
case age > sc.downAfter:
|
||||
newState = "down"
|
||||
case age > sc.threshold:
|
||||
@@ -105,6 +108,20 @@ func (sc *HostStalenessChecker) Check() {
|
||||
}
|
||||
|
||||
oldState := sc.states[row.HostID]
|
||||
if newState == "off" || oldState == "off" {
|
||||
// Into the hold, or out of it back to ok: no event (the report ingest records the info events).
|
||||
// Out of it into stale/down (the hold ran out with the box still silent): the usual alarm, with a
|
||||
// message that says why it comes now.
|
||||
sc.states[row.HostID] = newState
|
||||
if newState == "stale" || newState == "down" {
|
||||
sc.downtimeStart[row.HostID] = time.Now()
|
||||
sc.emitHoldEnded(row, newState, age)
|
||||
} else if newState == "off" && oldState != "off" {
|
||||
sc.logger.Printf("[INFO] Host staleness: %s deliberately off since %s — alarms held until %s",
|
||||
row.HostID, row.DeliberateOffAt.Format(time.RFC3339), row.DeliberateOffAt.Add(store.DeliberateOffHold).Format(time.RFC3339))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if oldState == "" {
|
||||
// F2: first observation (incl. a hub restart). Healthy seeds silently; a host already
|
||||
// stale/down at first sight emits once (else a box that went silent while the hub was
|
||||
@@ -181,3 +198,23 @@ func (sc *HostStalenessChecker) emitTransition(hostID, customerID, oldState, new
|
||||
sc.onEvent(customerID, eventType, severity, message, "{}", "hub")
|
||||
}
|
||||
}
|
||||
|
||||
// emitHoldEnded raises the alarm a deliberate-off hold was holding (R-998): the box was switched off by the household
|
||||
// and is still silent when the hold runs out. The text names the power-off — „no report for 14d" alone reads as an
|
||||
// outage nobody saw coming.
|
||||
func (sc *HostStalenessChecker) emitHoldEnded(row store.HostStaleRow, newState string, age time.Duration) {
|
||||
eventType, severity := "host_stale", "warning"
|
||||
if newState == "down" {
|
||||
eventType, severity = "host_down", "error"
|
||||
}
|
||||
message := "Host " + row.HostID + ": switched off by the household on " + row.DeliberateOffAt.UTC().Format("2006-01-02 15:04 UTC") +
|
||||
" and still silent after the " + formatDuration(store.DeliberateOffHold) + " hold (no report for " + formatDuration(age) + ")"
|
||||
sc.logger.Printf("[INFO] Host staleness: %s off → %s (%s, the deliberate-off hold ended)", row.HostID, newState, eventType)
|
||||
if _, err := sc.store.SaveEvent(row.CustomerID, eventType, severity, message, "{}", "hub"); err != nil {
|
||||
sc.logger.Printf("[WARN] Failed to save host staleness event for %s: %v", row.HostID, err)
|
||||
return
|
||||
}
|
||||
if sc.onEvent != nil {
|
||||
sc.onEvent(row.CustomerID, eventType, severity, message, "{}", "hub")
|
||||
}
|
||||
}
|
||||
@@ -468,6 +468,11 @@ func (oc *OffsiteChecker) Check() {
|
||||
delete(oc.usedWindows, c.CustomerID)
|
||||
continue
|
||||
}
|
||||
// R-998: a box the household switched off has no fresh off-site copy by design — held for the 14 days.
|
||||
// States are kept, so the edge does not re-fire when it comes back.
|
||||
if _, held := oc.store.CustomerDeliberatelyOff(c.CustomerID, time.Now()); held {
|
||||
continue
|
||||
}
|
||||
if oc.store.IsCustomerBlocked(c.CustomerID) {
|
||||
delete(oc.fillStates, c.CustomerID)
|
||||
delete(oc.staleStates, c.CustomerID)
|
||||
|
||||
@@ -188,6 +188,9 @@ func (c *RestoreTestChecker) Check() {
|
||||
}
|
||||
now := c.now()
|
||||
for _, id := range ids {
|
||||
if _, held := c.store.CustomerDeliberatelyOff(id, now); held {
|
||||
continue // R-998: switched off by the household — no restore test can run; held for the 14 days
|
||||
}
|
||||
latest, rerr := c.store.GetLatestHostReportJSON(id)
|
||||
if rerr != nil || latest == "" {
|
||||
continue // no agent host-report → nothing to judge (the R-81 legacy-shape branch)
|
||||
|
||||
@@ -35,6 +35,12 @@ type EventNotifyFunc func(customerID, eventType, severity, message, detailsJSON,
|
||||
// second door.
|
||||
const StateDisabled = "disabled"
|
||||
|
||||
// StateDeliberateOff (R-998, doc 14) is the state of a customer whose box the HOUSEHOLD switched off with
|
||||
// „A gép kikapcsolása" — the host row carries the time (store.DeliberateOffHeld). Like StateDisabled it is a
|
||||
// recorded state, so CheckBackupDeadlines can skip it; unlike StateDisabled it ENDS: after the 14-day hold the
|
||||
// usual stale/down alarm fires, with a message naming the power-off. Pinned by deliberate_off_test.go.
|
||||
const StateDeliberateOff = "deliberately_off"
|
||||
|
||||
// StalenessChecker monitors customer report freshness and generates
|
||||
// node_stale / node_down / node_recovered events on state transitions.
|
||||
type StalenessChecker struct {
|
||||
@@ -139,7 +145,10 @@ func (sc *StalenessChecker) Check() {
|
||||
|
||||
age := time.Since(c.ReceivedAt)
|
||||
var newState string
|
||||
_, offHeld := sc.store.CustomerDeliberatelyOff(c.CustomerID, time.Now())
|
||||
switch {
|
||||
case age > sc.threshold && offHeld:
|
||||
newState = StateDeliberateOff
|
||||
case age > sc.downAfter:
|
||||
newState = "down"
|
||||
case age > sc.threshold:
|
||||
@@ -149,6 +158,22 @@ func (sc *StalenessChecker) Check() {
|
||||
}
|
||||
|
||||
oldState := sc.states[c.CustomerID]
|
||||
// R-998: into the hold, or back to ok from it — silent. Out of it into stale/down — the usual alarm,
|
||||
// with the power-off named in the text.
|
||||
if newState == StateDeliberateOff || (oldState == StateDeliberateOff && newState == "ok") {
|
||||
if oldState != newState {
|
||||
sc.logger.Printf("[INFO] Staleness: %s %s → %s (household power-off, no event)", c.CustomerID, oldState, newState)
|
||||
}
|
||||
sc.states[c.CustomerID] = newState
|
||||
delete(sc.downtimeStart, c.CustomerID)
|
||||
continue
|
||||
}
|
||||
if oldState == StateDeliberateOff {
|
||||
sc.states[c.CustomerID] = newState
|
||||
sc.downtimeStart[c.CustomerID] = time.Now()
|
||||
sc.emitHoldEnded(c.CustomerID, newState, age)
|
||||
continue
|
||||
}
|
||||
if oldState == "" || oldState == StateDisabled {
|
||||
// New customer — set state without event.
|
||||
//
|
||||
@@ -275,3 +300,21 @@ func (sc *StalenessChecker) isNewBox(customerID string) bool {
|
||||
}
|
||||
return time.Since(h.CreatedAt) < sc.threshold
|
||||
}
|
||||
|
||||
// emitHoldEnded raises the node alarm a household power-off was holding, once the 14-day hold ran out (R-998).
|
||||
func (sc *StalenessChecker) emitHoldEnded(customerID, newState string, age time.Duration) {
|
||||
eventType, severity := "node_stale", "warning"
|
||||
if newState == "down" {
|
||||
eventType, severity = "node_down", "error"
|
||||
}
|
||||
message := "No report received for " + formatDuration(age) + " — the box was switched off by the household and the " +
|
||||
formatDuration(store.DeliberateOffHold) + " hold has ended"
|
||||
sc.logger.Printf("[INFO] Staleness: %s %s → %s (%s, the deliberate-off hold ended)", customerID, StateDeliberateOff, newState, eventType)
|
||||
if _, err := sc.store.SaveEvent(customerID, eventType, severity, message, "{}", "hub"); err != nil {
|
||||
sc.logger.Printf("[WARN] Failed to save staleness event for %s: %v", customerID, err)
|
||||
return
|
||||
}
|
||||
if sc.onEvent != nil {
|
||||
sc.onEvent(customerID, eventType, severity, message, "{}", "hub")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user