Files
homelab/ci/images/kuma-sync/internal/kumaclient/client.go
T
claude-botandClaude Opus 5.5 37c83ed6c2 feat(kuma-sync): declare the public status page as a KumaStatusPage
New namespaced CRD KumaStatusPage (uptime.renner.uno/v1alpha1) and a
reconciler that owns the Kuma status page completely: config, domain
names and the full group/monitor list. Sections select KumaMonitors in
any namespace by label; monitors are ordered by displayName, then
namespace/name, a monitor goes to the first matching section only, and
monitors without a Kuma ID are left out and counted in status.

The reconciler adopts an existing page with the slug, writes only when
the managed content differs (no write per resync), reuses Kuma group IDs
by position so saves do not churn them, reverts UI edits on resync,
deletes the page on resource deletion, and reports Ready=False/Conflict
for a younger resource claiming the same slug. Every KumaMonitor change
to labels, displayName, monitor ID or deletion enqueues all pages.

kumaclient gains a StatusPages interface (Breml + Fake). The socket API
returns no group list, so groups are read from Kuma's public
GET /api/status-page/<slug>, as the bootstrap did. Fields kuma-sync does
not manage (logo, custom CSS, analytics, RSS title, auto refresh
interval) are written back unchanged. autoRefreshInterval is not in the
spec: go-uptime-kuma-client v0.6.2 (latest) never sends it on save.

The Ansible bootstrap no longer creates or edits status pages; the
existing page is left in place for kuma-sync to adopt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 21:03:50 +02:00

205 lines
6.4 KiB
Go

// Package kumaclient is the boundary to Uptime Kuma.
package kumaclient
import (
"context"
"errors"
"fmt"
"maps"
"math"
"slices"
)
var (
ErrNotFound = errors.New("monitor not found")
ErrNotOwned = errors.New("monitor not owned by kuma-sync")
// ErrOwnerTagMissing means the kuma-sync tag no longer exists in Kuma.
ErrOwnerTagMissing = errors.New("kuma-sync tag missing in Kuma")
// ErrOwnerConflict means a monitor carries more than one kuma-sync tag value.
ErrOwnerConflict = errors.New("monitor carries conflicting kuma-sync tag values")
// ErrPossibleOrphan means Create may have left an untagged monitor in Kuma.
ErrPossibleOrphan = errors.New("possible untagged orphan monitor left in Kuma")
// ErrStatusPageNotFound means no status page has the slug.
ErrStatusPageNotFound = errors.New("status page not found")
)
// ConflictError reports a monitor carrying several different kuma-sync tag
// values. Keys lists all of them, sorted. It matches ErrOwnerConflict.
type ConflictError struct {
MonitorID int64
Keys []string
}
func (e *ConflictError) Error() string {
return fmt.Sprintf("monitor %d carries %d owner keys %q", e.MonitorID, len(e.Keys), e.Keys)
}
func (e *ConflictError) Unwrap() error { return ErrOwnerConflict }
// Conflicts returns every ConflictError inside err, and whether err consists
// of nothing but conflicts. ListOwned joins one per conflicting monitor.
func Conflicts(err error) (conflicts []*ConflictError, only bool) {
only = err != nil
var walk func(error)
walk = func(e error) {
switch x := e.(type) {
case nil:
case *ConflictError:
conflicts = append(conflicts, x)
case interface{ Unwrap() []error }:
for _, s := range x.Unwrap() {
walk(s)
}
default:
if u, ok := e.(interface{ Unwrap() error }); ok && u.Unwrap() != nil {
walk(u.Unwrap())
return
}
only = false
}
}
walk(err)
return conflicts, only && len(conflicts) > 0
}
func (m Monitor) clone() Monitor {
m.Headers = maps.Clone(m.Headers)
m.AcceptedStatusCodes = slices.Clone(m.AcceptedStatusCodes)
m.NotificationIDs = slices.Clone(m.NotificationIDs)
m.DNSConditions = slices.Clone(m.DNSConditions)
if m.Parent != nil {
p := *m.Parent
m.Parent = &p
}
return m
}
func isHTTPFamily(typ string) bool {
return typ == "http" || typ == "keyword" || typ == "json-query"
}
// Normalized returns m with the defaults the real client applies on write, so
// a desired monitor can be compared with an observed one.
//
// 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. 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
}
if len(m.DNSConditions) == 0 {
m.DNSConditions = nil
}
if !isHTTPFamily(m.Type) {
m.Timeout = 0
return m
}
if m.Method == "" {
m.Method = "GET"
}
if m.AcceptedStatusCodes == nil {
m.AcceptedStatusCodes = []string{"200-299"}
}
if m.Timeout == 0 {
m.Timeout = int32(math.Round(0.8 * float64(m.Interval)))
}
return m
}
// Condition is one flat Kuma monitor condition.
type Condition struct {
Variable string
Operator string
Value string
// AndOr joins the condition to the previous one: "and" or "or".
AndOr string
}
// Monitor is the domain view of a Kuma monitor.
type Monitor struct {
ID int64
Type string
Name string
Description string
Parent *int64
Active bool
Interval int32
RetryInterval int32
MaxRetries int32
// Timeout is in seconds and only applies to http, keyword and json-query.
// 0 is written as round(0.8*Interval), because Kuma treats a stored 0 as a
// huge request timeout. For other types it is ignored and reads back as 0.
Timeout int32
URL string
Method string
Body string
Headers map[string]string
AcceptedStatusCodes []string
MaxRedirects int32
IgnoreTLS bool
ExpiryNotification bool
Keyword string
InvertKeyword bool
JSONPath string
JSONPathOperator string
ExpectedValue string
Hostname string
Port int32
DNSResolveType string
DNSResolveServer string
DNSConditions []Condition
// 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
}
// Client manages only monitors carrying the kuma-sync tag.
type Client interface {
// ListOwned returns monitors carrying the kuma-sync tag, normalized.
// It returns ErrOwnerTagMissing, with no list, if the tag was deleted in
// Kuma, so callers never mistake that for "no monitors". A monitor with
// conflicting tag values is left out and reported as a *ConflictError (all
// of them joined; see Conflicts) together with the rest of the list. Any
// error means the list is incomplete: callers must not conclude a monitor
// is absent from it.
ListOwned(ctx context.Context) ([]Monitor, error)
// Create creates the monitor and tags it with m.OwnerKey.
Create(ctx context.Context, m Monitor) (int64, error)
// Probe reports whether a monitor with this ID exists, owned or not, and
// the owner key it carries ("" when untagged). A monitor with conflicting
// tag values returns an error that wraps ErrOwnerConflict.
Probe(ctx context.Context, id int64) (found bool, ownerKey string, err error)
// Update overwrites an owned monitor. It wraps ErrNotFound for an unknown ID
// and ErrNotOwned for a monitor without the kuma-sync tag or whose tag
// value differs from m.OwnerKey.
Update(ctx context.Context, m Monitor) error
// 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)
StatusPages
}