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>
205 lines
6.4 KiB
Go
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
|
|
}
|