Files
homelab/ci/images/kuma-sync/internal/kumaclient/fake.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

206 lines
4.8 KiB
Go

package kumaclient
import (
"context"
"fmt"
"slices"
"sort"
"sync"
)
// Fake is an in-memory Client safe for concurrent use.
// ListErr, CreateErr, UpdateErr and DeleteErr, when set, are returned by the
// matching method; set them via the Set*Err methods while the fake is shared.
type Fake struct {
mu sync.Mutex
monitors map[int64]Monitor
nextID int64
ListErr error
CreateErr error
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
pages map[string]StatusPage
pageGroups map[int64]string // group ID -> page slug
nextPageID int64
nextGroupID int64
// PageErr, when set, is returned by every status page method.
PageErr error
}
// SetListPartial makes ListOwned return the stored monitors and err.
func (f *Fake) SetListPartial(err error) {
f.mu.Lock()
defer f.mu.Unlock()
f.ListErr, f.ListPartial = err, true
}
var _ Client = (*Fake)(nil)
func NewFake() *Fake {
return &Fake{
monitors: map[int64]Monitor{}, nextID: 1,
pages: map[string]StatusPage{}, pageGroups: map[int64]string{}, nextPageID: 1, nextGroupID: 1,
}
}
// AddForeign adds an untagged monitor. A zero ID is assigned the next free one.
func (f *Fake) AddForeign(m Monitor) int64 {
f.mu.Lock()
defer f.mu.Unlock()
m.OwnerKey = ""
if m.ID == 0 {
m.ID = f.allocID()
} else if m.ID >= f.nextID {
f.nextID = m.ID + 1
}
f.monitors[m.ID] = m.clone()
return m.ID
}
// Get returns a copy of the monitor with the given ID, owned or not, or nil.
func (f *Fake) Get(id int64) *Monitor {
f.mu.Lock()
defer f.mu.Unlock()
m, ok := f.monitors[id]
if !ok {
return nil
}
c := m.clone()
return &c
}
// SetErrs sets all four injected errors under the lock.
func (f *Fake) SetErrs(list, create, update, del error) {
f.mu.Lock()
defer f.mu.Unlock()
f.ListErr, f.CreateErr, f.UpdateErr, f.DeleteErr = list, create, update, del
f.ListPartial = false
}
// SetProbeErr makes Probe return err.
func (f *Fake) SetProbeErr(err error) {
f.mu.Lock()
defer f.mu.Unlock()
f.ProbeErr = err
}
// Probe reports whether a monitor exists, owned or not, and its owner key.
func (f *Fake) Probe(_ context.Context, id int64) (bool, string, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.ProbeErr != nil {
return false, "", f.ProbeErr
}
m, ok := f.monitors[id]
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++
return id
}
func (f *Fake) ListOwned(_ context.Context) ([]Monitor, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.ListErr != nil && !f.ListPartial {
return nil, f.ListErr
}
var out []Monitor
for _, m := range f.monitors {
if m.OwnerKey != "" {
out = append(out, m.clone())
}
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out, f.ListErr
}
func (f *Fake) Create(_ context.Context, m Monitor) (int64, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.CreateErr != nil {
return 0, f.CreateErr
}
if m.OwnerKey == "" {
return 0, fmt.Errorf("create: empty OwnerKey")
}
m.ID = f.allocID()
f.monitors[m.ID] = m.Normalized()
return m.ID, nil
}
func (f *Fake) Update(_ context.Context, m Monitor) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.UpdateErr != nil {
return f.UpdateErr
}
cur, ok := f.monitors[m.ID]
if !ok {
return fmt.Errorf("update %d: %w", m.ID, ErrNotFound)
}
if cur.OwnerKey == "" {
return fmt.Errorf("update %d: %w", m.ID, ErrNotOwned)
}
if m.OwnerKey != cur.OwnerKey {
return fmt.Errorf("update %d: owner key %q, not %q: %w", m.ID, cur.OwnerKey, m.OwnerKey, ErrNotOwned)
}
f.monitors[m.ID] = m.Normalized()
return nil
}
func (f *Fake) Delete(_ context.Context, id int64) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.DeleteErr != nil {
return f.DeleteErr
}
cur, ok := f.monitors[id]
if !ok {
return fmt.Errorf("delete %d: %w", id, ErrNotFound)
}
if cur.OwnerKey == "" {
return fmt.Errorf("delete %d: %w", id, ErrNotOwned)
}
delete(f.monitors, id)
return nil
}