Initial import: nasmon health monitor, split out of home_nas

Go service (nasmon/) plus nfpm packaging (packaging/), carried over from
the home_nas monorepo. Adds rpm packaging alongside the existing deb
build (Fedora/RHEL/Rocky dependency names via nfpm overrides, per-format
systemd unit path, dpkg+rpm-compatible maintainer scripts) and a Gitea
Actions workflow that builds both formats on every push.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-19 10:24:34 +03:00
co-authored by Claude Sonnet 5
commit 09159a5f31
61 changed files with 7882 additions and 0 deletions
+305
View File
@@ -0,0 +1,305 @@
package alert
import (
"path/filepath"
"strings"
"testing"
"time"
"nasmon/internal/collect"
"nasmon/internal/config"
"nasmon/internal/store"
)
func keys(ev []Event) []string {
out := make([]string, len(ev))
for i, e := range ev {
out[i] = e.Key
}
return out
}
func has(ev []Event, key string) bool {
for _, e := range ev {
if e.Key == key {
return true
}
}
return false
}
func TestEvaluateRules(t *testing.T) {
now := time.Date(2025, 9, 13, 12, 0, 0, 0, time.Local)
old := now.Add(-60 * 24 * time.Hour)
th := config.Defaults().Thresholds
in := Input{
Now: now, Th: th, PoolsOK: true, RootOK: true,
Smarts: []collect.Smart{
{Device: "/dev/sda", Serial: "A", Model: "HDD", Supported: true, Healthy: true, HasTemp: true, TempC: 55,
Attrs: []collect.SmartAttr{{ID: 5, Raw: 12}, {ID: 199, Raw: 5}}},
{Device: "/dev/sdb", Serial: "B", Model: "HDD", Supported: true, Healthy: false, HasTemp: true, TempC: 30,
LastTest: &collect.SelfTest{Type: "Short offline", Status: "Completed: read failure", Passed: false}},
{Device: "/dev/nvme0n1", Serial: "N", Model: "NVMe", Supported: true, Healthy: true,
NVMe: &collect.NVMeHealth{PercentageUsed: 95, AvailableSpare: 100}},
{Device: "/dev/sdc", Serial: "C", Model: "HDD", Supported: true, Healthy: true, HasTemp: true, TempC: 35},
},
Prev: map[string]*collect.Smart{"A": {Serial: "A", Attrs: []collect.SmartAttr{{ID: 199, Raw: 3}}}},
Disks: []collect.Disk{{Serial: "A"}, {Serial: "B"}, {Serial: "N"}, {Serial: "C"}},
Seen: []store.SeenDisk{{Serial: "A"}, {Serial: "GONE", Model: "Old", Device: "/dev/sdz"}},
Pools: []collect.Pool{
{Name: "good", Health: "ONLINE", CapPct: 40, Size: 100, Free: 60, LastScrub: &now, Errors: "No known data errors"},
{Name: "bad", Health: "DEGRADED", CapPct: 90, Size: 100, Free: 10, LastScrub: &old,
Vdevs: []collect.Vdev{{Name: "sda", Cksum: 2}}, Errors: "No known data errors"},
{Name: "never", Health: "ONLINE", CapPct: 10, Size: 100, Free: 90},
},
CPUTemp: 85, HasCPU: true,
RootFS: collect.FSUsage{Total: 100, Free: 5},
}
ev := Evaluate(in)
want := []string{
"smart:A:attr", "smart:A:crc", "smart:A:temp",
"smart:B:health", "smart:B:selftest",
"smart:N:nvme",
"disk:GONE:missing",
"zfs:bad:health", "zfs:bad:errors", "zfs:bad:capacity", "zfs:bad:scrub",
"zfs:never:scrub",
"sys:cpu:temp", "sys:rootfs:space",
}
for _, k := range want {
if !has(ev, k) {
t.Errorf("missing event %s; got %v", k, keys(ev))
}
}
if len(ev) != len(want) {
t.Errorf("got %d events, want %d: %v", len(ev), len(want), keys(ev))
}
for _, e := range ev {
if strings.HasPrefix(e.Key, "smart:C:") || strings.HasPrefix(e.Key, "zfs:good:") {
t.Errorf("unexpected event %s", e.Key)
}
if e.Key == "smart:A:attr" && e.Level != LevelCrit {
t.Errorf("reallocated sectors must be crit, got %s", e.Level)
}
}
// timeouts / reported-uncorrect alone are only a warning
ev = Evaluate(Input{Now: now, Th: th, Smarts: []collect.Smart{{Serial: "S", Supported: true, Healthy: true,
Attrs: []collect.SmartAttr{{ID: 187, Raw: 6}, {ID: 188, Raw: 3}}}}})
if len(ev) != 1 || ev[0].Key != "smart:S:attr" || ev[0].Level != LevelWarn {
t.Errorf("187/188 only: %+v", ev)
}
}
func TestEvaluateDisabledThresholds(t *testing.T) {
th := config.Thresholds{} // all zero = disabled
in := Input{Now: time.Now(), Th: th, PoolsOK: true, RootOK: true, HasCPU: true, CPUTemp: 99,
Smarts: []collect.Smart{{Serial: "A", Supported: true, Healthy: true, HasTemp: true, TempC: 99}},
Pools: []collect.Pool{{Name: "p", Health: "ONLINE", CapPct: 99, Errors: "No known data errors"}},
RootFS: collect.FSUsage{Total: 100, Free: 1}}
if ev := Evaluate(in); len(ev) != 0 {
t.Errorf("thresholds disabled but got %v", keys(ev))
}
}
type mailSink struct {
subjects []string
bodies []string
fail bool
}
func (m *mailSink) send(_ config.SMTP, subject, body string) error {
if m.fail {
return errTest
}
m.subjects = append(m.subjects, subject)
m.bodies = append(m.bodies, body)
return nil
}
type testErr struct{}
func (testErr) Error() string { return "boom" }
var errTest = testErr{}
func newManager(t *testing.T) (*Manager, *mailSink) {
t.Helper()
dir := t.TempDir()
st, err := store.Open(filepath.Join(dir, "t.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
cfg, err := config.Load(filepath.Join(dir, "config.json"))
if err != nil {
t.Fatal(err)
}
c := cfg.Get()
c.SMTP.Host, c.SMTP.To = "localhost", []string{"a@b"}
c.RemindHours = 2
if err := cfg.Save(c); err != nil {
t.Fatal(err)
}
sink := &mailSink{}
return &Manager{Store: st, Config: cfg, Send: sink.send}, sink
}
func TestReconcileLifecycle(t *testing.T) {
m, sink := newManager(t)
t0 := time.Date(2025, 9, 13, 10, 0, 0, 0, time.UTC)
ev := []Event{{Key: "zfs:tank:health", Level: LevelCrit, Title: "Pool tank is DEGRADED", Detail: "d"}}
out, err := m.Reconcile(Input{Now: t0}, ev)
if err != nil {
t.Fatal(err)
}
if len(out.Opened) != 1 || !out.Mailed || len(sink.subjects) != 1 {
t.Fatalf("open: %+v mails=%v", out, sink.subjects)
}
if !strings.Contains(sink.subjects[0], "Pool tank is DEGRADED") {
t.Errorf("subject = %q", sink.subjects[0])
}
// same event again shortly after: nothing new, no mail
out, _ = m.Reconcile(Input{Now: t0.Add(time.Hour)}, ev)
if len(out.Opened) != 0 || out.Mailed || len(sink.subjects) != 1 {
t.Fatalf("no-change cycle mailed: %+v", out)
}
// after RemindHours: reminder mail
out, _ = m.Reconcile(Input{Now: t0.Add(3 * time.Hour)}, ev)
if len(out.Reminded) != 1 || !out.Mailed || len(sink.subjects) != 2 {
t.Fatalf("reminder: %+v mails=%v", out, sink.subjects)
}
if !strings.Contains(sink.bodies[1], "STILL OPEN") {
t.Errorf("reminder body: %q", sink.bodies[1])
}
// frozen prefix: alert must survive a cycle without data
out, _ = m.Reconcile(Input{Now: t0.Add(4 * time.Hour), Frozen: []string{"zfs:"}}, nil)
if len(out.Resolved) != 0 {
t.Fatalf("frozen alert was resolved")
}
// condition gone: resolved + mail
out, _ = m.Reconcile(Input{Now: t0.Add(5 * time.Hour)}, nil)
if len(out.Resolved) != 1 || !out.Mailed || !strings.Contains(sink.bodies[len(sink.bodies)-1], "RESOLVED") {
t.Fatalf("resolve: %+v", out)
}
openNow, _ := m.Store.OpenAlerts()
if len(openNow) != 0 {
t.Errorf("still open: %+v", openNow)
}
}
func TestReconcileMailFailureKeepsUnnotified(t *testing.T) {
m, sink := newManager(t)
sink.fail = true
t0 := time.Now()
ev := []Event{{Key: "smart:A:health", Level: LevelCrit, Title: "x", Detail: "y"}}
out, _ := m.Reconcile(Input{Now: t0}, ev)
if out.Mailed || out.MailErr == nil {
t.Fatalf("expected mail error: %+v", out)
}
open, _ := m.Store.OpenAlerts()
if len(open) != 1 || open[0].LastNotifiedAt != nil {
t.Fatalf("alert should be open and un-notified: %+v", open)
}
// within the backoff window nothing is attempted
sink.fail = false
out, _ = m.Reconcile(Input{Now: t0.Add(time.Minute)}, ev)
if out.Mailed || out.MailErr != nil {
t.Fatalf("mail attempted inside backoff: %+v", out)
}
// after the backoff the un-notified alert is retried as new
out, _ = m.Reconcile(Input{Now: t0.Add(mailRetryAfter + time.Minute)}, ev)
if !out.Mailed || len(out.Opened) != 1 {
t.Fatalf("un-notified alert not retried: %+v", out)
}
}
func TestReconcileAcknowledged(t *testing.T) {
m, sink := newManager(t)
t0 := time.Date(2025, 9, 13, 10, 0, 0, 0, time.UTC)
ev := []Event{{Key: "smart:A:attr", Level: LevelWarn, Title: "SMART attributes degraded", Detail: "187 Reported_Uncorrect raw=6"}}
out, _ := m.Reconcile(Input{Now: t0}, ev)
if len(out.Opened) != 1 || len(sink.subjects) != 1 {
t.Fatalf("open: %+v", out)
}
id := out.Opened[0].ID
if err := m.Store.AckAlert(id, t0.Add(time.Minute)); err != nil {
t.Fatal(err)
}
// acknowledged + unchanged: no reminder even after RemindHours
out, _ = m.Reconcile(Input{Now: t0.Add(5 * time.Hour)}, ev)
if out.Mailed || len(out.Reminded) != 0 {
t.Fatalf("acked alert reminded: %+v", out)
}
if act, _ := m.Store.ActiveAlerts(); len(act) != 0 {
t.Fatalf("acked alert still active: %+v", act)
}
// counters grew: re-raised as new, mailed, visible again
ev[0].Detail = "187 Reported_Uncorrect raw=9"
out, _ = m.Reconcile(Input{Now: t0.Add(6 * time.Hour)}, ev)
if len(out.Opened) != 1 || !out.Mailed || len(sink.subjects) != 2 {
t.Fatalf("re-raise: %+v mails=%v", out, sink.subjects)
}
act, _ := m.Store.ActiveAlerts()
if len(act) != 1 || act[0].Detail != "187 Reported_Uncorrect raw=9" || act[0].Acked() {
t.Fatalf("re-raised alert: %+v", act)
}
// acknowledged again, then escalated warn -> crit: re-raised
_ = m.Store.AckAlert(id, t0.Add(7*time.Hour))
ev[0].Level = LevelCrit
out, _ = m.Reconcile(Input{Now: t0.Add(8 * time.Hour)}, ev)
if len(out.Opened) != 1 || len(sink.subjects) != 3 {
t.Fatalf("escalation not re-raised: %+v", out)
}
// acknowledged, then the problem disappears: resolved normally
_ = m.Store.AckAlert(id, t0.Add(9*time.Hour))
out, _ = m.Reconcile(Input{Now: t0.Add(10 * time.Hour)}, nil)
if len(out.Resolved) != 1 || len(sink.subjects) != 4 {
t.Fatalf("resolve of acked: %+v", out)
}
if open, _ := m.Store.OpenAlerts(); len(open) != 0 {
t.Fatalf("still open: %+v", open)
}
}
func TestStoreAlertLog(t *testing.T) {
m, _ := newManager(t)
st := m.Store
t0 := time.Now().Add(-30 * 24 * time.Hour)
old, _ := st.OpenAlert("k:old", LevelWarn, "old", "d", t0)
_ = st.ResolveAlert(old.ID, t0.Add(time.Hour))
fresh, _ := st.OpenAlert("k:fresh", LevelWarn, "fresh", "d", time.Now().Add(-2*time.Hour))
_ = st.ResolveAlert(fresh.ID, time.Now().Add(-time.Hour))
cur, _ := st.OpenAlert("k:cur", LevelCrit, "cur", "d", time.Now())
recent, _ := st.RecentAlerts(100, time.Now().Add(-7*24*time.Hour))
if len(recent) != 2 {
t.Fatalf("recent = %+v", recent)
}
if n, _ := st.CountResolvedBefore(time.Now().Add(-7 * 24 * time.Hour)); n != 1 {
t.Errorf("hidden count = %d", n)
}
all, _ := st.RecentAlerts(100, time.Time{})
if len(all) != 3 {
t.Fatalf("all = %+v", all)
}
// open alerts cannot be deleted, resolved ones can
_ = st.DeleteAlert(cur.ID)
_ = st.DeleteAlert(old.ID)
all, _ = st.RecentAlerts(100, time.Time{})
if len(all) != 2 {
t.Fatalf("after delete = %+v", all)
}
_ = st.PruneAlerts(time.Now().Add(-time.Minute))
all, _ = st.RecentAlerts(100, time.Time{})
if len(all) != 1 || all[0].ID != cur.ID {
t.Fatalf("after prune = %+v", all)
}
}
+128
View File
@@ -0,0 +1,128 @@
package alert
import (
"crypto/tls"
"errors"
"fmt"
"mime"
"net"
"net/smtp"
"strings"
"time"
"nasmon/internal/config"
)
// SendMail delivers a plain-text message using the configured SMTP account.
func SendMail(cfg config.SMTP, subject, body string) error {
if cfg.Host == "" {
return errors.New("SMTP host is not configured")
}
if len(cfg.To) == 0 {
return errors.New("no recipients configured")
}
from := cfg.From
if from == "" {
from = cfg.User
}
if from == "" {
return errors.New("sender address (from) is not configured")
}
addr := net.JoinHostPort(cfg.Host, fmt.Sprint(cfg.Port))
dialer := net.Dialer{Timeout: 20 * time.Second}
var c *smtp.Client
switch cfg.Security {
case "tls":
conn, err := tls.DialWithDialer(&dialer, "tcp", addr, &tls.Config{ServerName: cfg.Host})
if err != nil {
return fmt.Errorf("tls connect: %w", err)
}
c, err = smtp.NewClient(conn, cfg.Host)
if err != nil {
conn.Close()
return err
}
default:
conn, err := dialer.Dial("tcp", addr)
if err != nil {
return fmt.Errorf("connect: %w", err)
}
conn.SetDeadline(time.Now().Add(60 * time.Second))
c, err = smtp.NewClient(conn, cfg.Host)
if err != nil {
conn.Close()
return err
}
if cfg.Security == "starttls" {
if ok, _ := c.Extension("STARTTLS"); !ok {
c.Close()
return errors.New("server does not support STARTTLS")
}
if err := c.StartTLS(&tls.Config{ServerName: cfg.Host}); err != nil {
c.Close()
return fmt.Errorf("starttls: %w", err)
}
}
}
defer c.Close()
if cfg.User != "" {
ok, mechs := c.Extension("AUTH")
var auth smtp.Auth
switch {
case ok && strings.Contains(mechs, "PLAIN"):
auth = smtp.PlainAuth("", cfg.User, cfg.Pass, cfg.Host)
case ok && strings.Contains(mechs, "LOGIN"):
auth = loginAuth{cfg.User, cfg.Pass}
case ok && strings.Contains(mechs, "CRAM-MD5"):
auth = smtp.CRAMMD5Auth(cfg.User, cfg.Pass)
default:
auth = smtp.PlainAuth("", cfg.User, cfg.Pass, cfg.Host)
}
if err := c.Auth(auth); err != nil {
return fmt.Errorf("auth: %w", err)
}
}
if err := c.Mail(from); err != nil {
return fmt.Errorf("MAIL FROM: %w", err)
}
for _, to := range cfg.To {
if err := c.Rcpt(to); err != nil {
return fmt.Errorf("RCPT TO %s: %w", to, err)
}
}
w, err := c.Data()
if err != nil {
return err
}
msg := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nDate: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\n%s",
from, strings.Join(cfg.To, ", "), mime.QEncoding.Encode("utf-8", subject),
time.Now().Format(time.RFC1123Z), strings.ReplaceAll(body, "\n", "\r\n"))
if _, err := w.Write([]byte(msg)); err != nil {
return err
}
if err := w.Close(); err != nil {
return err
}
return c.Quit()
}
// loginAuth implements the legacy AUTH LOGIN mechanism (Yandex, Mail.ru, Exchange).
type loginAuth struct{ user, pass string }
func (a loginAuth) Start(*smtp.ServerInfo) (string, []byte, error) { return "LOGIN", nil, nil }
func (a loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
if !more {
return nil, nil
}
switch strings.ToLower(strings.TrimSpace(string(fromServer))) {
case "username:":
return []byte(a.user), nil
case "password:":
return []byte(a.pass), nil
}
return nil, fmt.Errorf("unexpected LOGIN challenge %q", fromServer)
}
+212
View File
@@ -0,0 +1,212 @@
// Package alert evaluates health rules, tracks open alerts and sends mail.
package alert
import (
"fmt"
"sort"
"strings"
"time"
"nasmon/internal/collect"
"nasmon/internal/config"
"nasmon/internal/store"
)
const (
LevelWarn = "warn"
LevelCrit = "crit"
)
// Event is one active problem. Key is stable across evaluations so the same
// condition maps to the same open alert: "<area>:<object>:<rule>".
type Event struct {
Key string
Level string
Title string
Detail string
}
// Input is everything the rules look at in one evaluation.
type Input struct {
Now time.Time
Th config.Thresholds
Smarts []collect.Smart // disks with usable SMART data (standby ones excluded)
Prev map[string]*collect.Smart // previous snapshot by serial, for growth rules
Disks []collect.Disk
Seen []store.SeenDisk
Pools []collect.Pool
PoolsOK bool
CPUTemp float64
HasCPU bool
RootFS collect.FSUsage
RootOK bool
// Frozen lists key prefixes whose open alerts must be left untouched
// because no fresh data was available for them in this cycle.
Frozen []string
}
// criticalATA are attributes whose non-zero raw value is a warning sign.
var criticalATA = map[int]string{
5: "Reallocated_Sector_Ct",
187: "Reported_Uncorrect",
188: "Command_Timeout",
197: "Current_Pending_Sector",
198: "Offline_Uncorrectable",
}
func diskLabel(s collect.Smart) string {
return fmt.Sprintf("%s (%s, %s)", s.Device, s.Model, s.Serial)
}
// Evaluate returns the list of currently active events.
func Evaluate(in Input) []Event {
var ev []Event
add := func(key, level, title, detail string) {
ev = append(ev, Event{Key: key, Level: level, Title: title, Detail: detail})
}
for _, s := range in.Smarts {
if s.Error != "" && !s.Supported {
continue
}
pfx := "smart:" + s.Serial + ":"
lbl := diskLabel(s)
if s.Supported && !s.Healthy {
add(pfx+"health", LevelCrit, "SMART health FAILED: "+lbl, "smartctl reports overall-health self-assessment: FAILED. Back up the data and replace the disk.")
}
var bad []string
level := LevelWarn
for _, a := range s.Attrs {
if _, ok := criticalATA[a.ID]; ok && a.Raw > 0 {
bad = append(bad, fmt.Sprintf("%d %s raw=%d", a.ID, a.Name, a.Raw))
if a.ID != 187 && a.ID != 188 { // uncorrect/timeouts are a hint, bad sectors are damage
level = LevelCrit
}
}
if a.WhenFailed != "" {
bad = append(bad, fmt.Sprintf("%d %s when_failed=%s (value %d, thresh %d)", a.ID, a.Name, a.WhenFailed, a.Value, a.Thresh))
level = LevelCrit
}
}
if len(bad) > 0 {
add(pfx+"attr", level, "SMART attributes degraded: "+lbl, strings.Join(bad, "\n"))
}
if cur, ok := s.Attr(199); ok {
if prev := in.Prev[s.Serial]; prev != nil {
if p, ok := prev.Attr(199); ok && cur.Raw > p.Raw {
add(pfx+"crc", LevelWarn, "UDMA CRC errors increasing: "+lbl,
fmt.Sprintf("199 UDMA_CRC_Error_Count %d -> %d. Usually a cable/connector problem.", p.Raw, cur.Raw))
}
}
}
if n := s.NVMe; n != nil {
var msgs []string
if n.CriticalWarning != 0 {
msgs = append(msgs, fmt.Sprintf("critical_warning=0x%02x", n.CriticalWarning))
}
if n.MediaErrors > 0 {
msgs = append(msgs, fmt.Sprintf("media_errors=%d", n.MediaErrors))
}
if n.PercentageUsed >= in.Th.NVMeUsedPct {
msgs = append(msgs, fmt.Sprintf("percentage_used=%d%% (>= %d%%)", n.PercentageUsed, in.Th.NVMeUsedPct))
}
if n.AvailableSpare < in.Th.NVMeSparePct {
msgs = append(msgs, fmt.Sprintf("available_spare=%d%% (< %d%%)", n.AvailableSpare, in.Th.NVMeSparePct))
}
if len(msgs) > 0 {
add(pfx+"nvme", LevelCrit, "NVMe health: "+lbl, strings.Join(msgs, "\n"))
}
}
if s.HasTemp && in.Th.DiskTempC > 0 && s.TempC >= in.Th.DiskTempC {
add(pfx+"temp", LevelWarn, fmt.Sprintf("Disk temperature %d°C: %s", s.TempC, lbl),
fmt.Sprintf("Threshold is %d°C.", in.Th.DiskTempC))
}
if t := s.LastTest; t != nil && !t.InProgress && !t.Passed {
add(pfx+"selftest", LevelCrit, "SMART self-test failed: "+lbl,
fmt.Sprintf("%s: %s (at %d power-on hours)", t.Type, t.Status, t.Hours))
}
}
// disks that were seen before but are gone now
present := map[string]bool{}
for _, d := range in.Disks {
present[d.Serial] = true
}
for _, d := range in.Seen {
if !present[d.Serial] {
add("disk:"+d.Serial+":missing", LevelCrit, fmt.Sprintf("Disk disappeared: %s (%s)", d.Model, d.Serial),
fmt.Sprintf("Last seen as %s at %s. If it was removed on purpose, forget it on the disk page.", d.Device, d.LastSeen.Format(time.RFC3339)))
}
}
if in.PoolsOK {
for _, p := range in.Pools {
pfx := "zfs:" + p.Name + ":"
if p.Health != "ONLINE" {
detail := "State: " + p.Health
if p.Status != "" {
detail += "\nStatus: " + p.Status
}
if p.Action != "" {
detail += "\nAction: " + p.Action
}
add(pfx+"health", LevelCrit, fmt.Sprintf("Pool %s is %s", p.Name, p.Health), detail)
}
var errs []string
for _, v := range p.Vdevs {
if v.Read > 0 || v.Write > 0 || v.Cksum > 0 {
errs = append(errs, fmt.Sprintf("%s: read=%d write=%d cksum=%d", v.Name, v.Read, v.Write, v.Cksum))
}
}
if p.Errors != "" && !strings.HasPrefix(p.Errors, "No known data errors") {
errs = append(errs, "errors: "+p.Errors)
}
if p.ScanErrs > 0 {
errs = append(errs, fmt.Sprintf("last scan finished with %d errors", p.ScanErrs))
}
if len(errs) > 0 {
add(pfx+"errors", LevelCrit, "Pool "+p.Name+" has errors", strings.Join(errs, "\n"))
}
if in.Th.PoolCapacityPct > 0 && p.CapPct >= in.Th.PoolCapacityPct {
add(pfx+"capacity", LevelWarn, fmt.Sprintf("Pool %s is %d%% full", p.Name, p.CapPct),
fmt.Sprintf("Pool size %s. Threshold %d%%.", humanBytes(p.Size), in.Th.PoolCapacityPct))
}
if in.Th.ScrubMaxAgeDays > 0 && !p.Scrubbing {
maxAge := time.Duration(in.Th.ScrubMaxAgeDays) * 24 * time.Hour
if p.LastScrub == nil {
add(pfx+"scrub", LevelWarn, "Pool "+p.Name+" was never scrubbed", "Run: zpool scrub "+p.Name)
} else if age := in.Now.Sub(*p.LastScrub); age > maxAge {
add(pfx+"scrub", LevelWarn, fmt.Sprintf("Pool %s: last scrub %d days ago", p.Name, int(age.Hours()/24)),
fmt.Sprintf("Last scrub %s. Threshold %d days.", p.LastScrub.Format("2006-01-02"), in.Th.ScrubMaxAgeDays))
}
}
}
}
if in.HasCPU && in.Th.CPUTempC > 0 && in.CPUTemp >= float64(in.Th.CPUTempC) {
add("sys:cpu:temp", LevelWarn, fmt.Sprintf("CPU temperature %.0f°C", in.CPUTemp), fmt.Sprintf("Threshold %d°C.", in.Th.CPUTempC))
}
if in.RootOK && in.RootFS.Total > 0 && in.Th.RootFSFreePct > 0 {
pct := int(in.RootFS.Free * 100 / in.RootFS.Total)
if pct < in.Th.RootFSFreePct {
add("sys:rootfs:space", LevelWarn, fmt.Sprintf("Root filesystem %d%% free", pct),
fmt.Sprintf("Root filesystem size %s. Threshold %d%%.", humanBytes(in.RootFS.Total), in.Th.RootFSFreePct))
}
}
sort.Slice(ev, func(i, j int) bool { return ev[i].Key < ev[j].Key })
return ev
}
func humanBytes(b uint64) string {
const unit = 1024
if b < unit {
return fmt.Sprintf("%d B", b)
}
div, exp := uint64(unit), 0
for n := b / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %ciB", float64(b)/float64(div), "KMGTPE"[exp])
}
+188
View File
@@ -0,0 +1,188 @@
package alert
import (
"fmt"
"log"
"os"
"strings"
"time"
"nasmon/internal/config"
"nasmon/internal/store"
)
// Manager reconciles evaluated events with the alerts table and notifies.
type Manager struct {
Store *store.Store
Config *config.Store
Send func(cfg config.SMTP, subject, body string) error
lastErr string // last mail error logged, to avoid repeating it every cycle
nextMailTry time.Time // backoff after a delivery failure
}
const mailRetryAfter = 10 * time.Minute
// Outcome describes what one reconciliation did.
type Outcome struct {
Opened []store.Alert
Resolved []store.Alert
Reminded []store.Alert
Mailed bool
MailErr error
}
func escalated(old, cur string) bool { return old == LevelWarn && cur == LevelCrit }
func frozen(key string, prefixes []string) bool {
for _, p := range prefixes {
if strings.HasPrefix(key, p) {
return true
}
}
return false
}
// Reconcile opens/resolves alerts based on the active events and sends one
// email describing everything that changed in this cycle.
func (m *Manager) Reconcile(in Input, events []Event) (Outcome, error) {
var out Outcome
cfg := m.Config.Get()
now := in.Now
open, err := m.Store.OpenAlerts()
if err != nil {
return out, err
}
openByKey := map[string]store.Alert{}
for _, a := range open {
openByKey[a.Key] = a
}
active := map[string]Event{}
for _, e := range events {
active[e.Key] = e
}
// alerts re-raised in this cycle (acknowledged, but the problem changed)
reraised := map[int64]bool{}
for _, e := range events {
if a, ok := openByKey[e.Key]; ok {
changed := a.Detail != e.Detail || a.Level != e.Level
if changed {
_ = m.Store.UpdateAlertDetail(a.ID, e.Level, e.Detail)
}
// an acknowledged alert stays quiet until the problem gets worse:
// escalated level or different details (grown counters, new errors)
if a.Acked() && (a.Detail != e.Detail || escalated(a.Level, e.Level)) {
_ = m.Store.ReraiseAlert(a.ID)
a.AckedAt, a.LastNotifiedAt, a.Level, a.Detail = nil, nil, e.Level, e.Detail
openByKey[e.Key] = a
reraised[a.ID] = true
log.Printf("alert re-raised: %s — %s", a.Key, a.Title)
out.Opened = append(out.Opened, a)
}
continue
}
a, err := m.Store.OpenAlert(e.Key, e.Level, e.Title, e.Detail, now)
if err != nil {
return out, err
}
log.Printf("alert opened: %s — %s", a.Key, a.Title)
out.Opened = append(out.Opened, a)
}
remind := time.Duration(cfg.RemindHours) * time.Hour
for _, a := range open {
if _, still := active[a.Key]; still {
if reraised[a.ID] {
continue
}
switch {
case a.Acked():
// acknowledged: no reminders, no retries
case a.LastNotifiedAt == nil:
// never delivered (mail failed earlier): retry as new
out.Opened = append(out.Opened, a)
case remind > 0 && now.Sub(*a.LastNotifiedAt) >= remind:
out.Reminded = append(out.Reminded, a)
}
continue
}
if frozen(a.Key, in.Frozen) {
continue
}
if err := m.Store.ResolveAlert(a.ID, now); err != nil {
return out, err
}
log.Printf("alert resolved: %s — %s", a.Key, a.Title)
out.Resolved = append(out.Resolved, a)
}
if !cfg.AlertsEnabled || m.Send == nil {
return out, nil
}
if len(out.Opened) == 0 && len(out.Reminded) == 0 && (len(out.Resolved) == 0 || !cfg.NotifyResolved) {
return out, nil
}
if now.Before(m.nextMailTry) {
return out, nil
}
subject, body := compose(cfg, out)
if err := m.Send(cfg.SMTP, subject, body); err != nil {
out.MailErr = err
m.nextMailTry = now.Add(mailRetryAfter)
if err.Error() != m.lastErr {
log.Printf("alert mail failed (retry in %s): %v", mailRetryAfter, err)
m.lastErr = err.Error()
}
return out, nil
}
m.lastErr, m.nextMailTry = "", time.Time{}
out.Mailed = true
var ids []int64
for _, a := range append(append([]store.Alert{}, out.Opened...), out.Reminded...) {
ids = append(ids, a.ID)
}
_ = m.Store.MarkNotified(ids, now)
return out, nil
}
func compose(cfg config.Settings, o Outcome) (subject, body string) {
host, _ := os.Hostname()
var parts []string
if n := len(o.Opened); n > 0 {
parts = append(parts, fmt.Sprintf("%d new", n))
}
if n := len(o.Reminded); n > 0 {
parts = append(parts, fmt.Sprintf("%d still open", n))
}
if n := len(o.Resolved); n > 0 && cfg.NotifyResolved {
parts = append(parts, fmt.Sprintf("%d resolved", n))
}
subject = strings.TrimSpace(cfg.SMTP.SubjectPrefix + " " + strings.Join(parts, ", ") + " on " + host)
if len(o.Opened) == 1 && len(o.Reminded) == 0 && len(o.Resolved) == 0 {
subject = strings.TrimSpace(cfg.SMTP.SubjectPrefix + " " + o.Opened[0].Title)
}
var b strings.Builder
fmt.Fprintf(&b, "nasmon on %s, %s\n\n", host, time.Now().Format("2006-01-02 15:04:05 MST"))
section := func(title string, list []store.Alert) {
if len(list) == 0 {
return
}
fmt.Fprintf(&b, "== %s ==\n\n", title)
for _, a := range list {
fmt.Fprintf(&b, "[%s] %s\n", strings.ToUpper(a.Level), a.Title)
for _, l := range strings.Split(a.Detail, "\n") {
fmt.Fprintf(&b, " %s\n", l)
}
fmt.Fprintf(&b, " since %s\n\n", a.OpenedAt.Format("2006-01-02 15:04"))
}
}
section("NEW", o.Opened)
section("STILL OPEN", o.Reminded)
if cfg.NotifyResolved {
section("RESOLVED", o.Resolved)
}
return subject, b.String()
}