v0.184.0 — E-2b + Part 5: wire the drive-absent alarm that was never called
NotifyStorageDisconnected and NotifyStorageReconnected were defined and called from NOWHERE. Registered in allowedEventTypes, in DefaultEnabledEvents, and given a Hungarian message on the hub -- and never invoked. A drive going absent produced apps stopped, a WARN log and a UI badge, then silence on every channel. Verified against the gitignored-cmd/ trap with a positive control. Fifth instance of this class, found by E-2 Phase 0 rather than by a failure. A drive that is ONLY a backup target has no apps to stop, so it was silent twice. ReconcileDriveGates now calls both halves. When the absent drive is the whole-guest backup target it raises the more specific backup_target_absent (error) instead -- never both, since two mails for one event trains people to ignore the channel -- and recovers as backup_target_restored (info, the existing pairing-gated pattern; severityNotifies NOT widened). The recovery mirrors the alarm's choice or the operator cannot match them. Which drive is the target comes from the AGENT (/disks backup_target, >= 0.112.0), not from our StoragePath.BackupTarget: that is customer INTENT, and on the two boxes migrated by hand in E-1 the intent was never recorded while the drive really is the target. An older agent omits the field -> false -> generic alarm, never a wrong one. Before this an absent backup target had NO prompt signal: the tier stays DUE (targetStoragePresent checks name presence, never reachability), so the only evidence was its own failure at the next due cycle, up to ~24h away. The R-100 shape. Tests observe the WIRE, not a mock, because the failure class is "nothing arrives": a real Notifier posts to an httptest hub and the test asserts the event type and severity that actually went out. MinAgent: 0.112.0 Green gate: build + vet + test rc=0 (27 packages), run separately from this commit.
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
// capturedEvent is one POST /api/v1/event body the hub would have received.
|
||||
type capturedEvent struct {
|
||||
EventType string `json:"event_type"`
|
||||
Severity string `json:"severity"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// notifierAgainstHub wires a REAL Notifier at an httptest hub and returns a channel of what actually
|
||||
// went over the wire. The whole failure class E-2b addresses is "the method exists and nobody calls
|
||||
// it / nothing arrives", so the test has to observe the WIRE, not a mock's call count.
|
||||
func notifierAgainstHub(t *testing.T) (*Notifier, <-chan capturedEvent) {
|
||||
t.Helper()
|
||||
got := make(chan capturedEvent, 8)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var ev capturedEvent
|
||||
_ = json.Unmarshal(body, &ev)
|
||||
got <- ev
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), log.New(io.Discard, "", 0))
|
||||
if err != nil {
|
||||
t.Fatalf("settings: %v", err)
|
||||
}
|
||||
n := New(srv.URL, "test-key", "demo-hp", sett, log.New(os.Stderr, "", 0), false)
|
||||
return n, got
|
||||
}
|
||||
|
||||
func awaitEvent(t *testing.T, ch <-chan capturedEvent) capturedEvent {
|
||||
t.Helper()
|
||||
select {
|
||||
case ev := <-ch:
|
||||
return ev
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("no event reached the hub within 3s — the notification never went out")
|
||||
return capturedEvent{}
|
||||
}
|
||||
}
|
||||
|
||||
// E-2 Part 5 — the absent backup target must emit its OWN event type, at error severity.
|
||||
//
|
||||
// It has to be `backup_target_absent` specifically: the hub allowlists that string, and an event type
|
||||
// the hub does not allowlist is answered 400 and VANISHES (R-97a). A typo here is not a cosmetic bug,
|
||||
// it is total silence — the exact condition E-2 exists to end.
|
||||
func TestBackupTargetAbsentEmitsItsOwnEventType(t *testing.T) {
|
||||
n, got := notifierAgainstHub(t)
|
||||
n.NotifyBackupTargetAbsent("Külső HDD 1TB", "/mnt/hdd_1")
|
||||
ev := awaitEvent(t, got)
|
||||
if ev.EventType != "backup_target_absent" {
|
||||
t.Errorf("event_type = %q, want backup_target_absent (any other string is 400'd by the hub and lost)", ev.EventType)
|
||||
}
|
||||
if ev.Severity != "error" {
|
||||
t.Errorf("severity = %q, want error — a lost whole-system backup is not a warning", ev.Severity)
|
||||
}
|
||||
if ev.Message == "" {
|
||||
t.Error("message is empty — the operator mail would name no drive")
|
||||
}
|
||||
}
|
||||
|
||||
// The recovery half. `info` severity is deliberate and load-bearing: the recovery-mail pairing is
|
||||
// gated on it, and widening severityNotifies to carry recoveries at warning/error would page the
|
||||
// operator for good news across every event family, not just this one.
|
||||
func TestBackupTargetRestoredIsPairedAndInfo(t *testing.T) {
|
||||
n, got := notifierAgainstHub(t)
|
||||
n.NotifyBackupTargetRestored("Külső HDD 1TB", "/mnt/hdd_1")
|
||||
ev := awaitEvent(t, got)
|
||||
if ev.EventType != "backup_target_restored" {
|
||||
t.Errorf("event_type = %q, want backup_target_restored", ev.EventType)
|
||||
}
|
||||
if ev.Severity != "info" {
|
||||
t.Errorf("severity = %q, want info — recovery mails are pairing-gated on info", ev.Severity)
|
||||
}
|
||||
}
|
||||
|
||||
// E-2b — the seam that was defined and never called. This pins that NotifyStorageDisconnected
|
||||
// actually reaches the hub, which it never did before E-2: it was registered in allowedEventTypes,
|
||||
// DefaultEnabledEvents and the hub's Hungarian customerMessages, and called from nowhere in the repo.
|
||||
func TestStorageDisconnectedActuallyReachesTheHub(t *testing.T) {
|
||||
n, got := notifierAgainstHub(t)
|
||||
n.NotifyStorageDisconnected("Külső HDD 1TB", []string{"paperless"})
|
||||
ev := awaitEvent(t, got)
|
||||
if ev.EventType != "storage_disconnected" {
|
||||
t.Errorf("event_type = %q, want storage_disconnected", ev.EventType)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user