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>
281 lines
7.8 KiB
Go
281 lines
7.8 KiB
Go
//go:build envtest
|
|
|
|
package v1alpha1
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"k8s.io/apimachinery/pkg/api/errors"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/runtime"
|
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
|
"sigs.k8s.io/controller-runtime/pkg/envtest"
|
|
)
|
|
|
|
var k8s client.Client
|
|
|
|
func TestMain(m *testing.M) {
|
|
env := &envtest.Environment{
|
|
CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd")},
|
|
ErrorIfCRDPathMissing: true,
|
|
}
|
|
cfg, err := env.Start()
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
scheme := runtime.NewScheme()
|
|
if err := AddToScheme(scheme); err != nil {
|
|
panic(err)
|
|
}
|
|
k8s, err = client.New(cfg, client.Options{Scheme: scheme})
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
code := m.Run()
|
|
if err := env.Stop(); err != nil {
|
|
panic(err)
|
|
}
|
|
if code != 0 {
|
|
panic("tests failed")
|
|
}
|
|
}
|
|
|
|
func monitor(name string, spec KumaMonitorSpec) *KumaMonitor {
|
|
return &KumaMonitor{
|
|
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default"},
|
|
Spec: spec,
|
|
}
|
|
}
|
|
|
|
func TestValidKeyword(t *testing.T) {
|
|
m := monitor("valid-keyword", KumaMonitorSpec{
|
|
Type: "keyword",
|
|
DisplayName: "kw",
|
|
HTTP: &HTTPSpec{URL: "https://example.com"},
|
|
Keyword: &KeywordSpec{Keyword: "ok"},
|
|
})
|
|
if err := k8s.Create(context.Background(), m); err != nil {
|
|
t.Fatalf("expected accept, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestKeywordWithoutBlock(t *testing.T) {
|
|
m := monitor("kw-no-block", KumaMonitorSpec{
|
|
Type: "keyword",
|
|
DisplayName: "kw",
|
|
HTTP: &HTTPSpec{URL: "https://example.com"},
|
|
})
|
|
expectInvalid(t, m, "keyword is required for type keyword")
|
|
}
|
|
|
|
func expectInvalid(t *testing.T, m *KumaMonitor, wants ...string) {
|
|
t.Helper()
|
|
err := k8s.Create(context.Background(), m)
|
|
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 TestGroupWithHTTP(t *testing.T) {
|
|
m := monitor("group-http", KumaMonitorSpec{
|
|
Type: "group",
|
|
DisplayName: "g",
|
|
HTTP: &HTTPSpec{URL: "https://example.com"},
|
|
})
|
|
expectInvalid(t, m, "type group takes no type blocks")
|
|
}
|
|
|
|
func TestDNSWithHTTP(t *testing.T) {
|
|
m := monitor("dns-http", KumaMonitorSpec{
|
|
Type: "dns",
|
|
DisplayName: "d",
|
|
HTTP: &HTTPSpec{URL: "https://example.com"},
|
|
DNS: &DNSSpec{Hostname: "example.com", ResolveType: "A"},
|
|
})
|
|
expectInvalid(t, m, "http is required for types http, keyword and json-query")
|
|
}
|
|
|
|
func TestPushTypeRejected(t *testing.T) {
|
|
m := monitor("push", KumaMonitorSpec{Type: "push", DisplayName: "p"})
|
|
expectInvalid(t, m, "spec.type", "Unsupported value")
|
|
}
|
|
|
|
func TestEmptyURLRejected(t *testing.T) {
|
|
m := monitor("empty-url", KumaMonitorSpec{
|
|
Type: "http",
|
|
DisplayName: "h",
|
|
HTTP: &HTTPSpec{URL: ""},
|
|
})
|
|
expectInvalid(t, m, "spec.http.url")
|
|
}
|
|
|
|
func TestNegativeMaxRetriesRejected(t *testing.T) {
|
|
m := monitor("neg-retries", KumaMonitorSpec{
|
|
Type: "http",
|
|
DisplayName: "h",
|
|
MaxRetries: -1,
|
|
HTTP: &HTTPSpec{URL: "https://example.com"},
|
|
})
|
|
expectInvalid(t, m, "spec.maxRetries")
|
|
}
|
|
|
|
func TestJSONQueryBadOperator(t *testing.T) {
|
|
m := monitor("jq-bad-op", KumaMonitorSpec{
|
|
Type: "json-query",
|
|
DisplayName: "jq",
|
|
HTTP: &HTTPSpec{URL: "https://example.com"},
|
|
JSONQuery: &JSONQuerySpec{Expression: "a", Operator: "=~", Expected: "b"},
|
|
})
|
|
expectInvalid(t, m, "spec.jsonQuery.operator", "Unsupported value")
|
|
}
|
|
|
|
func TestIntervalBelowMin(t *testing.T) {
|
|
m := monitor("interval-low", KumaMonitorSpec{
|
|
Type: "http",
|
|
DisplayName: "h",
|
|
Interval: 5,
|
|
HTTP: &HTTPSpec{URL: "https://example.com"},
|
|
})
|
|
expectInvalid(t, m, "spec.interval")
|
|
}
|
|
|
|
func TestDefaults(t *testing.T) {
|
|
ctx := context.Background()
|
|
m := monitor("defaults", KumaMonitorSpec{
|
|
Type: "http",
|
|
DisplayName: "h",
|
|
HTTP: &HTTPSpec{URL: "https://example.com"},
|
|
})
|
|
if err := k8s.Create(ctx, m); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := &KumaMonitor{}
|
|
if err := k8s.Get(ctx, client.ObjectKeyFromObject(m), got); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.Spec.Interval != 60 {
|
|
t.Fatalf("interval = %v, want 60", got.Spec.Interval)
|
|
}
|
|
codes := got.Spec.HTTP.AcceptedStatusCodes
|
|
if len(codes) != 1 || codes[0] != "200-299" {
|
|
t.Fatalf("acceptedStatusCodes = %v", codes)
|
|
}
|
|
}
|
|
|
|
// The controller ignores updates that leave metadata.generation unchanged, so
|
|
// deleting a resource that has a finalizer must bump it.
|
|
func TestDeletionBumpsGeneration(t *testing.T) {
|
|
ctx := context.Background()
|
|
m := monitor("deletion-gen", KumaMonitorSpec{
|
|
Type: "http", DisplayName: "h", HTTP: &HTTPSpec{URL: "https://example.com"},
|
|
})
|
|
m.Finalizers = []string{"test/finalizer"}
|
|
if err := k8s.Create(ctx, m); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
before := m.Generation
|
|
if err := k8s.Delete(ctx, m); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := &KumaMonitor{}
|
|
if err := k8s.Get(ctx, client.ObjectKeyFromObject(m), got); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.DeletionTimestamp.IsZero() {
|
|
t.Fatal("expected deletionTimestamp to be set")
|
|
}
|
|
if 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)
|
|
})
|
|
}
|
|
}
|