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>
207 lines
6.3 KiB
Go
207 lines
6.3 KiB
Go
package kumaclient
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"github.com/breml/go-uptime-kuma-client/statuspage"
|
|
)
|
|
|
|
const (
|
|
// Messages Kuma acks for an unknown slug
|
|
// (server/socket-handlers/status-page-socket-handler.js).
|
|
msgNoSlug = "No slug?"
|
|
msgPageNotFound = "Status Page is not found"
|
|
defaultIconPath = "/icon.svg"
|
|
maxPublicPageBody = 8 << 20
|
|
)
|
|
|
|
// pageNotFound maps Kuma's unknown-slug acks to ErrStatusPageNotFound. The
|
|
// library formats a failed ack as "<command>: <msg>".
|
|
func pageNotFound(slug string, err error) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
msg := err.Error()
|
|
if strings.HasSuffix(msg, ": "+msgNoSlug) || strings.HasSuffix(msg, ": "+msgPageNotFound) {
|
|
return fmt.Errorf("status page %q: %w", slug, errors.Join(ErrStatusPageNotFound, err))
|
|
}
|
|
return err
|
|
}
|
|
|
|
// publicPage is the part of GET /api/status-page/<slug> that the socket API
|
|
// does not return: the groups in display order, each with its monitors in
|
|
// display order.
|
|
type publicPage struct {
|
|
PublicGroupList []struct {
|
|
ID int64 `json:"id"`
|
|
Name string `json:"name"`
|
|
MonitorList []struct {
|
|
ID int64 `json:"id"`
|
|
SendURL any `json:"sendUrl"`
|
|
} `json:"monitorList"`
|
|
} `json:"publicGroupList"`
|
|
}
|
|
|
|
// truthy follows JavaScript truthiness for the values sendUrl takes: a
|
|
// boolean, or 0/1 from the database.
|
|
func truthy(v any) bool {
|
|
switch x := v.(type) {
|
|
case bool:
|
|
return x
|
|
case float64:
|
|
return x != 0
|
|
case string:
|
|
return x != ""
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (b *Breml) GetStatusPage(ctx context.Context, slug string) (StatusPage, error) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return withRecovery(ctx, b, func() (StatusPage, error) { return b.getStatusPage(ctx, slug) })
|
|
}
|
|
|
|
func (b *Breml) getStatusPage(ctx context.Context, slug string) (StatusPage, error) {
|
|
sp, err := b.c.GetStatusPage(ctx, slug)
|
|
if err != nil {
|
|
return StatusPage{}, pageNotFound(slug, err)
|
|
}
|
|
pub, err := b.fetchPublicPage(ctx, slug)
|
|
if err != nil {
|
|
return StatusPage{}, err
|
|
}
|
|
p := StatusPage{
|
|
ID: sp.ID,
|
|
Slug: sp.Slug,
|
|
Title: sp.Title,
|
|
Description: sp.Description,
|
|
FooterText: sp.FooterText,
|
|
Theme: sp.Theme,
|
|
DomainNames: sp.DomainNameList,
|
|
ShowTags: sp.ShowTags,
|
|
ShowPoweredBy: sp.ShowPoweredBy,
|
|
ShowCertificateExpiry: sp.ShowCertificateExpiry,
|
|
}
|
|
for _, g := range pub.PublicGroupList {
|
|
group := StatusGroup{ID: g.ID, Name: g.Name}
|
|
for _, m := range g.MonitorList {
|
|
group.Monitors = append(group.Monitors, PageMonitor{ID: m.ID, ShowURL: truthy(m.SendURL)})
|
|
}
|
|
p.Groups = append(p.Groups, group)
|
|
}
|
|
return p.Normalized(), nil
|
|
}
|
|
|
|
// fetchPublicPage reads the public page data. Kuma caches the response for up
|
|
// to five minutes and clears the cache on every saveStatusPage.
|
|
func (b *Breml) fetchPublicPage(ctx context.Context, slug string) (publicPage, error) {
|
|
ctx, cancel := context.WithTimeout(ctx, operationTimeout)
|
|
defer cancel()
|
|
u := strings.TrimRight(b.url, "/") + "/api/status-page/" + url.PathEscape(slug)
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
|
if err != nil {
|
|
return publicPage{}, err
|
|
}
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return publicPage{}, fmt.Errorf("get public status page %q: %w", slug, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, maxPublicPageBody))
|
|
if err != nil {
|
|
return publicPage{}, fmt.Errorf("get public status page %q: %w", slug, err)
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
return publicPage{}, fmt.Errorf("get public status page %q: HTTP %d", slug, resp.StatusCode)
|
|
}
|
|
var pub publicPage
|
|
if err := json.Unmarshal(body, &pub); err != nil {
|
|
return publicPage{}, fmt.Errorf("get public status page %q: %w", slug, err)
|
|
}
|
|
return pub, nil
|
|
}
|
|
|
|
func (b *Breml) AddStatusPage(ctx context.Context, slug, title string) (int64, error) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
if err := b.c.AddStatusPage(ctx, title, slug); err != nil {
|
|
// Not retried: the page may exist although the ack was lost; the next
|
|
// reconcile finds it.
|
|
b.recoverSession(ctx, err)
|
|
return 0, err
|
|
}
|
|
sp, err := withRecovery(ctx, b, func() (*statuspage.StatusPage, error) { return b.c.GetStatusPage(ctx, slug) })
|
|
if err != nil {
|
|
return 0, pageNotFound(slug, err)
|
|
}
|
|
return sp.ID, nil
|
|
}
|
|
|
|
func (b *Breml) SaveStatusPage(ctx context.Context, p StatusPage) error {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
_, err := withRecovery(ctx, b, func() (struct{}, error) { return struct{}{}, b.saveStatusPage(ctx, p) })
|
|
return err
|
|
}
|
|
|
|
// saveStatusPage starts from the page's current config, so the fields
|
|
// kuma-sync does not manage are written back unchanged. The library never
|
|
// sends autoRefreshInterval, rssTitle or showOnlyLastHeartbeat, which Kuma
|
|
// then leaves as stored.
|
|
func (b *Breml) saveStatusPage(ctx context.Context, p StatusPage) error {
|
|
p = p.Normalized()
|
|
cur, err := b.c.GetStatusPage(ctx, p.Slug)
|
|
if err != nil {
|
|
return pageNotFound(p.Slug, err)
|
|
}
|
|
sp := *cur
|
|
sp.Title = p.Title
|
|
sp.Description = p.Description
|
|
sp.FooterText = p.FooterText
|
|
sp.Theme = p.Theme
|
|
sp.ShowTags = p.ShowTags
|
|
sp.ShowPoweredBy = p.ShowPoweredBy
|
|
sp.ShowCertificateExpiry = p.ShowCertificateExpiry
|
|
// Kuma rejects anything but an array.
|
|
sp.DomainNameList = append([]string{}, p.DomainNames...)
|
|
// Kuma reports an unset logo as its default path; writing that back would
|
|
// store it.
|
|
if sp.Icon == defaultIconPath {
|
|
sp.Icon = ""
|
|
}
|
|
sp.PublicGroupList = make([]statuspage.PublicGroup, 0, len(p.Groups))
|
|
for _, g := range p.Groups {
|
|
group := statuspage.PublicGroup{ID: g.ID, Name: g.Name, MonitorList: []statuspage.PublicMonitor{}}
|
|
for _, m := range g.Monitors {
|
|
pm := statuspage.PublicMonitor{ID: m.ID}
|
|
if m.ShowURL {
|
|
show := true
|
|
pm.SendURL = &show
|
|
}
|
|
group.MonitorList = append(group.MonitorList, pm)
|
|
}
|
|
sp.PublicGroupList = append(sp.PublicGroupList, group)
|
|
}
|
|
if _, err := b.c.SaveStatusPage(ctx, &sp); err != nil {
|
|
return pageNotFound(p.Slug, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (b *Breml) DeleteStatusPage(ctx context.Context, slug string) error {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
_, err := withRecovery(ctx, b, func() (struct{}, error) {
|
|
return struct{}{}, pageNotFound(slug, b.c.DeleteStatusPage(ctx, slug))
|
|
})
|
|
return err
|
|
}
|