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>
This commit is contained in:
2026-10-20 21:03:50 +02:00
co-authored by Claude Opus 5.5
parent bb9d7e66f6
commit 37c83ed6c2
20 changed files with 2137 additions and 64 deletions
@@ -2,10 +2,6 @@
uptime_kuma_version: "2.5.6" uptime_kuma_version: "2.5.6"
uptime_kuma_host: kuma.renner.uno uptime_kuma_host: kuma.renner.uno
uptime_kuma_base_url: "https://{{ uptime_kuma_host }}" uptime_kuma_base_url: "https://{{ uptime_kuma_host }}"
# Custom domain of the status page; not routed by Traefik yet.
uptime_kuma_status_host: uptime.renner.uno
uptime_kuma_status_slug: renner
uptime_kuma_status_title: Renner
uptime_kuma_dir: /opt/uptime-kuma uptime_kuma_dir: /opt/uptime-kuma
uptime_kuma_traefik_network: netbird_netbird uptime_kuma_traefik_network: netbird_netbird
+2 -48
View File
@@ -1,4 +1,4 @@
// Bootstraps Uptime Kuma: admin user, general settings, SMTP notification, status page, // Bootstraps Uptime Kuma: admin user, general settings, SMTP notification,
// and (when KUMA_METRICS_ROTATE=true) a fresh Prometheus API key written to KUMA_METRICS_KEY_FILE. // and (when KUMA_METRICS_ROTATE=true) a fresh Prometheus API key written to KUMA_METRICS_KEY_FILE.
// All input comes from environment variables; secrets are never printed. // All input comes from environment variables; secrets are never printed.
// Output lines: "admin user created" / "already set up", then "changed: <step>" or "ok: <step>". // Output lines: "admin user created" / "already set up", then "changed: <step>" or "ok: <step>".
@@ -30,9 +30,6 @@ const REQUIRED = [
"KUMA_SMTP_PASSWORD", "KUMA_SMTP_PASSWORD",
"KUMA_SMTP_FROM", "KUMA_SMTP_FROM",
"KUMA_SMTP_TO", "KUMA_SMTP_TO",
"KUMA_STATUS_SLUG",
"KUMA_STATUS_TITLE",
"KUMA_STATUS_DOMAIN",
]; ];
const missing = REQUIRED.filter((name) => !process.env[name]); const missing = REQUIRED.filter((name) => !process.env[name]);
const rotateMetricsKey = process.env.KUMA_METRICS_ROTATE === "true"; const rotateMetricsKey = process.env.KUMA_METRICS_ROTATE === "true";
@@ -47,7 +44,7 @@ const env = process.env;
const socket = io(KUMA_URL, { transports: ["websocket"], reconnection: false }); const socket = io(KUMA_URL, { transports: ["websocket"], reconnection: false });
const lists = {}; const lists = {};
const LIST_EVENTS = ["notificationList", "apiKeyList", "statusPageList"]; const LIST_EVENTS = ["notificationList", "apiKeyList"];
for (const event of LIST_EVENTS) { for (const event of LIST_EVENTS) {
socket.on(event, (data) => { socket.on(event, (data) => {
lists[event] = data; lists[event] = data;
@@ -154,48 +151,6 @@ async function ensureNotification() {
report("notification", true); report("notification", true);
} }
async function fetchPublicGroupList(slug) {
const res = await fetch(`${KUMA_URL}/api/status-page/${encodeURIComponent(slug)}`);
if (!res.ok) {
throw new Error(`GET /api/status-page/${slug} returned ${res.status}`);
}
const body = await res.json();
return (body.publicGroupList || []).map((group) => ({
id: group.id,
name: group.name,
monitorList: (group.monitorList || []).map((monitor) => {
const entry = { id: monitor.id };
if (monitor.sendUrl !== undefined) entry.sendUrl = monitor.sendUrl;
if (monitor.url !== undefined) entry.url = monitor.url;
return entry;
}),
}));
}
async function ensureStatusPage() {
const slug = env.KUMA_STATUS_SLUG;
let changed = false;
const pages = Object.values(lists.statusPageList || {});
if (!pages.some((page) => page.slug === slug)) {
await callOk("addStatusPage", env.KUMA_STATUS_TITLE, slug);
changed = true;
}
const { config } = await callOk("getStatusPage", slug);
const domains = config.domainNameList || [];
if (config.title !== env.KUMA_STATUS_TITLE || !domains.includes(env.KUMA_STATUS_DOMAIN)) {
// saveStatusPage replaces all groups, so the current ones are sent back unchanged.
const publicGroupList = await fetchPublicGroupList(slug);
const updated = {
...config,
title: env.KUMA_STATUS_TITLE,
domainNameList: domains.includes(env.KUMA_STATUS_DOMAIN) ? domains : [...domains, env.KUMA_STATUS_DOMAIN],
};
await callOk("saveStatusPage", slug, updated, config.icon, publicGroupList);
changed = true;
}
report("status page", changed);
}
async function rotateMetricsApiKey() { async function rotateMetricsApiKey() {
if (!rotateMetricsKey) { if (!rotateMetricsKey) {
report("metrics api key", false); report("metrics api key", false);
@@ -215,7 +170,6 @@ async function main() {
await login(); await login();
await ensureSettings(); await ensureSettings();
await ensureNotification(); await ensureNotification();
await ensureStatusPage();
await rotateMetricsApiKey(); await rotateMetricsApiKey();
} }
-9
View File
@@ -139,12 +139,6 @@
- -e - -e
- KUMA_SMTP_TO - KUMA_SMTP_TO
- -e - -e
- KUMA_STATUS_SLUG
- -e
- KUMA_STATUS_TITLE
- -e
- KUMA_STATUS_DOMAIN
- -e
- KUMA_METRICS_ROTATE - KUMA_METRICS_ROTATE
- -e - -e
- KUMA_METRICS_KEY_FILE - KUMA_METRICS_KEY_FILE
@@ -163,9 +157,6 @@
KUMA_SMTP_PASSWORD: "{{ _uptime_kuma_smtp.secret.token }}" KUMA_SMTP_PASSWORD: "{{ _uptime_kuma_smtp.secret.token }}"
KUMA_SMTP_FROM: "{{ uptime_kuma_smtp_from }}" KUMA_SMTP_FROM: "{{ uptime_kuma_smtp_from }}"
KUMA_SMTP_TO: "{{ uptime_kuma_smtp_to }}" KUMA_SMTP_TO: "{{ uptime_kuma_smtp_to }}"
KUMA_STATUS_SLUG: "{{ uptime_kuma_status_slug }}"
KUMA_STATUS_TITLE: "{{ uptime_kuma_status_title }}"
KUMA_STATUS_DOMAIN: "{{ uptime_kuma_status_host }}"
KUMA_METRICS_ROTATE: "{{ 'false' if (_uptime_kuma_metrics_check.status | default(0)) == 200 else 'true' }}" KUMA_METRICS_ROTATE: "{{ 'false' if (_uptime_kuma_metrics_check.status | default(0)) == 200 else 'true' }}"
KUMA_METRICS_KEY_FILE: /app/data/.metrics-api-key KUMA_METRICS_KEY_FILE: /app/data/.metrics-api-key
register: _uptime_kuma_setup register: _uptime_kuma_setup
@@ -0,0 +1,82 @@
package v1alpha1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// StatusPageSection is one group of monitors on the status page.
type StatusPageSection struct {
// +kubebuilder:validation:MinLength=1
Name string `json:"name"`
// Selects KumaMonitors in any namespace.
Selector metav1.LabelSelector `json:"selector"`
}
type KumaStatusPageSpec struct {
// Kuma slug: lowercase letters and digits, single hyphens between them.
// +kubebuilder:validation:Pattern=`^[a-z0-9]+(-[a-z0-9]+)*$`
// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="slug is immutable"
Slug string `json:"slug"`
// +kubebuilder:validation:MinLength=1
Title string `json:"title"`
Description string `json:"description,omitempty"`
FooterText string `json:"footerText,omitempty"`
// Hostnames that serve this page.
// +listType=set
// +kubebuilder:validation:items:MaxLength=253
// +kubebuilder:validation:items:Pattern=`^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$`
DomainNames []string `json:"domainNames,omitempty"`
// +kubebuilder:validation:Enum=auto;light;dark
// +kubebuilder:default=auto
Theme string `json:"theme,omitempty"`
ShowTags bool `json:"showTags,omitempty"`
ShowPoweredBy bool `json:"showPoweredBy,omitempty"`
ShowCertificateExpiry bool `json:"showCertificateExpiry,omitempty"`
// Ordered. A monitor matching several sections is listed in the first only.
// +kubebuilder:validation:MinItems=1
// +listType=map
// +listMapKey=name
Sections []StatusPageSection `json:"sections"`
}
type SectionStatus struct {
Name string `json:"name"`
Monitors int32 `json:"monitors"`
}
type KumaStatusPageStatus struct {
StatusPageID int64 `json:"statusPageID,omitempty"`
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
// Monitors listed per section.
Sections []SectionStatus `json:"sections,omitempty"`
// Selected monitors left out because they have no Kuma monitor ID yet.
UnsyncedMonitors int32 `json:"unsyncedMonitors,omitempty"`
// +listType=map
// +listMapKey=type
Conditions []metav1.Condition `json:"conditions,omitempty"`
}
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:resource:path=kumastatuspages,scope=Namespaced
// +kubebuilder:printcolumn:name="Slug",type=string,JSONPath=`.spec.slug`
// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status`
type KumaStatusPage struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec KumaStatusPageSpec `json:"spec"`
Status KumaStatusPageStatus `json:"status,omitempty"`
}
// +kubebuilder:object:root=true
type KumaStatusPageList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []KumaStatusPage `json:"items"`
}
func init() {
SchemeBuilder.Register(&KumaStatusPage{}, &KumaStatusPageList{})
}
@@ -4,6 +4,7 @@ package v1alpha1
import ( import (
"context" "context"
"fmt"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
@@ -196,3 +197,84 @@ func TestDeletionBumpsGeneration(t *testing.T) {
t.Fatalf("generation %d did not increase past %d on deletion", got.Generation, before) t.Fatalf("generation %d did not increase past %d on deletion", got.Generation, before)
} }
} }
func statusPage(name string, mutate func(*KumaStatusPageSpec)) *KumaStatusPage {
sp := &KumaStatusPage{
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default"},
Spec: KumaStatusPageSpec{
Slug: "renner", Title: "Renner",
Sections: []StatusPageSection{{Name: "Core", Selector: metav1.LabelSelector{}}},
},
}
if mutate != nil {
mutate(&sp.Spec)
}
return sp
}
func expectPageInvalid(t *testing.T, sp *KumaStatusPage, wants ...string) {
t.Helper()
err := k8s.Create(context.Background(), sp)
if err == nil || !errors.IsInvalid(err) {
t.Fatalf("expected invalid, got %v", err)
}
for _, w := range wants {
if !strings.Contains(err.Error(), w) {
t.Fatalf("error %q does not contain %q", err.Error(), w)
}
}
}
func TestStatusPageDefaults(t *testing.T) {
ctx := context.Background()
sp := statusPage("page-defaults", nil)
if err := k8s.Create(ctx, sp); err != nil {
t.Fatal(err)
}
got := &KumaStatusPage{}
if err := k8s.Get(ctx, client.ObjectKeyFromObject(sp), got); err != nil {
t.Fatal(err)
}
if got.Spec.Theme != "auto" {
t.Fatalf("theme = %q, want auto", got.Spec.Theme)
}
}
func TestStatusPageSlugImmutable(t *testing.T) {
ctx := context.Background()
sp := statusPage("page-immutable", nil)
if err := k8s.Create(ctx, sp); err != nil {
t.Fatal(err)
}
sp.Spec.Slug = "other"
err := k8s.Update(ctx, sp)
if err == nil || !strings.Contains(err.Error(), "slug is immutable") {
t.Fatalf("expected immutable error, got %v", err)
}
}
func TestStatusPageInvalidSpecs(t *testing.T) {
cases := map[string]struct {
mutate func(*KumaStatusPageSpec)
want string
}{
"uppercase slug": {func(s *KumaStatusPageSpec) { s.Slug = "Renner" }, "spec.slug"},
"double hyphen": {func(s *KumaStatusPageSpec) { s.Slug = "a--b" }, "spec.slug"},
"trailing hyphen": {func(s *KumaStatusPageSpec) { s.Slug = "a-" }, "spec.slug"},
"empty title": {func(s *KumaStatusPageSpec) { s.Title = "" }, "spec.title"},
"no sections": {func(s *KumaStatusPageSpec) { s.Sections = nil }, "spec.sections"},
"bad theme": {func(s *KumaStatusPageSpec) { s.Theme = "blue" }, "spec.theme"},
"bad domain": {func(s *KumaStatusPageSpec) { s.DomainNames = []string{"https://x.example"} }, "spec.domainNames"},
"duplicate domain": {func(s *KumaStatusPageSpec) { s.DomainNames = []string{"a.example", "a.example"} }, "spec.domainNames"},
"duplicate section": {func(s *KumaStatusPageSpec) {
s.Sections = append(s.Sections, StatusPageSection{Name: "Core"})
}, "spec.sections"},
}
i := 0
for name, c := range cases {
i++
t.Run(name, func(t *testing.T) {
expectPageInvalid(t, statusPage(fmt.Sprintf("page-invalid-%d", i), c.mutate), c.want)
})
}
}
@@ -237,6 +237,119 @@ func (in *KumaMonitorStatus) DeepCopy() *KumaMonitorStatus {
return out return out
} }
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *KumaStatusPage) DeepCopyInto(out *KumaStatusPage) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
in.Spec.DeepCopyInto(&out.Spec)
in.Status.DeepCopyInto(&out.Status)
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KumaStatusPage.
func (in *KumaStatusPage) DeepCopy() *KumaStatusPage {
if in == nil {
return nil
}
out := new(KumaStatusPage)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *KumaStatusPage) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *KumaStatusPageList) DeepCopyInto(out *KumaStatusPageList) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ListMeta.DeepCopyInto(&out.ListMeta)
if in.Items != nil {
in, out := &in.Items, &out.Items
*out = make([]KumaStatusPage, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KumaStatusPageList.
func (in *KumaStatusPageList) DeepCopy() *KumaStatusPageList {
if in == nil {
return nil
}
out := new(KumaStatusPageList)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *KumaStatusPageList) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *KumaStatusPageSpec) DeepCopyInto(out *KumaStatusPageSpec) {
*out = *in
if in.DomainNames != nil {
in, out := &in.DomainNames, &out.DomainNames
*out = make([]string, len(*in))
copy(*out, *in)
}
if in.Sections != nil {
in, out := &in.Sections, &out.Sections
*out = make([]StatusPageSection, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KumaStatusPageSpec.
func (in *KumaStatusPageSpec) DeepCopy() *KumaStatusPageSpec {
if in == nil {
return nil
}
out := new(KumaStatusPageSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *KumaStatusPageStatus) DeepCopyInto(out *KumaStatusPageStatus) {
*out = *in
if in.Sections != nil {
in, out := &in.Sections, &out.Sections
*out = make([]SectionStatus, len(*in))
copy(*out, *in)
}
if in.Conditions != nil {
in, out := &in.Conditions, &out.Conditions
*out = make([]v1.Condition, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KumaStatusPageStatus.
func (in *KumaStatusPageStatus) DeepCopy() *KumaStatusPageStatus {
if in == nil {
return nil
}
out := new(KumaStatusPageStatus)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *LocalRef) DeepCopyInto(out *LocalRef) { func (in *LocalRef) DeepCopyInto(out *LocalRef) {
*out = *in *out = *in
@@ -266,3 +379,34 @@ func (in *PortSpec) DeepCopy() *PortSpec {
in.DeepCopyInto(out) in.DeepCopyInto(out)
return out return out
} }
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *SectionStatus) DeepCopyInto(out *SectionStatus) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SectionStatus.
func (in *SectionStatus) DeepCopy() *SectionStatus {
if in == nil {
return nil
}
out := new(SectionStatus)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *StatusPageSection) DeepCopyInto(out *StatusPageSection) {
*out = *in
in.Selector.DeepCopyInto(&out.Selector)
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StatusPageSection.
func (in *StatusPageSection) DeepCopy() *StatusPageSection {
if in == nil {
return nil
}
out := new(StatusPageSection)
in.DeepCopyInto(out)
return out
}
@@ -0,0 +1,246 @@
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.23.0
name: kumastatuspages.uptime.renner.uno
spec:
group: uptime.renner.uno
names:
kind: KumaStatusPage
listKind: KumaStatusPageList
plural: kumastatuspages
singular: kumastatuspage
scope: Namespaced
versions:
- additionalPrinterColumns:
- jsonPath: .spec.slug
name: Slug
type: string
- jsonPath: .status.conditions[?(@.type=="Ready")].status
name: Ready
type: string
name: v1alpha1
schema:
openAPIV3Schema:
properties:
apiVersion:
description: |-
APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
type: string
kind:
description: |-
Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
type: string
metadata:
type: object
spec:
properties:
description:
type: string
domainNames:
description: Hostnames that serve this page.
items:
maxLength: 253
pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
type: string
type: array
x-kubernetes-list-type: set
footerText:
type: string
sections:
description: Ordered. A monitor matching several sections is listed
in the first only.
items:
description: StatusPageSection is one group of monitors on the status
page.
properties:
name:
minLength: 1
type: string
selector:
description: Selects KumaMonitors in any namespace.
properties:
matchExpressions:
description: matchExpressions is a list of label selector
requirements. The requirements are ANDed.
items:
description: |-
A label selector requirement is a selector that contains values, a key, and an operator that
relates the key and values.
properties:
key:
description: key is the label key that the selector
applies to.
type: string
operator:
description: |-
operator represents a key's relationship to a set of values.
Valid operators are In, NotIn, Exists and DoesNotExist.
type: string
values:
description: |-
values is an array of string values. If the operator is In or NotIn,
the values array must be non-empty. If the operator is Exists or DoesNotExist,
the values array must be empty. This array is replaced during a strategic
merge patch.
items:
type: string
type: array
x-kubernetes-list-type: atomic
required:
- key
- operator
type: object
type: array
x-kubernetes-list-type: atomic
matchLabels:
additionalProperties:
type: string
description: |-
matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
map is equivalent to an element of matchExpressions, whose key field is "key", the
operator is "In", and the values array contains only "value". The requirements are ANDed.
type: object
type: object
x-kubernetes-map-type: atomic
required:
- name
- selector
type: object
minItems: 1
type: array
x-kubernetes-list-map-keys:
- name
x-kubernetes-list-type: map
showCertificateExpiry:
type: boolean
showPoweredBy:
type: boolean
showTags:
type: boolean
slug:
description: 'Kuma slug: lowercase letters and digits, single hyphens
between them.'
pattern: ^[a-z0-9]+(-[a-z0-9]+)*$
type: string
x-kubernetes-validations:
- message: slug is immutable
rule: self == oldSelf
theme:
default: auto
enum:
- auto
- light
- dark
type: string
title:
minLength: 1
type: string
required:
- sections
- slug
- title
type: object
status:
properties:
conditions:
items:
description: Condition contains details for one aspect of the current
state of this API Resource.
properties:
lastTransitionTime:
description: |-
lastTransitionTime is the last time the condition transitioned from one status to another.
This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
format: date-time
type: string
message:
description: |-
message is a human readable message indicating details about the transition.
This may be an empty string.
maxLength: 32768
type: string
observedGeneration:
description: |-
observedGeneration represents the .metadata.generation that the condition was set based upon.
For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
with respect to the current state of the instance.
format: int64
minimum: 0
type: integer
reason:
description: |-
reason contains a programmatic identifier indicating the reason for the condition's last transition.
Producers of specific condition types may define expected values and meanings for this field,
and whether the values are considered a guaranteed API.
The value should be a CamelCase string.
This field may not be empty.
maxLength: 1024
minLength: 1
pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
type: string
status:
description: status of the condition, one of True, False, Unknown.
enum:
- "True"
- "False"
- Unknown
type: string
type:
description: type of condition in CamelCase or in foo.example.com/CamelCase.
maxLength: 316
pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
type: string
required:
- lastTransitionTime
- message
- reason
- status
- type
type: object
type: array
x-kubernetes-list-map-keys:
- type
x-kubernetes-list-type: map
observedGeneration:
format: int64
type: integer
sections:
description: Monitors listed per section.
items:
properties:
monitors:
format: int32
type: integer
name:
type: string
required:
- monitors
- name
type: object
type: array
statusPageID:
format: int64
type: integer
unsyncedMonitors:
description: Selected monitors left out because they have no Kuma
monitor ID yet.
format: int32
type: integer
type: object
required:
- spec
type: object
served: true
storage: true
subresources:
status: {}
@@ -21,6 +21,7 @@ rules:
- uptime.renner.uno - uptime.renner.uno
resources: resources:
- kumamonitors - kumamonitors
- kumastatuspages
verbs: verbs:
- get - get
- list - list
@@ -31,12 +32,14 @@ rules:
- uptime.renner.uno - uptime.renner.uno
resources: resources:
- kumamonitors/finalizers - kumamonitors/finalizers
- kumastatuspages/finalizers
verbs: verbs:
- update - update
- apiGroups: - apiGroups:
- uptime.renner.uno - uptime.renner.uno
resources: resources:
- kumamonitors/status - kumamonitors/status
- kumastatuspages/status
verbs: verbs:
- get - get
- patch - patch
+4 -1
View File
@@ -47,7 +47,7 @@ type Options struct {
SkipNameValidation bool SkipNameValidation bool
} }
// NewManager builds the manager with the reconciler and the orphan GC. // NewManager builds the manager with the reconcilers and the orphan GC.
// There is no leader election: a single replica runs. // There is no leader election: a single replica runs.
func NewManager(o Options) (manager.Manager, error) { func NewManager(o Options) (manager.Manager, error) {
scheme := runtime.NewScheme() scheme := runtime.NewScheme()
@@ -75,6 +75,9 @@ func NewManager(o Options) (manager.Manager, error) {
if err := (&controller.Reconciler{Client: mgr.GetClient(), Kuma: o.Kuma, Resync: o.Resync}).SetupWithManager(mgr); err != nil { if err := (&controller.Reconciler{Client: mgr.GetClient(), Kuma: o.Kuma, Resync: o.Resync}).SetupWithManager(mgr); err != nil {
return nil, fmt.Errorf("set up reconciler: %w", err) return nil, fmt.Errorf("set up reconciler: %w", err)
} }
if err := (&controller.StatusPageReconciler{Client: mgr.GetClient(), Kuma: o.Kuma, Resync: o.Resync}).SetupWithManager(mgr); err != nil {
return nil, fmt.Errorf("set up status page reconciler: %w", err)
}
if err := mgr.Add(&controller.GC{ if err := mgr.Add(&controller.GC{
Client: mgr.GetClient(), Client: mgr.GetClient(),
Kuma: o.Kuma, Kuma: o.Kuma,
@@ -0,0 +1,315 @@
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)
}
@@ -0,0 +1,439 @@
package controller
import (
"context"
"errors"
"slices"
"testing"
"time"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
"sigs.k8s.io/controller-runtime/pkg/event"
v1 "gitea.renner.uno/konairius/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/kumaclient"
)
// countingPages records status page writes on top of the Kuma fake.
type countingPages struct {
kumaclient.StatusPages
adds, saves, deletes int
}
func (c *countingPages) AddStatusPage(ctx context.Context, slug, title string) (int64, error) {
c.adds++
return c.StatusPages.AddStatusPage(ctx, slug, title)
}
func (c *countingPages) SaveStatusPage(ctx context.Context, p kumaclient.StatusPage) error {
c.saves++
return c.StatusPages.SaveStatusPage(ctx, p)
}
func (c *countingPages) DeleteStatusPage(ctx context.Context, slug string) error {
c.deletes++
return c.StatusPages.DeleteStatusPage(ctx, slug)
}
type pageEnv struct {
r *StatusPageReconciler
k8s client.Client
kuma *kumaclient.Fake
cnt *countingPages
}
func newPageEnv(t *testing.T, objs ...client.Object) *pageEnv {
t.Helper()
s := runtime.NewScheme()
if err := v1.AddToScheme(s); err != nil {
t.Fatal(err)
}
k8s := fake.NewClientBuilder().WithScheme(s).WithObjects(objs...).
WithStatusSubresource(&v1.KumaMonitor{}, &v1.KumaStatusPage{}).Build()
kf := kumaclient.NewFake()
cnt := &countingPages{StatusPages: kf}
return &pageEnv{r: &StatusPageReconciler{Client: k8s, Kuma: cnt}, k8s: k8s, kuma: kf, cnt: cnt}
}
var created = metav1.NewTime(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
func statusPage(name string, sections ...v1.StatusPageSection) *v1.KumaStatusPage {
return &v1.KumaStatusPage{
ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: name, CreationTimestamp: created},
Spec: v1.KumaStatusPageSpec{
Slug: "renner", Title: "Renner", Description: "d", FooterText: "f", Theme: "auto",
DomainNames: []string{"uptime.renner.uno", "status.renner.uno"}, ShowPoweredBy: true,
Sections: sections,
},
}
}
func section(name string, labels map[string]string) v1.StatusPageSection {
return v1.StatusPageSection{Name: name, Selector: metav1.LabelSelector{MatchLabels: labels}}
}
// listed is a KumaMonitor that already has a Kuma monitor ID.
func listed(ns, name, display string, id int64, labels map[string]string) *v1.KumaMonitor {
return &v1.KumaMonitor{
ObjectMeta: metav1.ObjectMeta{Namespace: ns, Name: name, Labels: labels},
Spec: v1.KumaMonitorSpec{Type: "group", DisplayName: display},
Status: v1.KumaMonitorStatus{MonitorID: id},
}
}
func (e *pageEnv) reconcile(t *testing.T, name string) (ctrl.Result, error) {
t.Helper()
return e.r.Reconcile(t.Context(), ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "default", Name: name}})
}
func (e *pageEnv) get(t *testing.T, name string) *v1.KumaStatusPage {
t.Helper()
sp := &v1.KumaStatusPage{}
if err := e.k8s.Get(t.Context(), types.NamespacedName{Namespace: "default", Name: name}, sp); err != nil {
t.Fatal(err)
}
return sp
}
func pageReady(t *testing.T, sp *v1.KumaStatusPage) *metav1.Condition {
t.Helper()
c := meta.FindStatusCondition(sp.Status.Conditions, v1.ConditionReady)
if c == nil {
t.Fatal("no Ready condition")
}
return c
}
func monitorIDs(g kumaclient.StatusGroup) []int64 {
var ids []int64
for _, m := range g.Monitors {
ids = append(ids, m.ID)
}
return ids
}
var public = map[string]string{"status": "public"}
func TestStatusPageCreates(t *testing.T) {
e := newPageEnv(t, statusPage("p", section("Core", public)), listed("a", "x", "X", 7, public))
res, err := e.reconcile(t, "p")
if err != nil {
t.Fatal(err)
}
if res.RequeueAfter != 5*time.Minute {
t.Errorf("RequeueAfter = %v", res.RequeueAfter)
}
if e.cnt.adds != 1 || e.cnt.saves != 1 {
t.Fatalf("adds=%d saves=%d, want 1 and 1", e.cnt.adds, e.cnt.saves)
}
got := e.kuma.Page("renner")
if got == nil {
t.Fatal("page not created")
}
want := kumaclient.StatusPage{
Slug: "renner", Title: "Renner", Description: "d", FooterText: "f", Theme: "auto",
DomainNames: []string{"status.renner.uno", "uptime.renner.uno"}, ShowPoweredBy: true,
Groups: []kumaclient.StatusGroup{{Name: "Core", Monitors: []kumaclient.PageMonitor{{ID: 7}}}},
}
if !got.SameContent(want) {
t.Errorf("page = %+v", *got)
}
sp := e.get(t, "p")
if c := pageReady(t, sp); c.Status != metav1.ConditionTrue || c.Reason != ReasonSynced {
t.Errorf("Ready = %+v", c)
}
if sp.Status.StatusPageID != got.ID || sp.Status.ObservedGeneration != sp.Generation {
t.Errorf("status = %+v", sp.Status)
}
if len(sp.Status.Sections) != 1 || sp.Status.Sections[0] != (v1.SectionStatus{Name: "Core", Monitors: 1}) {
t.Errorf("sections = %+v", sp.Status.Sections)
}
if len(sp.Finalizers) != 1 {
t.Errorf("finalizers = %v", sp.Finalizers)
}
}
func TestStatusPageAdoptsEqualWithoutWrite(t *testing.T) {
e := newPageEnv(t, statusPage("p", section("Core", public)), listed("a", "x", "X", 7, public))
e.kuma.PutPage(kumaclient.StatusPage{
ID: 42, Slug: "renner", Title: "Renner", Description: "d", FooterText: "f", Theme: "auto",
DomainNames: []string{"uptime.renner.uno", "status.renner.uno"}, ShowPoweredBy: true,
Groups: []kumaclient.StatusGroup{{Name: "Core", Monitors: []kumaclient.PageMonitor{{ID: 7}}}},
})
for range 2 {
if _, err := e.reconcile(t, "p"); err != nil {
t.Fatal(err)
}
}
if e.cnt.adds != 0 || e.cnt.saves != 0 {
t.Errorf("adds=%d saves=%d, want none", e.cnt.adds, e.cnt.saves)
}
if id := e.get(t, "p").Status.StatusPageID; id != 42 {
t.Errorf("statusPageID = %d, want 42", id)
}
}
func TestStatusPageNoWriteOnResync(t *testing.T) {
e := newPageEnv(t, statusPage("p", section("Core", public)), listed("a", "x", "X", 7, public))
for range 3 {
if _, err := e.reconcile(t, "p"); err != nil {
t.Fatal(err)
}
}
if e.cnt.adds != 1 || e.cnt.saves != 1 {
t.Errorf("adds=%d saves=%d, want 1 and 1", e.cnt.adds, e.cnt.saves)
}
}
func TestStatusPageRevertsDrift(t *testing.T) {
e := newPageEnv(t,
statusPage("p", section("Core", public), section("Edge", map[string]string{"status": "edge"})),
listed("a", "x", "A", 7, public), listed("a", "y", "B", 8, public),
listed("a", "z", "C", 9, map[string]string{"status": "edge"}))
if _, err := e.reconcile(t, "p"); err != nil {
t.Fatal(err)
}
clean := *e.kuma.Page("renner")
groupIDs := []int64{clean.Groups[0].ID, clean.Groups[1].ID}
slices.Sort(groupIDs)
drifts := map[string]func(p *kumaclient.StatusPage){
"title": func(p *kumaclient.StatusPage) { p.Title = "UI" },
"extra domain": func(p *kumaclient.StatusPage) { p.DomainNames = append(p.DomainNames, "x.example.com") },
"monitor order": func(p *kumaclient.StatusPage) {
p.Groups[0].Monitors[0], p.Groups[0].Monitors[1] = p.Groups[0].Monitors[1], p.Groups[0].Monitors[0]
},
"group order": func(p *kumaclient.StatusPage) { p.Groups[0], p.Groups[1] = p.Groups[1], p.Groups[0] },
"extra group": func(p *kumaclient.StatusPage) { p.Groups = append(p.Groups, kumaclient.StatusGroup{Name: "UI"}) },
"extra monitor": func(p *kumaclient.StatusPage) {
p.Groups[1].Monitors = append(p.Groups[1].Monitors, kumaclient.PageMonitor{ID: 99})
},
"clickable link": func(p *kumaclient.StatusPage) { p.Groups[0].Monitors[0].ShowURL = true },
"theme": func(p *kumaclient.StatusPage) { p.Theme = "dark" },
"powered by": func(p *kumaclient.StatusPage) { p.ShowPoweredBy = false },
}
for name, drift := range drifts {
t.Run(name, func(t *testing.T) {
p := *e.kuma.Page("renner")
drift(&p)
e.kuma.PutPage(p)
before := e.cnt.saves
if _, err := e.reconcile(t, "p"); err != nil {
t.Fatal(err)
}
if e.cnt.saves != before+1 {
t.Fatalf("saves = %d, want %d", e.cnt.saves, before+1)
}
got := *e.kuma.Page("renner")
if !got.SameContent(clean) {
t.Errorf("not reverted: %+v", got)
}
// Group IDs are reused by position, so none is created or lost.
ids := []int64{got.Groups[0].ID, got.Groups[1].ID}
slices.Sort(ids)
if !equalIDs(ids, groupIDs) {
t.Errorf("group IDs %v, want %v", ids, groupIDs)
}
})
}
}
func TestStatusPageSectionOrderAndFirstMatch(t *testing.T) {
both := map[string]string{"status": "public", "tier": "core"}
e := newPageEnv(t,
statusPage("p",
section("Core", map[string]string{"tier": "core"}),
section("All", public),
section("Empty", map[string]string{"status": "none"})),
listed("b", "m", "Beta", 3, both),
listed("a", "m", "Alpha", 5, both),
listed("z", "m", "Same", 6, public),
listed("a", "n", "Same", 4, public),
listed("a", "o", "Same", 2, public),
listed("a", "other", "Other", 9, map[string]string{"status": "private"}),
)
if _, err := e.reconcile(t, "p"); err != nil {
t.Fatal(err)
}
got := e.kuma.Page("renner")
if len(got.Groups) != 3 {
t.Fatalf("groups = %+v", got.Groups)
}
wantIDs := [][]int64{{5, 3}, {4, 2, 6}, nil}
for i, g := range got.Groups {
if ids := monitorIDs(g); !equalIDs(ids, wantIDs[i]) {
t.Errorf("group %q monitors = %v, want %v", g.Name, ids, wantIDs[i])
}
}
if names := []string{got.Groups[0].Name, got.Groups[1].Name, got.Groups[2].Name}; names[0] != "Core" || names[1] != "All" || names[2] != "Empty" {
t.Errorf("group names = %v", names)
}
st := e.get(t, "p").Status
want := []v1.SectionStatus{{Name: "Core", Monitors: 2}, {Name: "All", Monitors: 3}, {Name: "Empty", Monitors: 0}}
if len(st.Sections) != 3 || st.Sections[0] != want[0] || st.Sections[1] != want[1] || st.Sections[2] != want[2] {
t.Errorf("sections = %+v", st.Sections)
}
}
func equalIDs(a, b []int64) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func TestStatusPageSkipsUnsyncedAndDeleting(t *testing.T) {
deleting := listed("a", "gone", "Gone", 11, public)
deleting.Finalizers = []string{Finalizer}
deleting.DeletionTimestamp = &created
e := newPageEnv(t, statusPage("p", section("Core", public)),
listed("a", "x", "X", 7, public), listed("a", "new", "New", 0, public), deleting)
if _, err := e.reconcile(t, "p"); err != nil {
t.Fatal(err)
}
if ids := monitorIDs(e.kuma.Page("renner").Groups[0]); !equalIDs(ids, []int64{7}) {
t.Errorf("monitors = %v, want [7]", ids)
}
st := e.get(t, "p").Status
if st.UnsyncedMonitors != 1 || st.Sections[0].Monitors != 1 {
t.Errorf("status = %+v", st)
}
}
func TestStatusPageDelete(t *testing.T) {
e := newPageEnv(t, statusPage("p", section("Core", public)))
if _, err := e.reconcile(t, "p"); err != nil {
t.Fatal(err)
}
if err := e.k8s.Delete(t.Context(), e.get(t, "p")); err != nil {
t.Fatal(err)
}
if _, err := e.reconcile(t, "p"); err != nil {
t.Fatal(err)
}
if e.kuma.Page("renner") != nil {
t.Error("page still in Kuma")
}
if err := e.k8s.Get(t.Context(), types.NamespacedName{Namespace: "default", Name: "p"}, &v1.KumaStatusPage{}); err == nil {
t.Error("resource still exists")
}
}
func TestStatusPageDeleteWhenGoneInKuma(t *testing.T) {
e := newPageEnv(t, statusPage("p", section("Core", public)))
if _, err := e.reconcile(t, "p"); err != nil {
t.Fatal(err)
}
if err := e.kuma.DeleteStatusPage(t.Context(), "renner"); err != nil {
t.Fatal(err)
}
if err := e.k8s.Delete(t.Context(), e.get(t, "p")); err != nil {
t.Fatal(err)
}
if _, err := e.reconcile(t, "p"); err != nil {
t.Fatalf("delete of a page already gone: %v", err)
}
}
func TestStatusPageSlugConflict(t *testing.T) {
older := statusPage("older", section("Core", public))
newer := statusPage("newer", section("Other", public))
newer.CreationTimestamp = metav1.NewTime(created.Add(time.Hour))
newer.Spec.Title = "Newer"
e := newPageEnv(t, older, newer)
for _, n := range []string{"newer", "older"} {
if _, err := e.reconcile(t, n); err != nil {
t.Fatal(err)
}
}
c := pageReady(t, e.get(t, "newer"))
if c.Status != metav1.ConditionFalse || c.Reason != ReasonConflict {
t.Errorf("newer Ready = %+v", c)
}
if c := pageReady(t, e.get(t, "older")); c.Status != metav1.ConditionTrue {
t.Errorf("older Ready = %+v", c)
}
if got := e.kuma.Page("renner"); got == nil || got.Title != "Renner" {
t.Fatalf("page = %+v", got)
}
// Deleting the loser leaves the page alone.
if err := e.k8s.Delete(t.Context(), e.get(t, "newer")); err != nil {
t.Fatal(err)
}
if _, err := e.reconcile(t, "newer"); err != nil {
t.Fatal(err)
}
if e.kuma.Page("renner") == nil || e.cnt.deletes != 0 {
t.Errorf("loser deletion touched Kuma: deletes=%d", e.cnt.deletes)
}
}
func TestStatusPageInvalidSelector(t *testing.T) {
bad := v1.StatusPageSection{Name: "Bad", Selector: metav1.LabelSelector{
MatchExpressions: []metav1.LabelSelectorRequirement{{Key: "a", Operator: "Near"}},
}}
e := newPageEnv(t, statusPage("p", bad))
res, err := e.reconcile(t, "p")
if err != nil {
t.Fatal(err)
}
if res.RequeueAfter != 5*time.Minute {
t.Errorf("RequeueAfter = %v", res.RequeueAfter)
}
if c := pageReady(t, e.get(t, "p")); c.Status != metav1.ConditionFalse || c.Reason != ReasonInvalidSelector {
t.Errorf("Ready = %+v", c)
}
if e.kuma.Page("renner") != nil {
t.Error("page created despite invalid selector")
}
}
func TestStatusPageKumaUnavailable(t *testing.T) {
e := newPageEnv(t, statusPage("p", section("Core", public)))
boom := errors.New("socket closed")
e.kuma.SetPageErr(boom)
if _, err := e.reconcile(t, "p"); !errors.Is(err, boom) {
t.Fatalf("err = %v, want %v", err, boom)
}
if c := pageReady(t, e.get(t, "p")); c.Status != metav1.ConditionFalse || c.Reason != ReasonKumaUnavailable {
t.Errorf("Ready = %+v", c)
}
}
func TestMonitorListingChanged(t *testing.T) {
base := listed("a", "x", "X", 7, public)
now := metav1.Now()
cases := map[string]struct {
mutate func(*v1.KumaMonitor)
want bool
}{
"labels": {func(m *v1.KumaMonitor) { m.Labels = map[string]string{"status": "edge"} }, true},
"displayName": {func(m *v1.KumaMonitor) { m.Spec.DisplayName = "Y" }, true},
"monitorID": {func(m *v1.KumaMonitor) { m.Status.MonitorID = 8 }, true},
"deleting": {func(m *v1.KumaMonitor) { m.DeletionTimestamp = &now }, true},
"conditions": {func(m *v1.KumaMonitor) {
m.Status.Conditions = []metav1.Condition{{Type: v1.ConditionReady, Status: metav1.ConditionFalse}}
}, false},
"interval": {func(m *v1.KumaMonitor) { m.Spec.Interval = 120 }, false},
}
for name, c := range cases {
n := base.DeepCopy()
c.mutate(n)
if got := monitorListingChanged(event.UpdateEvent{ObjectOld: base, ObjectNew: n}); got != c.want {
t.Errorf("%s: got %v, want %v", name, got, c.want)
}
}
}
@@ -0,0 +1,143 @@
//go:build integration
package controller_test
import (
"errors"
"fmt"
"testing"
"time"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/util/retry"
v1 "gitea.renner.uno/konairius/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/konairius/homelab/kuma-sync/internal/kumaclient"
)
func labeled(km *v1.KumaMonitor, labels map[string]string) *v1.KumaMonitor {
km.Labels = labels
return km
}
// pageMonitors returns the monitor IDs per group name, in order.
func pageMonitors(p kumaclient.StatusPage) map[string][]int64 {
out := map[string][]int64{}
for _, g := range p.Groups {
ids := []int64{}
for _, m := range g.Monitors {
ids = append(ids, m.ID)
}
out[g.Name] = ids
}
return out
}
func TestE2EStatusPage(t *testing.T) {
f, env := newE2E(t)
// The bootstrap created the page before kuma-sync owned it.
if err := f.raw.AddStatusPage(t.Context(), "Old", "renner"); err != nil {
t.Fatal(err)
}
pre, err := f.census.GetStatusPage(t.Context(), "renner")
if err != nil {
t.Fatal(err)
}
f.start(env)
core := map[string]string{"status": "core"}
f.apply(
labeled(group("zeta", ""), core),
labeled(group("alpha", ""), core),
labeled(dns("edge", ""), map[string]string{"status": "edge"}),
)
page := &v1.KumaStatusPage{
ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "public"},
Spec: v1.KumaStatusPageSpec{
Slug: "renner", Title: "Renner", DomainNames: []string{"uptime.example.com"},
Sections: []v1.StatusPageSection{
{Name: "Core", Selector: metav1.LabelSelector{MatchLabels: core}},
{Name: "Edge", Selector: metav1.LabelSelector{MatchLabels: map[string]string{"status": "edge"}}},
},
},
}
if err := f.k8s.Create(t.Context(), page); err != nil {
t.Fatal(err)
}
var ids map[string]int64
expect := func(what string, want func() map[string][]int64) {
t.Helper()
eventually(t, 60*time.Second, what, func() (bool, string) {
m := byKey(f.owned())
ids = map[string]int64{}
for _, k := range []string{"zeta", "alpha", "edge"} {
ids[k] = m["default/"+k].ID
}
p, err := f.census.GetStatusPage(t.Context(), "renner")
if err != nil {
return false, err.Error()
}
got, w := pageMonitors(p), want()
if p.Title != "Renner" || p.ID != pre.ID || fmt.Sprint(p.DomainNames) != "[uptime.example.com]" ||
len(p.Groups) != len(w) || fmt.Sprint(got) != fmt.Sprint(w) {
return false, fmt.Sprintf("page %+v, want groups %v", p, w)
}
return true, ""
})
}
expect("page adopted with sorted sections", func() map[string][]int64 {
return map[string][]int64{"Core": {ids["alpha"], ids["zeta"]}, "Edge": {ids["edge"]}}
})
cur := &v1.KumaStatusPage{}
if err := f.k8s.Get(t.Context(), types.NamespacedName{Namespace: "default", Name: "public"}, cur); err != nil {
t.Fatal(err)
}
if cur.Status.StatusPageID != pre.ID {
t.Errorf("statusPageID = %d, want %d", cur.Status.StatusPageID, pre.ID)
}
// Edit in the Kuma UI: reverted by a resync.
raw, err := f.raw.GetStatusPage(t.Context(), "renner")
if err != nil {
t.Fatal(err)
}
raw.Title = "UI"
raw.DomainNameList = []string{"other.example.com"}
raw.Icon = ""
raw.PublicGroupList = nil
if _, err := f.raw.SaveStatusPage(t.Context(), raw); err != nil {
t.Fatal(err)
}
expect("UI edit reverted", func() map[string][]int64 {
return map[string][]int64{"Core": {ids["alpha"], ids["zeta"]}, "Edge": {ids["edge"]}}
})
// Relabeling a monitor moves it.
if err := retry.RetryOnConflict(retry.DefaultRetry, func() error {
km := &v1.KumaMonitor{}
if err := f.k8s.Get(t.Context(), types.NamespacedName{Namespace: "default", Name: "zeta"}, km); err != nil {
return err
}
km.Labels = map[string]string{"status": "edge"}
return f.k8s.Update(t.Context(), km)
}); err != nil {
t.Fatal(err)
}
expect("relabeled monitor moved", func() map[string][]int64 {
return map[string][]int64{"Core": {ids["alpha"]}, "Edge": {ids["edge"], ids["zeta"]}}
})
if err := f.k8s.Delete(t.Context(), page); err != nil {
t.Fatal(err)
}
eventually(t, 30*time.Second, "page removed from Kuma", func() (bool, string) {
_, err := f.census.GetStatusPage(t.Context(), "renner")
if errors.Is(err, kumaclient.ErrStatusPageNotFound) {
return true, ""
}
return false, fmt.Sprintf("get: %v", err)
})
}
@@ -158,7 +158,7 @@ func (b *Breml) checkSession(ctx context.Context, err error) (rebuilt, dead bool
if ctx.Err() != nil { if ctx.Err() != nil {
return false, false return false, false
} }
for _, app := range []error{ErrNotFound, ErrNotOwned, ErrOwnerTagMissing, ErrOwnerConflict} { for _, app := range []error{ErrNotFound, ErrNotOwned, ErrOwnerTagMissing, ErrOwnerConflict, ErrStatusPageNotFound} {
if errors.Is(err, app) { if errors.Is(err, app) {
return false, false return false, false
} }
@@ -0,0 +1,206 @@
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
}
@@ -19,6 +19,8 @@ var (
ErrOwnerConflict = errors.New("monitor carries conflicting kuma-sync tag values") ErrOwnerConflict = errors.New("monitor carries conflicting kuma-sync tag values")
// ErrPossibleOrphan means Create may have left an untagged monitor in Kuma. // ErrPossibleOrphan means Create may have left an untagged monitor in Kuma.
ErrPossibleOrphan = errors.New("possible untagged orphan monitor left 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 // ConflictError reports a monitor carrying several different kuma-sync tag
@@ -197,4 +199,6 @@ type Client interface {
// DefaultNotifications returns the IDs of all notifications marked // DefaultNotifications returns the IDs of all notifications marked
// "default enabled" in Kuma, sorted. An empty result is valid. // "default enabled" in Kuma, sorted. An empty result is valid.
DefaultNotifications(ctx context.Context) ([]int64, error) DefaultNotifications(ctx context.Context) ([]int64, error)
StatusPages
} }
@@ -28,6 +28,13 @@ type Fake struct {
// ListPartial makes ListOwned return the stored monitors together with // ListPartial makes ListOwned return the stored monitors together with
// ListErr, as the real client does for owner conflicts. // ListErr, as the real client does for owner conflicts.
ListPartial bool 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. // SetListPartial makes ListOwned return the stored monitors and err.
@@ -40,7 +47,10 @@ func (f *Fake) SetListPartial(err error) {
var _ Client = (*Fake)(nil) var _ Client = (*Fake)(nil)
func NewFake() *Fake { func NewFake() *Fake {
return &Fake{monitors: map[int64]Monitor{}, nextID: 1} 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. // AddForeign adds an untagged monitor. A zero ID is assigned the next free one.
@@ -0,0 +1,127 @@
package kumaclient
import (
"context"
"fmt"
)
// SetPageErr makes every status page method return err.
func (f *Fake) SetPageErr(err error) {
f.mu.Lock()
defer f.mu.Unlock()
f.PageErr = err
}
// Page returns a copy of the stored page, or nil.
func (f *Fake) Page(slug string) *StatusPage {
f.mu.Lock()
defer f.mu.Unlock()
p, ok := f.pages[slug]
if !ok {
return nil
}
c := p.clone()
return &c
}
// PutPage stores p as if edited in the Kuma UI. Groups with ID 0 get new IDs.
func (f *Fake) PutPage(p StatusPage) {
f.mu.Lock()
defer f.mu.Unlock()
if cur, ok := f.pages[p.Slug]; ok {
p.ID = cur.ID
} else if p.ID == 0 {
p.ID = f.nextPageID
f.nextPageID++
}
f.storeGroups(&p)
f.pages[p.Slug] = p.Normalized()
}
// storeGroups assigns IDs to new groups and drops the page's other groups.
func (f *Fake) storeGroups(p *StatusPage) {
p.Groups = p.clone().Groups
keep := map[int64]bool{}
for i := range p.Groups {
if p.Groups[i].ID == 0 {
p.Groups[i].ID = f.nextGroupID
f.nextGroupID++
}
keep[p.Groups[i].ID] = true
f.pageGroups[p.Groups[i].ID] = p.Slug
}
for id, slug := range f.pageGroups {
if slug == p.Slug && !keep[id] {
delete(f.pageGroups, id)
}
}
}
func (f *Fake) GetStatusPage(_ context.Context, slug string) (StatusPage, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.PageErr != nil {
return StatusPage{}, f.PageErr
}
p, ok := f.pages[slug]
if !ok {
return StatusPage{}, fmt.Errorf("status page %q: %w", slug, ErrStatusPageNotFound)
}
return p.clone(), nil
}
// AddStatusPage stores the defaults Kuma's addStatusPage writes.
func (f *Fake) AddStatusPage(_ context.Context, slug, title string) (int64, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.PageErr != nil {
return 0, f.PageErr
}
if _, ok := f.pages[slug]; ok {
return 0, fmt.Errorf("add status page %q: slug exists", slug)
}
p := StatusPage{ID: f.nextPageID, Slug: slug, Title: title, Theme: "auto", ShowPoweredBy: true}
f.nextPageID++
f.pages[slug] = p
return p.ID, nil
}
// SaveStatusPage rejects a group ID of another page, as Kuma fails on it.
func (f *Fake) SaveStatusPage(_ context.Context, p StatusPage) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.PageErr != nil {
return f.PageErr
}
cur, ok := f.pages[p.Slug]
if !ok {
return fmt.Errorf("status page %q: %w", p.Slug, ErrStatusPageNotFound)
}
for _, g := range p.Groups {
if g.ID != 0 && f.pageGroups[g.ID] != p.Slug {
return fmt.Errorf("save status page %q: group %d not on this page", p.Slug, g.ID)
}
}
p.ID = cur.ID
f.storeGroups(&p)
f.pages[p.Slug] = p.Normalized()
return nil
}
func (f *Fake) DeleteStatusPage(_ context.Context, slug string) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.PageErr != nil {
return f.PageErr
}
if _, ok := f.pages[slug]; !ok {
return fmt.Errorf("status page %q: %w", slug, ErrStatusPageNotFound)
}
delete(f.pages, slug)
for id, s := range f.pageGroups {
if s == slug {
delete(f.pageGroups, id)
}
}
return nil
}
@@ -0,0 +1,109 @@
package kumaclient
import (
"context"
"slices"
"strings"
)
// StatusPage is the domain view of a Kuma status page. Fields kuma-sync does
// not manage (logo, custom CSS, analytics, RSS title, auto refresh interval)
// are kept as they are in Kuma.
type StatusPage struct {
// ID is read only.
ID int64
Slug string
Title string
Description string
FooterText string
Theme string
// DomainNames is a set; Kuma returns it in no defined order.
DomainNames []string
ShowTags bool
ShowPoweredBy bool
ShowCertificateExpiry bool
// Groups are in display order.
Groups []StatusGroup
}
// StatusGroup is one section of a status page.
type StatusGroup struct {
// ID is Kuma's group ID; 0 creates a new group on save.
ID int64
Name string
Monitors []PageMonitor
}
// PageMonitor is one monitor of a group, in display order.
type PageMonitor struct {
ID int64
// ShowURL is Kuma's per-page "show clickable link" setting.
ShowURL bool
}
// Normalized returns p with DomainNames trimmed, sorted and deduplicated,
// blank names dropped, and empty slices nil. It is idempotent.
func (p StatusPage) Normalized() StatusPage {
var domains []string
for _, d := range p.DomainNames {
if d = strings.TrimSpace(d); d != "" {
domains = append(domains, d)
}
}
slices.Sort(domains)
p.DomainNames = slices.Compact(domains)
var groups []StatusGroup
for _, g := range p.Groups {
g.Monitors = slices.Clone(g.Monitors)
if len(g.Monitors) == 0 {
g.Monitors = nil
}
groups = append(groups, g)
}
p.Groups = groups
return p
}
// SameContent reports whether p and o hold the same managed content. IDs are
// ignored.
func (p StatusPage) SameContent(o StatusPage) bool {
a, b := p.Normalized(), o.Normalized()
if a.Slug != b.Slug || a.Title != b.Title || a.Description != b.Description ||
a.FooterText != b.FooterText || a.Theme != b.Theme ||
a.ShowTags != b.ShowTags || a.ShowPoweredBy != b.ShowPoweredBy ||
a.ShowCertificateExpiry != b.ShowCertificateExpiry ||
!slices.Equal(a.DomainNames, b.DomainNames) || len(a.Groups) != len(b.Groups) {
return false
}
for i := range a.Groups {
if a.Groups[i].Name != b.Groups[i].Name || !slices.Equal(a.Groups[i].Monitors, b.Groups[i].Monitors) {
return false
}
}
return true
}
func (p StatusPage) clone() StatusPage {
p.DomainNames = slices.Clone(p.DomainNames)
p.Groups = slices.Clone(p.Groups)
for i := range p.Groups {
p.Groups[i].Monitors = slices.Clone(p.Groups[i].Monitors)
}
return p
}
// StatusPages manages Kuma status pages by slug.
type StatusPages interface {
// GetStatusPage returns the page with its groups. It wraps
// ErrStatusPageNotFound when no page has this slug.
GetStatusPage(ctx context.Context, slug string) (StatusPage, error)
// AddStatusPage creates an empty page and returns its ID.
AddStatusPage(ctx context.Context, slug, title string) (int64, error)
// SaveStatusPage writes the managed fields and replaces the group list.
// Every non-zero group ID must belong to the page; groups not listed are
// deleted. It wraps ErrStatusPageNotFound when no page has this slug.
SaveStatusPage(ctx context.Context, p StatusPage) error
// DeleteStatusPage removes the page with its groups and incidents. It
// wraps ErrStatusPageNotFound when no page has this slug.
DeleteStatusPage(ctx context.Context, slug string) error
}
@@ -0,0 +1,152 @@
//go:build integration
package kumaclient
import (
"context"
"errors"
"testing"
"github.com/breml/go-uptime-kuma-client/statuspage"
)
func TestStatusPageRoundTrip(t *testing.T) {
ctx := context.Background()
url := startKuma(t)
b := setupClient(t, url)
var ids []int64
for _, name := range []string{"m1", "m2", "m3"} {
id, err := b.Create(ctx, Monitor{Type: "group", Name: name, Active: true, Interval: 60, RetryInterval: 60, OwnerKey: "a/" + name})
if err != nil {
t.Fatal(err)
}
ids = append(ids, id)
}
pageID, err := b.AddStatusPage(ctx, "renner", "Renner")
if err != nil {
t.Fatal(err)
}
fresh, err := b.GetStatusPage(ctx, "renner")
if err != nil {
t.Fatal(err)
}
if fresh.ID != pageID || fresh.Title != "Renner" || fresh.Theme != "auto" || len(fresh.Groups) != 0 {
t.Fatalf("new page = %+v", fresh)
}
// An unmanaged field set in the UI.
raw, err := b.c.GetStatusPage(ctx, "renner")
if err != nil {
t.Fatal(err)
}
raw.CustomCSS = "body{}"
raw.Icon = ""
if _, err := b.c.SaveStatusPage(ctx, raw); err != nil {
t.Fatal(err)
}
want := StatusPage{
Slug: "renner", Title: "Status", Description: "desc", FooterText: "foot", Theme: "dark",
DomainNames: []string{"uptime.example.com", "status.example.com"},
ShowTags: true, ShowPoweredBy: false, ShowCertificateExpiry: true,
Groups: []StatusGroup{
{Name: "Core", Monitors: []PageMonitor{{ID: ids[2]}, {ID: ids[0]}}},
{Name: "Empty"},
{Name: "Edge", Monitors: []PageMonitor{{ID: ids[1]}}},
},
}
if err := b.SaveStatusPage(ctx, want); err != nil {
t.Fatal(err)
}
got, err := freshClient(t, url).GetStatusPage(ctx, "renner")
if err != nil {
t.Fatal(err)
}
if !got.SameContent(want) || got.ID != pageID {
t.Fatalf("read back:\n got %+v\n want %+v", got, want)
}
for _, g := range got.Groups {
if g.ID == 0 {
t.Fatalf("group without ID: %+v", got.Groups)
}
}
// Saving with the read group IDs keeps them.
again := want
again.Groups = got.Groups
if err := b.SaveStatusPage(ctx, again); err != nil {
t.Fatal(err)
}
after, err := b.GetStatusPage(ctx, "renner")
if err != nil {
t.Fatal(err)
}
for i := range after.Groups {
if after.Groups[i].ID != got.Groups[i].ID {
t.Errorf("group %d ID %d -> %d", i, got.Groups[i].ID, after.Groups[i].ID)
}
}
raw, err = b.c.GetStatusPage(ctx, "renner")
if err != nil {
t.Fatal(err)
}
if raw.CustomCSS != "body{}" || raw.Icon != "/icon.svg" {
t.Errorf("unmanaged fields changed: customCSS=%q icon=%q", raw.CustomCSS, raw.Icon)
}
}
func TestStatusPageShowURLReadBack(t *testing.T) {
ctx := context.Background()
b := setupClient(t, startKuma(t))
id, err := b.Create(ctx, Monitor{
Type: "http", Name: "h", Active: false, Interval: 60, RetryInterval: 60,
URL: "https://example.com", OwnerKey: "a/h",
})
if err != nil {
t.Fatal(err)
}
if _, err := b.AddStatusPage(ctx, "renner", "Renner"); err != nil {
t.Fatal(err)
}
raw, err := b.c.GetStatusPage(ctx, "renner")
if err != nil {
t.Fatal(err)
}
show := true
raw.PublicGroupList = []statuspage.PublicGroup{{Name: "G", MonitorList: []statuspage.PublicMonitor{{ID: id, SendURL: &show}}}}
if _, err := b.c.SaveStatusPage(ctx, raw); err != nil {
t.Fatal(err)
}
got, err := b.GetStatusPage(ctx, "renner")
if err != nil {
t.Fatal(err)
}
if len(got.Groups) != 1 || len(got.Groups[0].Monitors) != 1 || !got.Groups[0].Monitors[0].ShowURL {
t.Errorf("groups = %+v", got.Groups)
}
}
func TestStatusPageNotFound(t *testing.T) {
ctx := context.Background()
b := setupClient(t, startKuma(t))
if _, err := b.GetStatusPage(ctx, "missing"); !errors.Is(err, ErrStatusPageNotFound) {
t.Errorf("get: %v", err)
}
if err := b.SaveStatusPage(ctx, StatusPage{Slug: "missing", Title: "x", Theme: "auto"}); !errors.Is(err, ErrStatusPageNotFound) {
t.Errorf("save: %v", err)
}
if err := b.DeleteStatusPage(ctx, "missing"); !errors.Is(err, ErrStatusPageNotFound) {
t.Errorf("delete: %v", err)
}
if _, err := b.AddStatusPage(ctx, "here", "Here"); err != nil {
t.Fatal(err)
}
if err := b.DeleteStatusPage(ctx, "here"); err != nil {
t.Fatal(err)
}
if _, err := b.GetStatusPage(ctx, "here"); !errors.Is(err, ErrStatusPageNotFound) {
t.Errorf("get after delete: %v", err)
}
}
@@ -0,0 +1,67 @@
package kumaclient
import (
"errors"
"testing"
)
func TestStatusPageSameContent(t *testing.T) {
base := StatusPage{
ID: 1, Slug: "s", Title: "T", Theme: "auto", DomainNames: []string{"b.example", "a.example"},
Groups: []StatusGroup{{ID: 3, Name: "G", Monitors: []PageMonitor{{ID: 1}, {ID: 2}}}},
}
same := StatusPage{
ID: 9, Slug: "s", Title: "T", Theme: "auto", DomainNames: []string{"a.example", " b.example", "", "a.example"},
Groups: []StatusGroup{{ID: 0, Name: "G", Monitors: []PageMonitor{{ID: 1}, {ID: 2}}}},
}
if !base.SameContent(same) {
t.Error("IDs, domain order, blanks and duplicates must not count")
}
emptyGroup := StatusPage{Groups: []StatusGroup{{Name: "G", Monitors: []PageMonitor{}}}}
if !emptyGroup.SameContent(StatusPage{Groups: []StatusGroup{{Name: "G"}}}) {
t.Error("empty and nil monitor lists must be equal")
}
diffs := map[string]func(p *StatusPage){
"monitor order": func(p *StatusPage) { p.Groups[0].Monitors = []PageMonitor{{ID: 2}, {ID: 1}} },
"show url": func(p *StatusPage) { p.Groups[0].Monitors[0].ShowURL = true },
"group name": func(p *StatusPage) { p.Groups[0].Name = "H" },
"extra group": func(p *StatusPage) { p.Groups = append(p.Groups, StatusGroup{Name: "H"}) },
"domain": func(p *StatusPage) { p.DomainNames = []string{"a.example"} },
"footer": func(p *StatusPage) { p.FooterText = "f" },
"cert expiry": func(p *StatusPage) { p.ShowCertificateExpiry = true },
}
for name, mutate := range diffs {
p := base.clone()
mutate(&p)
if base.SameContent(p) {
t.Errorf("%s: change not detected", name)
}
}
}
func TestTruthy(t *testing.T) {
for _, c := range []struct {
v any
want bool
}{{nil, false}, {false, false}, {true, true}, {float64(0), false}, {float64(1), true}, {"", false}} {
if got := truthy(c.v); got != c.want {
t.Errorf("truthy(%#v) = %v", c.v, got)
}
}
}
func TestPageNotFound(t *testing.T) {
for _, msg := range []string{
"get status page s: getStatusPage: No slug?",
"save status page: saveStatusPage: No slug?",
"delete status page: deleteStatusPage: Status Page is not found",
} {
if err := pageNotFound("s", errors.New(msg)); !errors.Is(err, ErrStatusPageNotFound) {
t.Errorf("%q not mapped", msg)
}
}
other := errors.New("save status page: saveStatusPage: Invalid Slug")
if err := pageNotFound("s", other); errors.Is(err, ErrStatusPageNotFound) {
t.Error("other error mapped to not found")
}
}