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>
316 lines
11 KiB
Go
316 lines
11 KiB
Go
package controller
|
|
|
|
import (
|
|
"cmp"
|
|
"context"
|
|
"errors"
|
|
"reflect"
|
|
"slices"
|
|
"time"
|
|
|
|
"k8s.io/apimachinery/pkg/api/meta"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/labels"
|
|
"k8s.io/apimachinery/pkg/types"
|
|
ctrl "sigs.k8s.io/controller-runtime"
|
|
"sigs.k8s.io/controller-runtime/pkg/builder"
|
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
|
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
|
|
"sigs.k8s.io/controller-runtime/pkg/event"
|
|
"sigs.k8s.io/controller-runtime/pkg/handler"
|
|
"sigs.k8s.io/controller-runtime/pkg/log"
|
|
"sigs.k8s.io/controller-runtime/pkg/predicate"
|
|
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
|
|
|
v1 "gitea.renner.uno/konairius/homelab/kuma-sync/api/v1alpha1"
|
|
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/kumaclient"
|
|
)
|
|
|
|
const (
|
|
ReasonConflict = "Conflict"
|
|
ReasonInvalidSelector = "InvalidSelector"
|
|
|
|
msgPageSynced = "status page in sync"
|
|
msgSlugConflict = "an older KumaStatusPage owns this slug"
|
|
msgInvalidSelector = "a section selector is invalid"
|
|
)
|
|
|
|
// +kubebuilder:rbac:groups=uptime.renner.uno,resources=kumastatuspages,verbs=get;list;watch;update;patch
|
|
// +kubebuilder:rbac:groups=uptime.renner.uno,resources=kumastatuspages/status,verbs=get;update;patch
|
|
// +kubebuilder:rbac:groups=uptime.renner.uno,resources=kumastatuspages/finalizers,verbs=update
|
|
|
|
// StatusPageReconciler syncs KumaStatusPage resources to Kuma. The page,
|
|
// its domain names and its whole group list belong to the resource.
|
|
type StatusPageReconciler struct {
|
|
Client client.Client
|
|
Kuma kumaclient.StatusPages
|
|
// Resync is how often a healthy resource is reconciled again, which
|
|
// reverts edits made in Kuma. 0 means 5 minutes.
|
|
Resync time.Duration
|
|
}
|
|
|
|
func (r *StatusPageReconciler) resync() time.Duration {
|
|
if r.Resync > 0 {
|
|
return r.Resync
|
|
}
|
|
return resyncPeriod
|
|
}
|
|
|
|
// SetupWithManager ignores status-only updates of the page itself. Any
|
|
// KumaMonitor change that can alter a page's content enqueues every page.
|
|
func (r *StatusPageReconciler) SetupWithManager(mgr ctrl.Manager) error {
|
|
return ctrl.NewControllerManagedBy(mgr).
|
|
For(&v1.KumaStatusPage{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})).
|
|
Watches(&v1.KumaMonitor{}, handler.EnqueueRequestsFromMapFunc(r.allPages),
|
|
builder.WithPredicates(predicate.Funcs{UpdateFunc: monitorListingChanged})).
|
|
Named("kumastatuspage").
|
|
Complete(r)
|
|
}
|
|
|
|
// monitorListingChanged reports changes to what a page shows of a monitor.
|
|
func monitorListingChanged(e event.UpdateEvent) bool {
|
|
o, ok1 := e.ObjectOld.(*v1.KumaMonitor)
|
|
n, ok2 := e.ObjectNew.(*v1.KumaMonitor)
|
|
if !ok1 || !ok2 {
|
|
return true
|
|
}
|
|
return !reflect.DeepEqual(o.Labels, n.Labels) ||
|
|
o.Spec.DisplayName != n.Spec.DisplayName ||
|
|
o.Status.MonitorID != n.Status.MonitorID ||
|
|
o.DeletionTimestamp.IsZero() != n.DeletionTimestamp.IsZero()
|
|
}
|
|
|
|
func (r *StatusPageReconciler) allPages(ctx context.Context, _ client.Object) []reconcile.Request {
|
|
var list v1.KumaStatusPageList
|
|
if err := r.Client.List(ctx, &list); err != nil {
|
|
log.FromContext(ctx).Error(err, "list KumaStatusPages to enqueue")
|
|
return nil
|
|
}
|
|
reqs := make([]reconcile.Request, 0, len(list.Items))
|
|
for _, p := range list.Items {
|
|
reqs = append(reqs, reconcile.Request{NamespacedName: types.NamespacedName{Namespace: p.Namespace, Name: p.Name}})
|
|
}
|
|
return reqs
|
|
}
|
|
|
|
// pageView is the status written after a successful sync.
|
|
type pageView struct {
|
|
id int64
|
|
sections []v1.SectionStatus
|
|
unsynced int32
|
|
}
|
|
|
|
func (r *StatusPageReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
|
|
sp := &v1.KumaStatusPage{}
|
|
if err := r.Client.Get(ctx, req.NamespacedName, sp); err != nil {
|
|
return ctrl.Result{}, client.IgnoreNotFound(err)
|
|
}
|
|
if !sp.DeletionTimestamp.IsZero() {
|
|
return r.reconcileDelete(ctx, sp)
|
|
}
|
|
if controllerutil.AddFinalizer(sp, Finalizer) {
|
|
if err := r.Client.Update(ctx, sp); err != nil {
|
|
return ctrl.Result{}, err
|
|
}
|
|
}
|
|
|
|
owner, err := r.ownsSlug(ctx, sp)
|
|
if err != nil {
|
|
return ctrl.Result{}, err
|
|
}
|
|
if !owner {
|
|
err := r.setReady(ctx, sp, metav1.ConditionFalse, ReasonConflict, msgSlugConflict, nil)
|
|
return ctrl.Result{RequeueAfter: r.resync()}, err
|
|
}
|
|
|
|
desired, view, err := r.desired(ctx, sp)
|
|
if err != nil {
|
|
var sel *selectorError
|
|
if errors.As(err, &sel) {
|
|
log.FromContext(ctx).Info("invalid section selector", "section", sel.section, "error", sel.err.Error())
|
|
err := r.setReady(ctx, sp, metav1.ConditionFalse, ReasonInvalidSelector, msgInvalidSelector, nil)
|
|
return ctrl.Result{RequeueAfter: r.resync()}, err
|
|
}
|
|
return ctrl.Result{}, err
|
|
}
|
|
|
|
cur, err := r.Kuma.GetStatusPage(ctx, sp.Spec.Slug)
|
|
switch {
|
|
case errors.Is(err, kumaclient.ErrStatusPageNotFound):
|
|
id, err := r.Kuma.AddStatusPage(ctx, sp.Spec.Slug, sp.Spec.Title)
|
|
if err != nil {
|
|
return r.fail(ctx, sp, &failure{ReasonKumaUnavailable, msgKumaUnavailable, err})
|
|
}
|
|
view.id = id
|
|
if err := r.Kuma.SaveStatusPage(ctx, desired); err != nil {
|
|
return r.fail(ctx, sp, &failure{ReasonKumaUnavailable, msgKumaUnavailable, err})
|
|
}
|
|
case err != nil:
|
|
return r.fail(ctx, sp, &failure{ReasonKumaUnavailable, msgKumaUnavailable, err})
|
|
default:
|
|
view.id = cur.ID
|
|
// Reusing the page's group IDs in order keeps them stable; Kuma
|
|
// rewrites name and monitors of each.
|
|
for i := range desired.Groups {
|
|
if i < len(cur.Groups) {
|
|
desired.Groups[i].ID = cur.Groups[i].ID
|
|
}
|
|
}
|
|
if !desired.SameContent(cur) {
|
|
log.FromContext(ctx).Info("status page differs from spec, saving", "slug", sp.Spec.Slug)
|
|
if err := r.Kuma.SaveStatusPage(ctx, desired); err != nil {
|
|
return r.fail(ctx, sp, &failure{ReasonKumaUnavailable, msgKumaUnavailable, err})
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := r.setReady(ctx, sp, metav1.ConditionTrue, ReasonSynced, msgPageSynced, &view); err != nil {
|
|
return ctrl.Result{}, err
|
|
}
|
|
return ctrl.Result{RequeueAfter: r.resync()}, nil
|
|
}
|
|
|
|
func (r *StatusPageReconciler) reconcileDelete(ctx context.Context, sp *v1.KumaStatusPage) (ctrl.Result, error) {
|
|
if !controllerutil.ContainsFinalizer(sp, Finalizer) {
|
|
return ctrl.Result{}, nil
|
|
}
|
|
owner, err := r.ownsSlug(ctx, sp)
|
|
if err != nil {
|
|
return ctrl.Result{}, err
|
|
}
|
|
if owner {
|
|
err := r.Kuma.DeleteStatusPage(ctx, sp.Spec.Slug)
|
|
if err != nil && !errors.Is(err, kumaclient.ErrStatusPageNotFound) {
|
|
return r.fail(ctx, sp, &failure{ReasonKumaUnavailable, msgKumaUnavailable, err})
|
|
}
|
|
}
|
|
controllerutil.RemoveFinalizer(sp, Finalizer)
|
|
return ctrl.Result{}, r.Client.Update(ctx, sp)
|
|
}
|
|
|
|
// ownsSlug reports whether sp is the oldest KumaStatusPage with its slug;
|
|
// ties go to the lower namespace/name.
|
|
func (r *StatusPageReconciler) ownsSlug(ctx context.Context, sp *v1.KumaStatusPage) (bool, error) {
|
|
var list v1.KumaStatusPageList
|
|
if err := r.Client.List(ctx, &list); err != nil {
|
|
return false, err
|
|
}
|
|
for i := range list.Items {
|
|
o := &list.Items[i]
|
|
if o.Spec.Slug != sp.Spec.Slug || (o.Namespace == sp.Namespace && o.Name == sp.Name) {
|
|
continue
|
|
}
|
|
if comparePages(o, sp) < 0 {
|
|
return false, nil
|
|
}
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
func comparePages(a, b *v1.KumaStatusPage) int {
|
|
if c := a.CreationTimestamp.Compare(b.CreationTimestamp.Time); c != 0 {
|
|
return c
|
|
}
|
|
return cmp.Or(cmp.Compare(a.Namespace, b.Namespace), cmp.Compare(a.Name, b.Name))
|
|
}
|
|
|
|
type selectorError struct {
|
|
section string
|
|
err error
|
|
}
|
|
|
|
func (e *selectorError) Error() string { return "section " + e.section + ": " + e.err.Error() }
|
|
|
|
// desired builds the page from the spec and the KumaMonitors of all
|
|
// namespaces. Each section lists its matching monitors by displayName, then
|
|
// namespace/name; a monitor goes to the first section it matches. Monitors
|
|
// without a Kuma ID, or being deleted, are left out; the former are counted.
|
|
func (r *StatusPageReconciler) desired(ctx context.Context, sp *v1.KumaStatusPage) (kumaclient.StatusPage, pageView, error) {
|
|
s := sp.Spec
|
|
page := kumaclient.StatusPage{
|
|
Slug: s.Slug,
|
|
Title: s.Title,
|
|
Description: s.Description,
|
|
FooterText: s.FooterText,
|
|
Theme: s.Theme,
|
|
DomainNames: slices.Clone(s.DomainNames),
|
|
ShowTags: s.ShowTags,
|
|
ShowPoweredBy: s.ShowPoweredBy,
|
|
ShowCertificateExpiry: s.ShowCertificateExpiry,
|
|
}
|
|
var view pageView
|
|
|
|
var monitors v1.KumaMonitorList
|
|
if err := r.Client.List(ctx, &monitors); err != nil {
|
|
return page, view, err
|
|
}
|
|
items := monitors.Items
|
|
slices.SortFunc(items, func(a, b v1.KumaMonitor) int {
|
|
return cmp.Or(
|
|
cmp.Compare(a.Spec.DisplayName, b.Spec.DisplayName),
|
|
cmp.Compare(a.Namespace, b.Namespace),
|
|
cmp.Compare(a.Name, b.Name),
|
|
)
|
|
})
|
|
|
|
taken := make([]bool, len(items))
|
|
for _, sec := range s.Sections {
|
|
sel, err := metav1.LabelSelectorAsSelector(&sec.Selector)
|
|
if err != nil {
|
|
return page, view, &selectorError{section: sec.Name, err: err}
|
|
}
|
|
group := kumaclient.StatusGroup{Name: sec.Name}
|
|
for i := range items {
|
|
km := &items[i]
|
|
if taken[i] || !km.DeletionTimestamp.IsZero() || !sel.Matches(labels.Set(km.Labels)) {
|
|
continue
|
|
}
|
|
taken[i] = true
|
|
if km.Status.MonitorID == 0 {
|
|
view.unsynced++
|
|
continue
|
|
}
|
|
group.Monitors = append(group.Monitors, kumaclient.PageMonitor{ID: km.Status.MonitorID})
|
|
}
|
|
page.Groups = append(page.Groups, group)
|
|
view.sections = append(view.sections, v1.SectionStatus{Name: sec.Name, Monitors: int32(len(group.Monitors))})
|
|
}
|
|
return page, view, nil
|
|
}
|
|
|
|
// fail records a Ready=False outcome and returns its cause so the request is
|
|
// requeued with backoff.
|
|
func (r *StatusPageReconciler) fail(ctx context.Context, sp *v1.KumaStatusPage, f *failure) (ctrl.Result, error) {
|
|
log.FromContext(ctx).Info("reconcile failed", "reason", f.reason, "error", f.err.Error())
|
|
if serr := r.setReady(ctx, sp, metav1.ConditionFalse, f.reason, f.message, nil); serr != nil {
|
|
return ctrl.Result{}, errors.Join(f.err, serr)
|
|
}
|
|
return ctrl.Result{}, f.err
|
|
}
|
|
|
|
// setReady writes the Ready condition, and the synced view when given,
|
|
// through the status subresource. It writes nothing if the status is
|
|
// unchanged.
|
|
func (r *StatusPageReconciler) setReady(
|
|
ctx context.Context, sp *v1.KumaStatusPage, status metav1.ConditionStatus, reason, msg string, view *pageView,
|
|
) error {
|
|
orig := sp.Status.DeepCopy()
|
|
if view != nil {
|
|
sp.Status.StatusPageID = view.id
|
|
sp.Status.Sections = view.sections
|
|
sp.Status.UnsyncedMonitors = view.unsynced
|
|
sp.Status.ObservedGeneration = sp.Generation
|
|
}
|
|
meta.SetStatusCondition(&sp.Status.Conditions, metav1.Condition{
|
|
Type: v1.ConditionReady,
|
|
Status: status,
|
|
Reason: reason,
|
|
Message: msg,
|
|
ObservedGeneration: sp.Generation,
|
|
})
|
|
if reflect.DeepEqual(*orig, sp.Status) {
|
|
return nil
|
|
}
|
|
return r.Client.Status().Update(ctx, sp)
|
|
}
|