Compare commits

..
Author SHA1 Message Date
claude-botandClaude Opus 5.5 bb992833c2 feat(kuma-sync): attach Kuma's default notifications to managed monitors
review-bot reviewed: comment
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
Kuma 2.5.6 applies "default enabled" notifications only in the web UI
(src/pages/EditMonitor.vue:4128-4132); the server's add handler stores
exactly the notificationIDList the client sends (server/server.js:748,791).
Monitors created by kuma-sync therefore had no notifications.

The notification set is now a managed field: exactly the IDs of all
notifications with isDefault set, sorted, applied on create and update
(group monitors included). Monitor.NotificationIDs is part of
Normalized(), so a changed default set or a hand-made change triggers an
update on the next resync and an unchanged one does not.

Client.DefaultNotifications reads the library's cached notificationList
(go-uptime-kuma-client v0.6.2 notification.go:17). Kuma has no command to
request the list; it pushes it at login and to every session of the user
after each change (server/client.js:31, server/server.js:1573,1594,1836).
A failed read is KumaUnavailable with no create or update.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 08:23:44 +02:00
19 changed files with 326 additions and 220 deletions
@@ -7,12 +7,14 @@ import (
"fmt"
"io"
"path/filepath"
"slices"
"sync"
"testing"
"time"
kuma "github.com/breml/go-uptime-kuma-client"
"github.com/breml/go-uptime-kuma-client/monitor"
"github.com/breml/go-uptime-kuma-client/notification"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
"k8s.io/apimachinery/pkg/api/meta"
@@ -395,6 +397,60 @@ func TestE2EDriftRepaired(t *testing.T) {
})
}
func (f *e2e) webhook(name string, isDefault bool) int64 {
f.t.Helper()
id, err := f.raw.CreateNotification(f.t.Context(), notification.Webhook{
Base: notification.Base{Name: name, IsActive: true, IsDefault: isDefault},
WebhookDetails: notification.WebhookDetails{
WebhookURL: "http://127.0.0.1:9/" + name, WebhookContentType: "json",
},
})
if err != nil {
f.t.Fatalf("create notification %s: %v", name, err)
}
return id
}
func TestE2EDefaultNotifications(t *testing.T) {
f, env := newE2E(t)
first := f.webhook("first", true)
f.webhook("opt-in", false)
f.start(env)
f.apply(group("g", ""), dns("probe", "g"))
carry := func(want ...int64) func() (bool, string) {
return func() (bool, string) {
m := byKey(f.owned())
for _, k := range []string{"default/g", "default/probe"} {
got, ok := m[k]
if !ok {
return false, k + " missing"
}
if !slices.Equal(got.NotificationIDs, want) {
return false, fmt.Sprintf("%s carries %v, want %v", k, got.NotificationIDs, want)
}
}
return true, ""
}
}
eventually(t, 30*time.Second, "monitors created with the default notification", carry(first))
second := f.webhook("second", true)
want := []int64{first, second}
slices.Sort(want)
eventually(t, 30*time.Second, "new default notification attached by a resync", carry(want...))
var live monitor.DNS
if err := f.raw.GetMonitorAs(t.Context(), byKey(f.owned())["default/probe"].ID, &live); err != nil {
t.Fatal(err)
}
live.NotificationIDs = nil
if err := f.raw.UpdateMonitor(t.Context(), &live); err != nil {
t.Fatal(err)
}
eventually(t, 30*time.Second, "notifications removed by hand restored by a resync", carry(want...))
}
// A monitor that lost its ownership tag in the UI must not get a twin when the
// controller restarts: the resource reports NotOwned and nothing is created.
func TestE2ETagRemovedNeverDuplicates(t *testing.T) {
@@ -129,6 +129,11 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
if fail != nil {
return r.fail(ctx, km, fail)
}
notifications, err := r.Kuma.DefaultNotifications(ctx)
if err != nil {
return r.fail(ctx, km, &failure{ReasonKumaUnavailable, msgKumaUnavailable, err})
}
desired.NotificationIDs = notifications
var id int64
if res.mon == nil {
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"slices"
"testing"
"time"
@@ -310,6 +311,79 @@ func TestReconcileListErrorNoCreate(t *testing.T) {
}
}
func TestReconcileCreateGetsDefaultNotifications(t *testing.T) {
group := &v1.KumaMonitor{
ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "grp"},
Spec: v1.KumaMonitorSpec{Type: "group", DisplayName: "grp", Interval: 60, RetryInterval: 60},
}
e := newEnv(t, httpMonitor("a"), group)
e.kuma.SetDefaultNotifications(7, 3)
for _, name := range []string{"a", "grp"} {
if _, err := e.reconcile(t, name); err != nil {
t.Fatal(err)
}
got := e.kuma.Get(e.get(t, name).Status.MonitorID)
if got == nil || !slices.Equal(got.NotificationIDs, []int64{3, 7}) {
t.Errorf("%s: kuma monitor = %+v, want notifications [3 7]", name, got)
}
}
}
func TestReconcileRevertsNotificationDrift(t *testing.T) {
km := httpMonitor("a")
e := newEnv(t, km)
e.kuma.SetDefaultNotifications(1, 2)
drifted := Desired(km)
drifted.NotificationIDs = []int64{2, 9}
id, _ := e.kuma.Create(t.Context(), drifted)
if _, err := e.reconcile(t, "a"); err != nil {
t.Fatal(err)
}
if e.cnt.updates != 1 || e.cnt.creates != 0 {
t.Fatalf("updates=%d creates=%d", e.cnt.updates, e.cnt.creates)
}
if got := e.kuma.Get(id).NotificationIDs; !slices.Equal(got, []int64{1, 2}) {
t.Errorf("notifications = %v, want [1 2]", got)
}
}
func TestReconcileUnchangedNotificationsNoUpdate(t *testing.T) {
km := httpMonitor("a")
e := newEnv(t, km)
e.kuma.SetDefaultNotifications(4, 2)
existing := Desired(km)
existing.NotificationIDs = []int64{4, 2}
e.kuma.Create(t.Context(), existing)
for range 2 {
if _, err := e.reconcile(t, "a"); err != nil {
t.Fatal(err)
}
}
if e.cnt.updates != 0 || e.cnt.creates != 0 {
t.Fatalf("updates=%d creates=%d, want 0 and 0", e.cnt.updates, e.cnt.creates)
}
}
func TestReconcileNotificationListErrorNoWrite(t *testing.T) {
adopt := httpMonitor("b")
e := newEnv(t, httpMonitor("a"), adopt)
drifted := Desired(adopt)
drifted.Interval = 30
e.kuma.Create(t.Context(), drifted)
e.kuma.SetNotificationsErr(errors.New("boom"))
for _, name := range []string{"a", "b"} {
if _, err := e.reconcile(t, name); err == nil {
t.Fatalf("%s: expected error", name)
}
if c := ready(t, e.get(t, name)); c.Status != metav1.ConditionFalse || c.Reason != ReasonKumaUnavailable {
t.Errorf("%s: Ready = %+v", name, c)
}
}
if e.cnt.creates != 0 || e.cnt.updates != 0 {
t.Errorf("creates=%d updates=%d", e.cnt.creates, e.cnt.updates)
}
}
func TestReconcileOwnerTagMissing(t *testing.T) {
e := newEnv(t, httpMonitor("a"))
e.kuma.SetErrs(kumaclient.ErrOwnerTagMissing, nil, nil, nil)
@@ -13,6 +13,7 @@ import (
kuma "github.com/breml/go-uptime-kuma-client"
"github.com/breml/go-uptime-kuma-client/monitor"
"github.com/breml/go-uptime-kuma-client/notification"
"github.com/breml/go-uptime-kuma-client/tag"
)
@@ -373,8 +374,8 @@ func (b *Breml) update(ctx context.Context, m Monitor) error {
return fmt.Errorf("update %d: monitor carries owner key %q, not %q: %w", m.ID, key, m.OwnerKey, ErrNotOwned)
}
// Start from the full current object, also across a type change, so
// fields we do not manage (notifications, proxy, resend interval, upside
// down, auth) survive the overwrite.
// fields we do not manage (proxy, resend interval, upside down, auth)
// survive the overwrite.
wire, err := toWire(m, &cur)
if err != nil {
return err
@@ -404,18 +405,42 @@ func (b *Breml) Delete(ctx context.Context, id int64) error {
return err
}
// DefaultNotifications reads the notification list cached by the session.
// Kuma has no command to request it: it pushes the list at login and to every
// session of the user after each change.
func (b *Breml) DefaultNotifications(ctx context.Context) ([]int64, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
b.mu.Lock()
defer b.mu.Unlock()
return defaultIDs(b.c.GetNotifications(ctx)), nil
}
func defaultIDs(list []notification.Base) []int64 {
var ids []int64
for _, n := range list {
if n.IsDefault {
ids = append(ids, n.ID)
}
}
slices.Sort(ids)
return ids
}
// toDomain converts a monitor from the Kuma monitor list.
func toDomain(base monitor.Base, ownerKey string) (Monitor, error) {
m := Monitor{
ID: base.ID,
Type: base.Type(),
Name: base.Name,
Parent: base.Parent,
Active: base.IsActive,
Interval: int32(base.Interval),
RetryInterval: int32(base.RetryInterval),
MaxRetries: int32(base.MaxRetries),
OwnerKey: ownerKey,
ID: base.ID,
Type: base.Type(),
Name: base.Name,
Parent: base.Parent,
Active: base.IsActive,
Interval: int32(base.Interval),
RetryInterval: int32(base.RetryInterval),
MaxRetries: int32(base.MaxRetries),
NotificationIDs: slices.Clone(base.NotificationIDs),
OwnerKey: ownerKey,
}
if base.Description != nil {
m.Description = *base.Description
@@ -565,6 +590,7 @@ func writeBase(b *monitor.Base, m Monitor) {
b.RetryInterval = int64(m.RetryInterval)
b.MaxRetries = int64(m.MaxRetries)
b.IsActive = m.Active
b.NotificationIDs = slices.Clone(m.NotificationIDs)
}
// writeHTTP expects m to be normalized.
@@ -287,12 +287,10 @@ func TestUpdateKeepsUnchangedFields(t *testing.T) {
}
}
// setUnmanaged sets state through the raw library that Monitor does not model.
func setUnmanaged(t *testing.T, b *Breml, id int64) (notificationID int64) {
func createWebhook(t *testing.T, b *Breml, name string, isDefault bool) int64 {
t.Helper()
ctx := context.Background()
nid, err := b.c.CreateNotification(ctx, &notification.Webhook{
Base: notification.Base{Name: "n", IsActive: true},
nid, err := b.c.CreateNotification(context.Background(), &notification.Webhook{
Base: notification.Base{Name: name, IsActive: true, IsDefault: isDefault},
WebhookDetails: notification.WebhookDetails{
WebhookURL: "http://example.com/hook", WebhookContentType: "application/json",
},
@@ -300,6 +298,15 @@ func setUnmanaged(t *testing.T, b *Breml, id int64) (notificationID int64) {
if err != nil {
t.Fatalf("create notification: %v", err)
}
return nid
}
// setUnmanaged sets state through the raw library that Monitor does not
// model, and attaches a notification by hand.
func setUnmanaged(t *testing.T, b *Breml, id int64) (notificationID int64) {
t.Helper()
ctx := context.Background()
nid := createWebhook(t, b, "n", false)
var kw monitor.HTTPKeyword
if err := b.c.GetMonitorAs(ctx, id, &kw); err != nil {
t.Fatal(err)
@@ -313,16 +320,15 @@ func setUnmanaged(t *testing.T, b *Breml, id int64) (notificationID int64) {
return nid
}
func assertUnmanaged(t *testing.T, c *Breml, id, nid int64, when string) {
func assertUnmanaged(t *testing.T, c *Breml, id int64, when string) {
t.Helper()
var base monitor.Base
base, err := c.c.GetMonitor(t.Context(), id)
if err != nil {
t.Fatal(err)
}
if !base.UpsideDown || base.ResendInterval != 7 || !reflect.DeepEqual(base.NotificationIDs, []int64{nid}) {
t.Errorf("%s: unmanaged state lost: upsideDown=%v resend=%d notifications=%v",
when, base.UpsideDown, base.ResendInterval, base.NotificationIDs)
if !base.UpsideDown || base.ResendInterval != 7 {
t.Errorf("%s: unmanaged state lost: upsideDown=%v resend=%d", when, base.UpsideDown, base.ResendInterval)
}
}
@@ -362,14 +368,15 @@ func TestUpdatePreservesUnmanagedAndClearsManaged(t *testing.T) {
t.Fatal(err)
}
m := byID(got)[id]
if m.Parent != nil || m.Headers != nil || m.Keyword != "" || m.Interval != 90 {
if m.Parent != nil || m.Headers != nil || m.Keyword != "" || m.Interval != 90 || m.NotificationIDs != nil {
t.Errorf("managed fields not cleared: %+v", m)
}
assertUnmanaged(t, fresh, id, nid, "after plain update")
assertUnmanaged(t, fresh, id, "after plain update")
// Type change must not wipe unmanaged state either.
changed := cleared
changed.Type = "http"
changed.NotificationIDs = []int64{nid}
if err := b.Update(ctx, changed); err != nil {
t.Fatal(err)
}
@@ -377,10 +384,55 @@ func TestUpdatePreservesUnmanagedAndClearsManaged(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if m := byID(got)[id]; m.Type != "http" {
t.Errorf("type = %q, want http", m.Type)
if m := byID(got)[id]; m.Type != "http" || !reflect.DeepEqual(m.NotificationIDs, []int64{nid}) {
t.Errorf("type = %q, notifications = %v, want http and [%d]", m.Type, m.NotificationIDs, nid)
}
assertUnmanaged(t, fresh, id, "after type change")
}
func TestDefaultNotificationsAndWrite(t *testing.T) {
ctx := context.Background()
url := startKuma(t)
b := setupClient(t, url)
other := freshClient(t, url)
if ids, err := other.DefaultNotifications(ctx); err != nil || ids != nil {
t.Fatalf("no notifications: ids=%v err=%v", ids, err)
}
def := createWebhook(t, b, "default", true)
createWebhook(t, b, "opt-in", false)
// Kuma pushes the list to every session of the user, not only the writer.
deadline := time.Now().Add(10 * time.Second)
for {
ids, err := other.DefaultNotifications(ctx)
if err != nil {
t.Fatal(err)
}
if reflect.DeepEqual(ids, []int64{def}) {
break
}
if time.Now().After(deadline) {
t.Fatalf("other session sees defaults %v, want [%d]", ids, def)
}
time.Sleep(100 * time.Millisecond)
}
m := Monitor{
Type: "group", Name: "grp", Active: true, Interval: 60, RetryInterval: 60,
NotificationIDs: []int64{def}, OwnerKey: "default/grp",
}
id, err := b.Create(ctx, m)
if err != nil {
t.Fatal(err)
}
got, err := freshClient(t, url).ListOwned(ctx)
if err != nil {
t.Fatal(err)
}
m.ID = id
if !reflect.DeepEqual(byID(got)[id], m.Normalized()) {
t.Errorf("read-back differs:\n got %+v\n want %+v", byID(got)[id], m.Normalized())
}
assertUnmanaged(t, fresh, id, nid, "after type change")
}
func TestRestartAdoptsByTag(t *testing.T) {
@@ -63,6 +63,7 @@ func Conflicts(err error) (conflicts []*ConflictError, only bool) {
func (m Monitor) clone() Monitor {
m.Headers = maps.Clone(m.Headers)
m.AcceptedStatusCodes = slices.Clone(m.AcceptedStatusCodes)
m.NotificationIDs = slices.Clone(m.NotificationIDs)
if m.Parent != nil {
p := *m.Parent
m.Parent = &p
@@ -80,12 +81,18 @@ func isHTTPFamily(typ string) bool {
// For http, keyword and json-query: an empty Method becomes GET, empty
// AcceptedStatusCodes become ["200-299"], and Timeout 0 becomes
// round(0.8*Interval). For all other types Timeout is stored as 0 and the
// HTTP-only slices are nil. Empty Headers become nil. It is idempotent.
// HTTP-only slices are nil. Empty Headers become nil. NotificationIDs are
// sorted and deduplicated, empty becomes nil. It is idempotent.
func (m Monitor) Normalized() Monitor {
m = m.clone()
if len(m.Headers) == 0 {
m.Headers = nil
}
slices.Sort(m.NotificationIDs)
m.NotificationIDs = slices.Compact(m.NotificationIDs)
if len(m.NotificationIDs) == 0 {
m.NotificationIDs = nil
}
if len(m.AcceptedStatusCodes) == 0 {
m.AcceptedStatusCodes = nil
}
@@ -143,6 +150,9 @@ type Monitor struct {
DNSResolveType string
DNSResolveServer string
// NotificationIDs are the Kuma notifications attached to the monitor.
NotificationIDs []int64
// OwnerKey is the value of the kuma-sync tag; "" means not ours.
OwnerKey string
}
@@ -170,4 +180,7 @@ type Client interface {
// Delete removes an owned monitor. It wraps ErrNotFound for an unknown ID
// and ErrNotOwned for a monitor without the kuma-sync tag.
Delete(ctx context.Context, id int64) error
// DefaultNotifications returns the IDs of all notifications marked
// "default enabled" in Kuma, sorted. An empty result is valid.
DefaultNotifications(ctx context.Context) ([]int64, error)
}
@@ -3,6 +3,7 @@ package kumaclient
import (
"context"
"fmt"
"slices"
"sort"
"sync"
)
@@ -20,6 +21,10 @@ type Fake struct {
UpdateErr error
DeleteErr error
ProbeErr error
// NotificationsErr is returned by DefaultNotifications.
NotificationsErr error
// Defaults are the IDs DefaultNotifications returns.
Defaults []int64
// ListPartial makes ListOwned return the stored monitors together with
// ListErr, as the real client does for owner conflicts.
ListPartial bool
@@ -90,6 +95,32 @@ func (f *Fake) Probe(_ context.Context, id int64) (bool, string, error) {
return ok, m.OwnerKey, nil
}
// SetDefaultNotifications sets the IDs DefaultNotifications returns.
func (f *Fake) SetDefaultNotifications(ids ...int64) {
f.mu.Lock()
defer f.mu.Unlock()
f.Defaults = slices.Clone(ids)
}
// SetNotificationsErr makes DefaultNotifications return err.
func (f *Fake) SetNotificationsErr(err error) {
f.mu.Lock()
defer f.mu.Unlock()
f.NotificationsErr = err
}
// DefaultNotifications returns Defaults, sorted.
func (f *Fake) DefaultNotifications(_ context.Context) ([]int64, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.NotificationsErr != nil {
return nil, f.NotificationsErr
}
ids := slices.Clone(f.Defaults)
slices.Sort(ids)
return ids, nil
}
func (f *Fake) allocID() int64 {
id := f.nextID
f.nextID++
@@ -118,6 +118,21 @@ func TestFakeInjectedErrors(t *testing.T) {
}
}
func TestFakeDefaultNotifications(t *testing.T) {
f := NewFake()
if ids, err := f.DefaultNotifications(context.Background()); err != nil || ids != nil {
t.Fatalf("empty: %v %v", ids, err)
}
f.SetDefaultNotifications(4, 1)
if ids, err := f.DefaultNotifications(context.Background()); err != nil || len(ids) != 2 || ids[0] != 1 || ids[1] != 4 {
t.Fatalf("set: %v %v", ids, err)
}
f.SetNotificationsErr(errors.New("boom"))
if _, err := f.DefaultNotifications(context.Background()); err == nil {
t.Error("expected injected error")
}
}
func TestFakeUpdateRejectsOtherOwnerKey(t *testing.T) {
f := NewFake()
id, _ := f.Create(context.Background(), Monitor{Name: "a", OwnerKey: "ns/a"})
@@ -7,6 +7,7 @@ import (
"testing"
kuma "github.com/breml/go-uptime-kuma-client"
"github.com/breml/go-uptime-kuma-client/notification"
"github.com/breml/go-uptime-kuma-client/tag"
)
@@ -167,3 +168,29 @@ func TestFakeStoresNormalized(t *testing.T) {
t.Fatalf("fake did not normalize: %+v", got)
}
}
func TestNormalizedNotificationIDs(t *testing.T) {
in := Monitor{Type: "group", NotificationIDs: []int64{10, 2, 10}}
got := in.Normalized()
if !reflect.DeepEqual(got.NotificationIDs, []int64{2, 10}) {
t.Errorf("notifications = %v, want [2 10]", got.NotificationIDs)
}
if !reflect.DeepEqual(in.NotificationIDs, []int64{10, 2, 10}) {
t.Errorf("Normalized changed its receiver: %v", in.NotificationIDs)
}
if e := (Monitor{Type: "group", NotificationIDs: []int64{}}).Normalized(); e.NotificationIDs != nil {
t.Errorf("empty notifications must be nil: %v", e.NotificationIDs)
}
}
func TestDefaultIDs(t *testing.T) {
got := defaultIDs([]notification.Base{
{ID: 5, IsDefault: true}, {ID: 1}, {ID: 3, IsDefault: true},
})
if !reflect.DeepEqual(got, []int64{3, 5}) {
t.Errorf("defaultIDs = %v, want [3 5]", got)
}
if got := defaultIDs(nil); got != nil {
t.Errorf("defaultIDs(nil) = %v, want nil", got)
}
}
@@ -18,7 +18,6 @@ resources:
- prometheusrule-netbird-availability.yaml
- prometheusrule-netbird-ingress.yaml
- prometheusrule-openbao.yaml
- prometheusrule-uptime-kuma.yaml
# Prometheus's own retention. Until REN-107 `retention: 90d` and
# `retentionSize: 30GB` were both set, the size cap bound first, and the
# effective window was a 20.5-27.2 day saw-tooth with nothing reporting it.
@@ -44,8 +44,6 @@ spec:
# traverses the WAN and detects a controller/DC outage (the
# 2026-07-24 one ran ~20 h with no signal). Dashboard root, 200.
- https://netbird.renner.uno
# Dead-man probe for the off-site Uptime Kuma.
- https://kuma.renner.uno
# NetBird relay health endpoints on the netcup members (relay
# binary serves :9000/health, verified on 0.73.2). Plain http,
# ports outside the hairpin's 80/443 — real WAN path.
@@ -26,7 +26,6 @@ spec:
# proxy-1/2:9000/health same file, relay redundancy
# ci.renner.uno WoodpeckerDown
# openbao .../sys/health OpenBaoUnavailable
# kuma.renner.uno UptimeKumaUnreachable
#
# Warning, not critical, on purpose: this rule cannot know what a given
# target means. A target whose loss deserves a critical page gets its
@@ -34,7 +33,7 @@ spec:
# exclusion list from being arbitrary.
# anchor: blackbox-probe-failing
- alert: BlackboxProbeFailing
expr: probe_success{job="blackbox-http", instance!~"https://fsn1\\.your-objectstorage\\.com|https://netbird\\.renner\\.uno|http://proxy-[12]\\.renner\\.uno:9000/health|https://ci\\.renner\\.uno|https://kuma\\.renner\\.uno|https://openbao\\.renner\\.uno/v1/sys/health.*"} == 0
expr: probe_success{job="blackbox-http", instance!~"https://fsn1\\.your-objectstorage\\.com|https://netbird\\.renner\\.uno|http://proxy-[12]\\.renner\\.uno:9000/health|https://ci\\.renner\\.uno|https://openbao\\.renner\\.uno/v1/sys/health.*"} == 0
for: 10m
annotations:
summary: "Blackbox probe of {{ $labels.instance }} has been failing for 10 minutes"
@@ -1,35 +0,0 @@
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: uptime-kuma
namespace: monitoring
labels:
# kube-prometheus-stack's Prometheus selects on this label.
release: kube-prometheus-stack
spec:
groups:
- name: uptime-kuma.rules
rules:
# Dead-man probe for the off-site Uptime Kuma.
- alert: UptimeKumaUnreachable
expr: probe_success{job="blackbox-http", instance="https://kuma.renner.uno"} == 0
for: 10m
labels:
severity: critical
annotations:
summary: "Uptime Kuma (kuma.renner.uno) unreachable for 10m+"
description: "The blackbox probe of the off-site Uptime Kuma has been failing for 10 minutes. The external endpoints it monitors are unwatched until it returns. Check the NetBird VPS (netbird.renner.uno), the uptime-kuma container and Traefik."
# monitor_response_time is ms; -1 when a beat has no ping (DOWN, group).
# anchor: uptime-kuma-slow-response
- alert: UptimeKumaSlowResponse
expr: avg by (monitor_name) (avg_over_time((monitor_response_time > 0)[15m:1m])) > 1500
for: 15m
labels:
severity: warning
annotations:
summary: "Uptime Kuma monitor {{ $labels.monitor_name }} averaging {{ $value | humanize }} ms response time"
description: >-
The average response time of monitor {{ $labels.monitor_name }},
as measured from the off-site Uptime Kuma, has stayed above
1500 ms for 15 minutes.
@@ -32,7 +32,6 @@ retired the depth rule in favour of one persistence rule (`> 5` for `20m`).
| `ceph_test.yaml` | Hand-written suite for the `CephPoolLostRedundancyBlind` canary in `prometheusrule-ceph.yaml` (REN-93). |
| `disk-wear_test.yaml` | Hand-written suite for the EPHEMERAL exclusion in `prometheusrule-disk-wear.yaml` (REN-419). |
| `gitea_test.yaml` | Hand-written suite for `GiteaDataVolumeLow` in `prometheusrule-gitea.yaml` (REN-583). |
| `uptime-kuma_test.yaml` | Hand-written suite for `prometheusrule-uptime-kuma.yaml` and the kuma exclusion in `prometheusrule-blackbox.yaml`. |
| `mutations.txt` | Negative controls. Each must make the suite fail. |
## Adding a suite
@@ -172,12 +172,3 @@ disk-wear-ephemeral-keeps-partition|kubernetes/apps/monitoring-rules/prometheusr
# exactly-25% case in gitea_test.yaml.
gitea-for-10m|kubernetes/apps/monitoring-rules/prometheusrule-gitea.yaml|/alert: GiteaDataVolumeLow/,/for:/s/for: 15m/for: 10m/
gitea-floor-le|kubernetes/apps/monitoring-rules/prometheusrule-gitea.yaml|s/^\([[:space:]]*\)< 0\.25$/\1<= 0.25/
# prometheusrule-uptime-kuma.yaml / blackbox kuma exclusion. Detected by the
# cases in uptime-kuma_test.yaml.
kuma-slow-no-positive-filter|kubernetes/apps/monitoring-rules/prometheusrule-uptime-kuma.yaml|s/(monitor_response_time > 0)/(monitor_response_time)/
kuma-slow-threshold-2500|kubernetes/apps/monitoring-rules/prometheusrule-uptime-kuma.yaml|s/\[15m:1m\])) > 1500$/[15m:1m])) > 2500/
kuma-slow-threshold-900|kubernetes/apps/monitoring-rules/prometheusrule-uptime-kuma.yaml|s/\[15m:1m\])) > 1500$/[15m:1m])) > 900/
kuma-slow-for-30m|kubernetes/apps/monitoring-rules/prometheusrule-uptime-kuma.yaml|/alert: UptimeKumaSlowResponse/,/for:/s/for: 15m/for: 30m/
kuma-down-for-20m|kubernetes/apps/monitoring-rules/prometheusrule-uptime-kuma.yaml|/alert: UptimeKumaUnreachable/,/for:/s/for: 10m/for: 20m/
kuma-blackbox-exclusion-dropped|kubernetes/apps/monitoring-rules/prometheusrule-blackbox.yaml|s/kuma\\\\\.renner/kumx\\\\.renner/
@@ -1,110 +0,0 @@
# Suite for prometheusrule-uptime-kuma.yaml and the kuma exclusion in
# prometheusrule-blackbox.yaml.
rule_files:
- ../prometheusrule-uptime-kuma.yaml
- ../prometheusrule-blackbox.yaml
evaluation_interval: 1m
tests:
# 2000 ms sustained: pending at 0m, fires 15m later, not before.
- interval: 1m
input_series:
- series: 'monitor_response_time{monitor_name="slow-mon"}'
values: '2000+0x40'
alert_rule_test:
- eval_time: 14m
alertname: UptimeKumaSlowResponse
exp_alerts: []
- eval_time: 16m
alertname: UptimeKumaSlowResponse
exp_alerts:
- exp_labels:
severity: warning
monitor_name: slow-mon
exp_annotations:
summary: "Uptime Kuma monitor slow-mon averaging 2k ms response time"
description: "The average response time of monitor slow-mon, as measured from the off-site Uptime Kuma, has stayed above 1500 ms for 15 minutes."
# 1000 ms stays silent; a monitor reporting only -1 (DOWN, group) stays silent.
- interval: 1m
input_series:
- series: 'monitor_response_time{monitor_name="fast-mon"}'
values: '1000+0x40'
- series: 'monitor_response_time{monitor_name="down-mon"}'
values: '-1+0x40'
alert_rule_test:
- eval_time: 35m
alertname: UptimeKumaSlowResponse
exp_alerts: []
# Alternating 1000 and -1: the -1 samples must not drag a slow average down
# nor count as slow; positives average 1000, silent.
- interval: 1m
input_series:
- series: 'monitor_response_time{monitor_name="flap-fast"}'
values: '1000 -1 1000 -1 1000 -1 1000 -1 1000 -1 1000 -1 1000 -1 1000 -1 1000 -1 1000 -1 1000 -1 1000 -1 1000 -1 1000 -1 1000 -1 1000 -1 1000 -1 1000 -1 1000 -1 1000 -1'
alert_rule_test:
- eval_time: 35m
alertname: UptimeKumaSlowResponse
exp_alerts: []
# Alternating 2000 and -1: the -1 samples are excluded, so the average of the
# real samples is 2000 and the alert fires. Unfiltered, the mean is ~500.
- interval: 1m
input_series:
- series: 'monitor_response_time{monitor_name="flap-slow"}'
values: '2000 -1 2000 -1 2000 -1 2000 -1 2000 -1 2000 -1 2000 -1 2000 -1 2000 -1 2000 -1 2000 -1 2000 -1 2000 -1 2000 -1 2000 -1 2000 -1 2000 -1 2000 -1 2000 -1 2000 -1'
alert_rule_test:
- eval_time: 35m
alertname: UptimeKumaSlowResponse
exp_alerts:
- exp_labels:
severity: warning
monitor_name: flap-slow
exp_annotations:
summary: "Uptime Kuma monitor flap-slow averaging 2k ms response time"
description: "The average response time of monitor flap-slow, as measured from the off-site Uptime Kuma, has stayed above 1500 ms for 15 minutes."
# Dead-man probe: critical after 10m of failure; the catch-all warning must
# not also fire for kuma, but still fires for another failing instance.
- interval: 1m
input_series:
- series: 'probe_success{job="blackbox-http",instance="https://kuma.renner.uno"}'
values: '0+0x40'
- series: 'probe_success{job="blackbox-http",instance="https://other.renner.uno"}'
values: '0+0x40'
alert_rule_test:
- eval_time: 9m
alertname: UptimeKumaUnreachable
exp_alerts: []
- eval_time: 11m
alertname: UptimeKumaUnreachable
exp_alerts:
- exp_labels:
severity: critical
job: blackbox-http
instance: https://kuma.renner.uno
exp_annotations:
summary: "Uptime Kuma (kuma.renner.uno) unreachable for 10m+"
description: "The blackbox probe of the off-site Uptime Kuma has been failing for 10 minutes. The external endpoints it monitors are unwatched until it returns. Check the NetBird VPS (netbird.renner.uno), the uptime-kuma container and Traefik."
- eval_time: 15m
alertname: BlackboxProbeFailing
exp_alerts:
- exp_labels:
severity: warning
job: blackbox-http
instance: https://other.renner.uno
exp_annotations:
summary: "Blackbox probe of https://other.renner.uno has been failing for 10 minutes"
description: "The endpoint is not answering as expected. For the Authentik-gated hostnames this is a reachability check covering DNS, TLS, Traefik and the gate outpost — it says nothing about the app behind the gate, which the internal /-/healthy probes cover separately."
# Healthy probe stays silent.
- interval: 1m
input_series:
- series: 'probe_success{job="blackbox-http",instance="https://kuma.renner.uno"}'
values: '1+0x40'
alert_rule_test:
- eval_time: 30m
alertname: UptimeKumaUnreachable
exp_alerts: []
@@ -97,10 +97,6 @@ spec:
resources:
requests:
storage: 50Gi
# Mounted at /etc/prometheus/secrets/<name>/; see
# ./uptime-kuma-metrics-externalsecret.yaml.
secrets:
- uptime-kuma-metrics
# Additional scrape targets not covered by ServiceMonitors.
additionalScrapeConfigs:
# Gitea exposes /metrics anonymously on its main HTTP port. The
@@ -110,16 +106,6 @@ spec:
static_configs:
- targets: ["gitea.gitea.svc.cluster.local:3000"]
metrics_path: /metrics
# Off-site Uptime Kuma. /metrics takes the API key as the basic-auth
# password with an empty username.
- job_name: uptime-kuma
scheme: https
metrics_path: /metrics
basic_auth:
username: ""
password_file: /etc/prometheus/secrets/uptime-kuma-metrics/api_key
static_configs:
- targets: ["kuma.renner.uno:443"]
# Grafana
grafana:
@@ -3,7 +3,6 @@ kind: Kustomization
resources:
- grafana-oauth-externalsecret.yaml
- alertmanager-smtp-externalsecret.yaml
- uptime-kuma-metrics-externalsecret.yaml
- helmrelease-prometheus.yaml
- helmrelease-loki.yaml
- helmrelease-alloy-syslog.yaml
@@ -1,19 +0,0 @@
---
# Uptime Kuma /metrics API key for Prometheus; OpenBao homelab/infrastructure/uptime-kuma-metrics.
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: uptime-kuma-metrics
namespace: monitoring
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: openbao
target:
name: uptime-kuma-metrics
data:
- secretKey: api_key
remoteRef:
key: infrastructure/uptime-kuma-metrics
property: api_key