Compare commits

..
Author SHA1 Message Date
claude-botandClaude Opus 5.5 790e258894 feat(kuma-sync): deploy controller
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: comment
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Namespace, CRD (Flux prune disabled so a prune never cascades into
deleting every Kuma monitor via finalizers), cluster RBAC matching the
controller-gen role, Recreate Deployment of the first master build
(2026-10-19-0545) under image automation, and a default-deny
CiliumNetworkPolicy allowing only DNS, the kube-apiserver and
kuma.renner.uno:443 out, kubelet probes in.

The Kuma login comes from its own OpenBao path infrastructure/kuma-sync
(a copy of the single Kuma admin account) rather than reading
infrastructure/uptime-kuma.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 07:51:41 +02:00
k.renner 5e99ca4370 Merge pull request 'feat(kuma-sync): in-house Uptime Kuma controller (Tasks 1-6)' (#1342) from feat/kuma-sync into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: k.renner/homelab#1342
2026-10-19 05:09:52 +00:00
claude-botandClaude Opus 5.5 e1c0e73f61 fix(kuma-sync): orphan grace, refuse create beside an untagged previous monitor, drop push
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: comment
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 00:47:16 +02:00
claude-botandClaude Opus 5.5 59ccd74a89 test(kuma-sync): race-free drift test, restart proves adoption
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 00:30:13 +02:00
claude-botandClaude Opus 5.5 15a4e2a0dc test(kuma-sync): end-to-end integration suite
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 00:23:14 +02:00
claude-botandClaude Opus 5.5 2af1d880e4 fix(kuma-sync): confirm orphan deletes against the API server
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 00:16:21 +02:00
claude-botandClaude Opus 5.5 18ef262d93 feat(kuma-sync): orphan GC, manager and image build
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 00:12:24 +02:00
claude-botandClaude Opus 5.5 185738bd2c fix(kuma-sync): never create on an incomplete list; typed owner conflicts; duplicate owners
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 00:02:04 +02:00
claude-botandClaude Opus 5.5 0c487656e8 feat(kuma-sync): KumaMonitor reconciler
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:54:48 +02:00
claude-botandClaude Opus 5.5 b3f68753d1 fix(kuma-sync): flag unknown-outcome creates as possible orphans
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:50:53 +02:00
claude-botandClaude Opus 5.5 1968e91516 fix(kuma-sync): never create after a failed owner lookup
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:47:54 +02:00
claude-botandClaude Opus 5.5 4f0c7c414e fix(kuma-sync): reconnect, no orphan on create timeout, safe timeout, owner-tag integrity
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:41:20 +02:00
claude-botandClaude Opus 5.5 734f546d8b feat(kuma-sync): Kuma client on go-uptime-kuma-client
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:29:36 +02:00
claude-botandClaude Opus 5.5 90534f8ce7 fix(kuma-sync): deep-copy fake state, sentinel not-found/not-owned errors
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:17:34 +02:00
claude-botandClaude Opus 5.5 3a684d5452 feat(kuma-sync): Kuma client interface and fake
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:15:34 +02:00
claude-botandClaude Opus 5.5 ee0d416b80 fix(kuma-sync): precise validation tests and tighter CRD bounds
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:13:54 +02:00
claude-botandClaude Opus 5.5 fdb381305e feat(kuma-sync): KumaMonitor API types and CRD
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:10:50 +02:00
k.renner 5ebf7a04c4 Merge pull request 'docs(plans): kuma-sync controller design and plan' (#1341) from docs/uptime-kuma-plan into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
ci/woodpecker/cron/update-audit Pipeline was successful
ci/woodpecker/cron/e2e Pipeline failed
Reviewed-on: k.renner/homelab#1341
2026-10-18 21:05:14 +00:00
claude-botandClaude Opus 5.5 7f4c9c60c3 docs(plans): kuma-sync controller design and plan
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot skipped: closed during the review
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:03:26 +02:00
k.renner fde532b6a4 Merge pull request 'chore(autokuma): remove AutoKuma after failed pilot' (#1340) from chore/remove-autokuma into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#1340
2026-10-18 20:53:19 +00:00
claude-botandClaude Opus 5.5 333f331aed chore(autokuma): remove AutoKuma after failed pilot
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: comment
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
AutoKuma 2.0.0 never applied its ownership tag against Uptime Kuma 2.5.6, so a pod restart with empty /data duplicated every managed monitor (pilot step 5). Replaced by an in-house controller under a separate plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:48:31 +02:00
k.renner 1c4f4583fa Merge pull request 'fix(uptime-kuma): dual-stack egress network for Kuma checks' (#1339) from fix/uptime-kuma-ipv6 into master
ci/woodpecker/push/deploy-uptime-kuma Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#1339
2026-10-18 20:43:42 +00:00
k.renner b3a32435a8 Merge pull request 'test(autokuma): pilot step 3 — update interval and expiry_notification' (#1338) from feat/autokuma-pilot-update into master
Reviewed-on: k.renner/homelab#1338
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-10-18 20:43:27 +00:00
k.renner b31ee3189a Merge pull request 'fix(flux-notifications): stop alerting on successful image-automation commits' (#1337) from fix/drop-flux-image-automation-alert into master
Reviewed-on: k.renner/homelab#1337
2026-10-18 20:42:53 +00:00
claude-botandClaude Opus 5.5 64a5281bd0 fix(uptime-kuma): dual-stack egress network for Kuma checks
review-bot reviewed: comment
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:40:57 +02:00
claude-botandClaude Opus 5.5 f7a0719524 test(autokuma): pilot step 3 — update interval and expiry_notification
review-bot reviewed: approve
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:40:51 +02:00
claude-botandClaude Opus 5.5 4a302c2cbe fix(flux-notifications): stop alerting on successful image-automation commits
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: comment
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
The flux-image-automation Alert forwarded every info event from
ImageUpdateAutomation (i.e. each successful bump commit) to Alertmanager,
which emailed it as FluxImageUpdateAutomationSucceeded. A success is not
a problem and must not be an alert; the commits are in git history.

Image-automation failures still alert: flux-failures covers
ImageUpdateAutomation at error severity. The Alertmanager source="flux"
route stays for those; its comment now says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:38:25 +02:00
security-bot 7617a6725b chore(image-automation): bump docker.gitea.com/gitea-mcp-server:1.10.0
ci/woodpecker/cron/issue-fix Pipeline was successful
Updates managed by image-automation-controller.
2026-10-18 20:33:45 +00:00
k.renner c863b9845c Merge pull request 'feat(autokuma): pilot KumaEntities (Task 3)' (#1336) from feat/autokuma-pilot into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#1336
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-10-18 20:25:39 +00:00
53 changed files with 6534 additions and 272 deletions
+26
View File
@@ -428,6 +428,32 @@ steps:
--destination gitea.renner.uno/gitea_admin/authentik-gate-sync:$TAG \
--destination gitea.renner.uno/gitea_admin/authentik-gate-sync:latest
# kuma-sync: reconciles KumaMonitor resources into Uptime Kuma. Its unit
# tests run as a stage of the image build.
- name: build-kuma-sync
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: ci/images/kuma-sync
when:
- event: deployment
- event: push
path: ["ci/images/kuma-sync/**", ".woodpecker/build-ci-images.yml"]
environment:
GITEA_USER: gitea_admin
GITEA_TOKEN:
from_secret: ci_images_token
commands:
- mkdir -p /kaniko/.docker
- AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n')
- printf '{"auths":{"gitea.renner.uno":{"auth":"%s"}}}' "$AUTH" > /kaniko/.docker/config.json
# YYYY-MM-DD-HHMM: the image-automation ImagePolicy picks the newest.
- TAG=$(date +%Y-%m-%d-%H%M)
- |
/kaniko/executor \
--context . \
--dockerfile Containerfile \
--destination gitea.renner.uno/gitea_admin/kuma-sync:$TAG \
--destination gitea.renner.uno/gitea_admin/kuma-sync:latest
# bird: BGP daemon for the pod-network mesh over pod0 (#576). Runtime
# artifact like heating-can; same registry so the audit tracks it.
- name: build-bird
@@ -18,8 +18,12 @@ services:
- "traefik.http.routers.uptime-kuma.tls.certresolver=letsencrypt"
- "traefik.http.routers.uptime-kuma.service=uptime-kuma"
- "traefik.http.services.uptime-kuma.loadbalancer.server.port=3001"
- "traefik.docker.network={{ uptime_kuma_traefik_network }}"
networks:
- traefik
kuma:
gw_priority: 100
traefik:
gw_priority: 0
logging:
driver: "json-file"
options:
@@ -27,6 +31,8 @@ services:
max-file: "2"
networks:
kuma:
enable_ipv6: true
traefik:
external: true
name: {{ uptime_kuma_traefik_network }}
+1
View File
@@ -0,0 +1 @@
bin/
+1
View File
@@ -0,0 +1 @@
/bin/
+15
View File
@@ -0,0 +1,15 @@
# kuma-sync: reconciles KumaMonitor resources into Uptime Kuma. The unit tests
# (no tags, so no Docker and no envtest) gate the image: the final stage copies
# the binary out of this stage, so a failing test fails the build.
FROM golang:1.27@sha256:820153096fda4de6ac1b7194d12e416b7dd8f4e9a33d17e0345b111b945162d8 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go test ./...
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/kuma-sync ./cmd/kuma-sync
FROM gcr.io/distroless/static:nonroot@sha256:e2e927ec666bae08560abb3c55d0659eceabb657f56b6782ab500a9fc7f555e3
COPY --from=build /out/kuma-sync /kuma-sync
USER 65532
ENTRYPOINT ["/kuma-sync"]
+29
View File
@@ -0,0 +1,29 @@
CONTROLLER_GEN_VERSION := v0.23.0
ENVTEST_VERSION := v0.25.2
ENVTEST_K8S_VERSION := 1.37.0
BIN := $(CURDIR)/bin
CONTROLLER_GEN := $(BIN)/controller-gen
SETUP_ENVTEST := $(BIN)/setup-envtest
.PHONY: generate test test-envtest test-integration
$(CONTROLLER_GEN):
GOBIN=$(BIN) go install sigs.k8s.io/controller-tools/cmd/controller-gen@$(CONTROLLER_GEN_VERSION)
$(SETUP_ENVTEST):
GOBIN=$(BIN) go install sigs.k8s.io/controller-runtime/tools/setup-envtest@$(ENVTEST_VERSION)
generate: $(CONTROLLER_GEN)
$(CONTROLLER_GEN) object paths=./api/...
$(CONTROLLER_GEN) crd paths=./api/... output:crd:artifacts:config=config/crd
$(CONTROLLER_GEN) rbac:roleName=kuma-sync paths=./internal/... output:rbac:artifacts:config=config/rbac
test:
go test ./...
test-integration: $(SETUP_ENVTEST)
KUBEBUILDER_ASSETS="$$($(SETUP_ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(BIN)/k8s -p path)" go test -tags integration ./...
test-envtest:$(SETUP_ENVTEST)
KUBEBUILDER_ASSETS="$$($(SETUP_ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(BIN)/k8s -p path)" go test -tags envtest ./...
@@ -0,0 +1,15 @@
// +kubebuilder:object:generate=true
// +groupName=uptime.renner.uno
package v1alpha1
import (
"k8s.io/apimachinery/pkg/runtime/schema"
"sigs.k8s.io/controller-runtime/pkg/scheme"
)
var (
GroupVersion = schema.GroupVersion{Group: "uptime.renner.uno", Version: "v1alpha1"}
SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion}
AddToScheme = SchemeBuilder.AddToScheme
)
@@ -0,0 +1,132 @@
package v1alpha1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
const ConditionReady = "Ready"
// LocalRef references a KumaMonitor in the same namespace.
type LocalRef struct {
Name string `json:"name"`
}
// HTTPSpec configures http, keyword and json-query monitors.
type HTTPSpec struct {
// +kubebuilder:validation:MinLength=1
URL string `json:"url"`
// +kubebuilder:default=GET
Method string `json:"method,omitempty"`
// +kubebuilder:default={"200-299"}
AcceptedStatusCodes []string `json:"acceptedStatusCodes,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
Body string `json:"body,omitempty"`
MaxRedirects *int32 `json:"maxRedirects,omitempty"`
IgnoreTLS bool `json:"ignoreTLS,omitempty"`
ExpiryNotification bool `json:"expiryNotification,omitempty"`
}
type KeywordSpec struct {
// +kubebuilder:validation:MinLength=1
Keyword string `json:"keyword"`
Invert bool `json:"invert,omitempty"`
}
type JSONQuerySpec struct {
// JSONata expression.
// +kubebuilder:validation:MinLength=1
Expression string `json:"expression"`
// +kubebuilder:validation:Enum="==";"!=";"<";"<=";">";">=";contains
Operator string `json:"operator"`
Expected string `json:"expected"`
}
type DNSSpec struct {
// +kubebuilder:validation:MinLength=1
Hostname string `json:"hostname"`
// +kubebuilder:validation:Enum=A;AAAA;CNAME;MX;NS;PTR;SOA;SRV;TXT;CAA
ResolveType string `json:"resolveType"`
// +kubebuilder:default="1.1.1.1"
ResolveServer string `json:"resolveServer,omitempty"`
// +kubebuilder:validation:Minimum=1
// +kubebuilder:validation:Maximum=65535
// +kubebuilder:default=53
Port int32 `json:"port,omitempty"`
}
type PortSpec struct {
// +kubebuilder:validation:MinLength=1
Hostname string `json:"hostname"`
// +kubebuilder:validation:Minimum=1
// +kubebuilder:validation:Maximum=65535
Port int32 `json:"port"`
}
// +kubebuilder:validation:XValidation:rule="(self.type in ['http','keyword','json-query']) == has(self.http)",message="http is required for types http, keyword and json-query, and forbidden otherwise"
// +kubebuilder:validation:XValidation:rule="(self.type == 'keyword') == has(self.keyword)",message="keyword is required for type keyword and forbidden otherwise"
// +kubebuilder:validation:XValidation:rule="(self.type == 'json-query') == has(self.jsonQuery)",message="jsonQuery is required for type json-query and forbidden otherwise"
// +kubebuilder:validation:XValidation:rule="(self.type == 'dns') == has(self.dns)",message="dns is required for type dns and forbidden otherwise"
// +kubebuilder:validation:XValidation:rule="(self.type == 'port') == has(self.port)",message="port is required for type port and forbidden otherwise"
// +kubebuilder:validation:XValidation:rule="self.type != 'group' || !(has(self.http) || has(self.keyword) || has(self.jsonQuery) || has(self.dns) || has(self.port))",message="type group takes no type blocks"
type KumaMonitorSpec struct {
// +kubebuilder:validation:Enum=group;http;keyword;json-query;dns;port
Type string `json:"type"`
// +kubebuilder:validation:MinLength=1
DisplayName string `json:"displayName"`
ParentRef *LocalRef `json:"parentRef,omitempty"`
// Seconds.
// +kubebuilder:validation:Minimum=20
// +kubebuilder:default=60
Interval int32 `json:"interval,omitempty"`
// Seconds.
// +kubebuilder:validation:Minimum=20
// +kubebuilder:default=60
RetryInterval int32 `json:"retryInterval,omitempty"`
// +kubebuilder:validation:Minimum=0
// +kubebuilder:default=0
MaxRetries int32 `json:"maxRetries,omitempty"`
// Seconds.
// +kubebuilder:validation:Minimum=1
Timeout *int32 `json:"timeout,omitempty"`
Paused bool `json:"paused,omitempty"`
Description string `json:"description,omitempty"`
HTTP *HTTPSpec `json:"http,omitempty"`
Keyword *KeywordSpec `json:"keyword,omitempty"`
JSONQuery *JSONQuerySpec `json:"jsonQuery,omitempty"`
DNS *DNSSpec `json:"dns,omitempty"`
Port *PortSpec `json:"port,omitempty"`
}
type KumaMonitorStatus struct {
MonitorID int64 `json:"monitorID,omitempty"`
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
// +listType=map
// +listMapKey=type
Conditions []metav1.Condition `json:"conditions,omitempty"`
}
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:resource:path=kumamonitors,scope=Namespaced
// +kubebuilder:printcolumn:name="Type",type=string,JSONPath=`.spec.type`
// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status`
type KumaMonitor struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec KumaMonitorSpec `json:"spec"`
Status KumaMonitorStatus `json:"status,omitempty"`
}
// +kubebuilder:object:root=true
type KumaMonitorList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []KumaMonitor `json:"items"`
}
func init() {
SchemeBuilder.Register(&KumaMonitor{}, &KumaMonitorList{})
}
@@ -0,0 +1,198 @@
//go:build envtest
package v1alpha1
import (
"context"
"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)
}
}
@@ -0,0 +1,248 @@
//go:build !ignore_autogenerated
// Code generated by controller-gen. DO NOT EDIT.
package v1alpha1
import (
"k8s.io/apimachinery/pkg/apis/meta/v1"
runtime "k8s.io/apimachinery/pkg/runtime"
)
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *DNSSpec) DeepCopyInto(out *DNSSpec) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSSpec.
func (in *DNSSpec) DeepCopy() *DNSSpec {
if in == nil {
return nil
}
out := new(DNSSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *HTTPSpec) DeepCopyInto(out *HTTPSpec) {
*out = *in
if in.AcceptedStatusCodes != nil {
in, out := &in.AcceptedStatusCodes, &out.AcceptedStatusCodes
*out = make([]string, len(*in))
copy(*out, *in)
}
if in.Headers != nil {
in, out := &in.Headers, &out.Headers
*out = make(map[string]string, len(*in))
for key, val := range *in {
(*out)[key] = val
}
}
if in.MaxRedirects != nil {
in, out := &in.MaxRedirects, &out.MaxRedirects
*out = new(int32)
**out = **in
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HTTPSpec.
func (in *HTTPSpec) DeepCopy() *HTTPSpec {
if in == nil {
return nil
}
out := new(HTTPSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *JSONQuerySpec) DeepCopyInto(out *JSONQuerySpec) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new JSONQuerySpec.
func (in *JSONQuerySpec) DeepCopy() *JSONQuerySpec {
if in == nil {
return nil
}
out := new(JSONQuerySpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *KeywordSpec) DeepCopyInto(out *KeywordSpec) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KeywordSpec.
func (in *KeywordSpec) DeepCopy() *KeywordSpec {
if in == nil {
return nil
}
out := new(KeywordSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *KumaMonitor) DeepCopyInto(out *KumaMonitor) {
*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 KumaMonitor.
func (in *KumaMonitor) DeepCopy() *KumaMonitor {
if in == nil {
return nil
}
out := new(KumaMonitor)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *KumaMonitor) 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 *KumaMonitorList) DeepCopyInto(out *KumaMonitorList) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ListMeta.DeepCopyInto(&out.ListMeta)
if in.Items != nil {
in, out := &in.Items, &out.Items
*out = make([]KumaMonitor, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KumaMonitorList.
func (in *KumaMonitorList) DeepCopy() *KumaMonitorList {
if in == nil {
return nil
}
out := new(KumaMonitorList)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *KumaMonitorList) 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 *KumaMonitorSpec) DeepCopyInto(out *KumaMonitorSpec) {
*out = *in
if in.ParentRef != nil {
in, out := &in.ParentRef, &out.ParentRef
*out = new(LocalRef)
**out = **in
}
if in.Timeout != nil {
in, out := &in.Timeout, &out.Timeout
*out = new(int32)
**out = **in
}
if in.HTTP != nil {
in, out := &in.HTTP, &out.HTTP
*out = new(HTTPSpec)
(*in).DeepCopyInto(*out)
}
if in.Keyword != nil {
in, out := &in.Keyword, &out.Keyword
*out = new(KeywordSpec)
**out = **in
}
if in.JSONQuery != nil {
in, out := &in.JSONQuery, &out.JSONQuery
*out = new(JSONQuerySpec)
**out = **in
}
if in.DNS != nil {
in, out := &in.DNS, &out.DNS
*out = new(DNSSpec)
**out = **in
}
if in.Port != nil {
in, out := &in.Port, &out.Port
*out = new(PortSpec)
**out = **in
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KumaMonitorSpec.
func (in *KumaMonitorSpec) DeepCopy() *KumaMonitorSpec {
if in == nil {
return nil
}
out := new(KumaMonitorSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *KumaMonitorStatus) DeepCopyInto(out *KumaMonitorStatus) {
*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 KumaMonitorStatus.
func (in *KumaMonitorStatus) DeepCopy() *KumaMonitorStatus {
if in == nil {
return nil
}
out := new(KumaMonitorStatus)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *LocalRef) DeepCopyInto(out *LocalRef) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LocalRef.
func (in *LocalRef) DeepCopy() *LocalRef {
if in == nil {
return nil
}
out := new(LocalRef)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *PortSpec) DeepCopyInto(out *PortSpec) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PortSpec.
func (in *PortSpec) DeepCopy() *PortSpec {
if in == nil {
return nil
}
out := new(PortSpec)
in.DeepCopyInto(out)
return out
}
@@ -0,0 +1,32 @@
package main
import (
"errors"
"fmt"
)
type config struct {
URL string
Username string
Password string
}
// loadConfig reads the Kuma connection settings from the environment. It
// names every missing variable.
func loadConfig(getenv func(string) string) (config, error) {
var cfg config
var missing []error
for _, v := range []struct {
name string
dst *string
}{
{"KUMA_URL", &cfg.URL},
{"KUMA_USERNAME", &cfg.Username},
{"KUMA_PASSWORD", &cfg.Password},
} {
if *v.dst = getenv(v.name); *v.dst == "" {
missing = append(missing, fmt.Errorf("missing required environment variable %s", v.name))
}
}
return cfg, errors.Join(missing...)
}
@@ -0,0 +1,38 @@
package main
import (
"strings"
"testing"
)
func TestLoadConfig(t *testing.T) {
env := func(m map[string]string) func(string) string {
return func(k string) string { return m[k] }
}
full := map[string]string{"KUMA_URL": "http://kuma", "KUMA_USERNAME": "u", "KUMA_PASSWORD": "p"}
cfg, err := loadConfig(env(full))
if err != nil || cfg != (config{URL: "http://kuma", Username: "u", Password: "p"}) {
t.Fatalf("cfg=%+v err=%v", cfg, err)
}
for _, name := range []string{"KUMA_URL", "KUMA_USERNAME", "KUMA_PASSWORD"} {
m := map[string]string{}
for k, v := range full {
if k != name {
m[k] = v
}
}
_, err := loadConfig(env(m))
if err == nil || !strings.Contains(err.Error(), name) {
t.Errorf("missing %s: err = %v, want it to name the variable", name, err)
}
}
_, err = loadConfig(env(nil))
for _, name := range []string{"KUMA_URL", "KUMA_USERNAME", "KUMA_PASSWORD"} {
if err == nil || !strings.Contains(err.Error(), name) {
t.Errorf("all missing: err = %v, want %s named", err, name)
}
}
}
+56
View File
@@ -0,0 +1,56 @@
// Command kuma-sync reconciles KumaMonitor resources into Uptime Kuma.
package main
import (
"fmt"
"io"
"os"
"time"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/log/zap"
"gitea.renner.uno/k.renner/homelab/kuma-sync/internal/app"
"gitea.renner.uno/k.renner/homelab/kuma-sync/internal/kumaclient"
)
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "kuma-sync:", err)
os.Exit(1)
}
}
func run() error {
cfg, err := loadConfig(os.Getenv)
if err != nil {
return err
}
ctrl.SetLogger(zap.New())
// The Kuma session lives as long as this context, so it is the signal
// context of the process.
ctx := ctrl.SetupSignalHandler()
kuma, err := kumaclient.NewBreml(ctx, cfg.URL, cfg.Username, cfg.Password)
if err != nil {
return fmt.Errorf("connect to Kuma: %w", err)
}
if c, ok := kuma.(io.Closer); ok {
defer c.Close()
}
mgr, err := app.NewManager(app.Options{
Config: ctrl.GetConfigOrDie(),
Kuma: kuma,
MetricsAddr: ":8080",
ProbeAddr: ":8081",
GCInterval: 5 * time.Minute,
OrphanGrace: time.Hour,
Resync: 5 * time.Minute,
})
if err != nil {
return err
}
return mgr.Start(ctx)
}
@@ -0,0 +1,295 @@
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.23.0
name: kumamonitors.uptime.renner.uno
spec:
group: uptime.renner.uno
names:
kind: KumaMonitor
listKind: KumaMonitorList
plural: kumamonitors
singular: kumamonitor
scope: Namespaced
versions:
- additionalPrinterColumns:
- jsonPath: .spec.type
name: Type
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
displayName:
minLength: 1
type: string
dns:
properties:
hostname:
minLength: 1
type: string
port:
default: 53
format: int32
maximum: 65535
minimum: 1
type: integer
resolveServer:
default: 1.1.1.1
type: string
resolveType:
enum:
- A
- AAAA
- CNAME
- MX
- NS
- PTR
- SOA
- SRV
- TXT
- CAA
type: string
required:
- hostname
- resolveType
type: object
http:
description: HTTPSpec configures http, keyword and json-query monitors.
properties:
acceptedStatusCodes:
default:
- 200-299
items:
type: string
type: array
body:
type: string
expiryNotification:
type: boolean
headers:
additionalProperties:
type: string
type: object
ignoreTLS:
type: boolean
maxRedirects:
format: int32
type: integer
method:
default: GET
type: string
url:
minLength: 1
type: string
required:
- url
type: object
interval:
default: 60
description: Seconds.
format: int32
minimum: 20
type: integer
jsonQuery:
properties:
expected:
type: string
expression:
description: JSONata expression.
minLength: 1
type: string
operator:
enum:
- ==
- '!='
- <
- <=
- '>'
- '>='
- contains
type: string
required:
- expected
- expression
- operator
type: object
keyword:
properties:
invert:
type: boolean
keyword:
minLength: 1
type: string
required:
- keyword
type: object
maxRetries:
default: 0
format: int32
minimum: 0
type: integer
parentRef:
description: LocalRef references a KumaMonitor in the same namespace.
properties:
name:
type: string
required:
- name
type: object
paused:
type: boolean
port:
properties:
hostname:
minLength: 1
type: string
port:
format: int32
maximum: 65535
minimum: 1
type: integer
required:
- hostname
- port
type: object
retryInterval:
default: 60
description: Seconds.
format: int32
minimum: 20
type: integer
timeout:
description: Seconds.
format: int32
minimum: 1
type: integer
type:
enum:
- group
- http
- keyword
- json-query
- dns
- port
type: string
required:
- displayName
- type
type: object
x-kubernetes-validations:
- message: http is required for types http, keyword and json-query, and
forbidden otherwise
rule: (self.type in ['http','keyword','json-query']) == has(self.http)
- message: keyword is required for type keyword and forbidden otherwise
rule: (self.type == 'keyword') == has(self.keyword)
- message: jsonQuery is required for type json-query and forbidden otherwise
rule: (self.type == 'json-query') == has(self.jsonQuery)
- message: dns is required for type dns and forbidden otherwise
rule: (self.type == 'dns') == has(self.dns)
- message: port is required for type port and forbidden otherwise
rule: (self.type == 'port') == has(self.port)
- message: type group takes no type blocks
rule: self.type != 'group' || !(has(self.http) || has(self.keyword)
|| has(self.jsonQuery) || has(self.dns) || has(self.port))
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
monitorID:
format: int64
type: integer
observedGeneration:
format: int64
type: integer
type: object
required:
- spec
type: object
served: true
storage: true
subresources:
status: {}
+43
View File
@@ -0,0 +1,43 @@
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: kuma-sync
rules:
- apiGroups:
- ""
resources:
- events
verbs:
- create
- patch
- apiGroups:
- ""
resources:
- namespaces
verbs:
- get
- apiGroups:
- uptime.renner.uno
resources:
- kumamonitors
verbs:
- get
- list
- patch
- update
- watch
- apiGroups:
- uptime.renner.uno
resources:
- kumamonitors/finalizers
verbs:
- update
- apiGroups:
- uptime.renner.uno
resources:
- kumamonitors/status
verbs:
- get
- patch
- update
+123
View File
@@ -0,0 +1,123 @@
module gitea.renner.uno/k.renner/homelab/kuma-sync
go 1.27
require (
github.com/breml/go-uptime-kuma-client v0.6.2
github.com/testcontainers/testcontainers-go v0.44.0
k8s.io/api v0.37.1
k8s.io/apimachinery v0.37.1
k8s.io/client-go v0.37.1
k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3
sigs.k8s.io/controller-runtime v0.25.2
)
require (
dario.cat/mergo v1.0.2 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v0.2.1 // indirect
github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.7.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/ebitengine/purego v0.10.1 // indirect
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
github.com/evanphx/json-patch/v5 v5.9.11 // indirect
github.com/felixge/httpsnoop v1.1.0 // indirect
github.com/fsnotify/fsnotify v1.10.1 // indirect
github.com/fxamacker/cbor/v2 v2.9.1 // indirect
github.com/go-logr/logr v1.4.4 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-logr/zapr v1.3.0 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-openapi/jsonpointer v1.0.0 // indirect
github.com/go-openapi/jsonreference v1.0.0 // indirect
github.com/go-openapi/swag v0.27.1 // indirect
github.com/go-openapi/swag/cmdutils v0.27.1 // indirect
github.com/go-openapi/swag/conv v0.27.1 // indirect
github.com/go-openapi/swag/fileutils v0.27.1 // indirect
github.com/go-openapi/swag/jsonutils v0.27.1 // indirect
github.com/go-openapi/swag/loading v0.27.1 // indirect
github.com/go-openapi/swag/mangling v0.27.1 // indirect
github.com/go-openapi/swag/netutils v0.27.1 // indirect
github.com/go-openapi/swag/pools v0.27.1 // indirect
github.com/go-openapi/swag/stringutils v0.27.1 // indirect
github.com/go-openapi/swag/typeutils v0.27.1 // indirect
github.com/go-openapi/swag/yamlutils v0.27.1 // indirect
github.com/google/gnostic-models v0.7.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.19.2 // indirect
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/maldikhan/go.socket.io v0.1.1 // indirect
github.com/maniartech/signals v1.3.1 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.2.0 // indirect
github.com/moby/moby/api v1.55.0 // indirect
github.com/moby/moby/client v0.5.0 // indirect
github.com/moby/patternmatcher v0.6.1 // indirect
github.com/moby/sys/sequential v0.7.0 // indirect
github.com/moby/sys/user v0.4.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/pquerna/otp v1.5.0 // indirect
github.com/prometheus/client_golang v1.24.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.70.0 // indirect
github.com/prometheus/procfs v0.21.1 // indirect
github.com/shirou/gopsutil/v4 v4.26.6 // indirect
github.com/sirupsen/logrus v1.10.1 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/stretchr/testify v1.12.1 // indirect
github.com/tklauser/go-sysconf v0.4.0 // indirect
github.com/tklauser/numcpus v0.12.0 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 // indirect
go.opentelemetry.io/otel v1.45.0 // indirect
go.opentelemetry.io/otel/metric v1.45.0 // indirect
go.opentelemetry.io/otel/trace v1.45.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.28.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/crypto v0.55.0 // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/term v0.45.0 // indirect
golang.org/x/text v0.41.0 // indirect
golang.org/x/time v0.15.0 // indirect
gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
k8s.io/apiextensions-apiserver v0.37.1 // indirect
k8s.io/klog/v2 v2.140.0 // indirect
k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad // indirect
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
sigs.k8s.io/randfill v1.0.0 // indirect
sigs.k8s.io/structured-merge-diff/v6 v6.4.2 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
)
replace github.com/maldikhan/go.socket.io => github.com/breml/go.socket.io v0.0.0-20260516193936-e70410c8cd31
+312
View File
@@ -0,0 +1,312 @@
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw=
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/breml/go-uptime-kuma-client v0.6.2 h1:C2Z56OFLNCHHeEkQ6T8B7Nuqkf8SYtPy3Nb1YhFMCUA=
github.com/breml/go-uptime-kuma-client v0.6.2/go.mod h1:LQkdwVs3TzTQZ92WvPIGgHoAC6rUEJkn+8ULkc51uJ8=
github.com/breml/go.socket.io v0.0.0-20260516193936-e70410c8cd31 h1:l5fVxSzbJHJ1jcLxmcOLVY0uxZFfB1yuztPJVtMfLRg=
github.com/breml/go.socket.io v0.0.0-20260516193936-e70410c8cd31/go.mod h1:H1EoWDJvqfV2WryM8F9og6ZkH7NsZDlHr0BRkKb58tY=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/containerd/continuity v0.4.5 h1:ZRoN1sXq9u7V6QoHMcVWGhOwDFqZ4B9i5H6un1Wh0x4=
github.com/containerd/continuity v0.4.5/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/cli v27.4.1+incompatible h1:VzPiUlRJ/xh+otB75gva3r05isHMo5wXDfPRi5/b4hI=
github.com/docker/cli v27.4.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/docker v27.1.1+incompatible h1:hO/M4MtV36kzKldqnA37IWhebRA+LnqqcqDja6kVaKY=
github.com/docker/docker v27.1.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=
github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k=
github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ=
github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU=
github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM=
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ=
github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ=
github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s=
github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y=
github.com/go-openapi/jsonreference v1.0.0 h1:jlmTr6torcd1YgDQvSfNmRtKzYDO4FGBkrAdlAVWnpY=
github.com/go-openapi/jsonreference v1.0.0/go.mod h1:jtwdyGbJk0Xhe5Y+rwtglQP6Sb1WZST4rT32LWB+sv0=
github.com/go-openapi/swag v0.27.1 h1:VotvOLWW8q/EAxB0YdsBBGC8XYyeL1YwBj2ungAGPNg=
github.com/go-openapi/swag v0.27.1/go.mod h1:GTkJPwHfhJp6MWr4/rCh64HVI3Ofu+tcsbfjfHmTxpE=
github.com/go-openapi/swag/cmdutils v0.27.1 h1:I7sYqaWVl5mq0NEmNQkAmFDyNin9ufvMX/p2zwtQaOE=
github.com/go-openapi/swag/cmdutils v0.27.1/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM=
github.com/go-openapi/swag/conv v0.27.1 h1:8wi9ZG+olmY1wXphl93EWniPtbSPkXM/feH7FgjsvrU=
github.com/go-openapi/swag/conv v0.27.1/go.mod h1:QbqMivkpKhC3g1B1GGGOJ6ANewI3S62dbzYu3Duowqs=
github.com/go-openapi/swag/fileutils v0.27.1 h1:QQqBSoi5mW4XpU85nS0mLcA+zAE6vLzrb0QkmLKf9oM=
github.com/go-openapi/swag/fileutils v0.27.1/go.mod h1:VvJFZLTZS0AI854gEQz5tk7dBESdLjiNUMSZ/th2ry8=
github.com/go-openapi/swag/jsonutils v0.27.1 h1:SVgK3i4USzCU5mibOOS/l4ea2h9UQXy7J7RNLTjuXjU=
github.com/go-openapi/swag/jsonutils v0.27.1/go.mod h1:tdlEpZqdcQ17uj6J4YdK9vd8It5qWMwjWXOs0tjpRlk=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.1 h1:mJu3COL9WEaZVp/Kf2PRMi7tPszPEJfSr/OO75ynCs8=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.1/go.mod h1:mofwUWx70wvskwESqRJ//k/9kURmCgyJl5m5Ppoh5kY=
github.com/go-openapi/swag/loading v0.27.1 h1:/DxUgDXKbBX4bcn7r9uEXfJyzN5XpiJmZplzQTjrRCY=
github.com/go-openapi/swag/loading v0.27.1/go.mod h1:jvGh3iA2+zyUUycB5fgJWzeHnhrpvGnJJM0RVE9ZShE=
github.com/go-openapi/swag/mangling v0.27.1 h1:yC9D0HyUE8gbP+BfmGx9+AA89ikwZTMjESK3OnnoaqA=
github.com/go-openapi/swag/mangling v0.27.1/go.mod h1:jtBE2+V+3pILxOR7Vgce+Cwp6A2PgZbvVqfNntbVs0w=
github.com/go-openapi/swag/netutils v0.27.1 h1:mICMFoS82F5TZ4Zy3cqmcQk+BFeCp3Uyq3Np7GI0/qU=
github.com/go-openapi/swag/netutils v0.27.1/go.mod h1:J+WYyFMLtvtCGqa6jLv+YNUmIKI3ZRQRrvfNDMoQoEQ=
github.com/go-openapi/swag/pools v0.27.1 h1:9LeadcMyb2GJCbXX5hVQDbZ2Lq9TL4dCs/nx1j5DO0E=
github.com/go-openapi/swag/pools v0.27.1/go.mod h1:kVQefhSK5RWuRe7BXsL8htgBPAMpN7HDGpGEknqugeE=
github.com/go-openapi/swag/stringutils v0.27.1 h1:ZXePZ0r2p1qSjo8tD3Un4vFj8+FqlCkczxDrJIhYUp8=
github.com/go-openapi/swag/stringutils v0.27.1/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM=
github.com/go-openapi/swag/typeutils v0.27.1 h1:KSTdFlfnse4r6dP9IrEnwMldjE+zs71UeEB3//PtVXc=
github.com/go-openapi/swag/typeutils v0.27.1/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ=
github.com/go-openapi/swag/yamlutils v0.27.1 h1:ftxv6xvXb1E3zohUc+okZ9nSqNb9StQX/FXnKZ98sQA=
github.com/go-openapi/swag/yamlutils v0.27.1/go.mod h1:bnxFIB1qewGRiZHypXGZ3fNgf13/0HfRgnS/iZBDrOo=
github.com/go-openapi/testify/enable/yaml/v2 v2.6.0 h1:gGHwAJ0R/5jU8BEGDbfRNR3hL68dAVi84WuOApp29B0=
github.com/go-openapi/testify/enable/yaml/v2 v2.6.0/go.mod h1:tY+St1SGq4NFl0QIqdTY4aEdbChAHxhyB77XQi9iJCo=
github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug=
github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8=
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8=
github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak=
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/maniartech/signals v1.3.1 h1:pT3dK6x5Un+B6L3ZLAKygEe+L49TClPreyT08vOoHXY=
github.com/maniartech/signals v1.3.1/go.mod h1:AbE8Yy9ZjKCWNU/VhQ+0Ea9KOaTWHp6aOfdLBe5m1iM=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc=
github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
github.com/moby/moby/client v0.5.0 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc=
github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s=
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/sequential v0.7.0 h1:ASQNGNROJSuOO6LL6bPHbKvuZu6NU8P4ldPWk31zj/8=
github.com/moby/sys/sequential v0.7.0/go.mod h1:NfSTAp6V3fw4tmkD62PEcOKeZKquXT8VKCkf7aVR79o=
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/onsi/ginkgo/v2 v2.27.4 h1:fcEcQW/A++6aZAZQNUmNjvA9PSOzefMJBerHJ4t8v8Y=
github.com/onsi/ginkgo/v2 v2.27.4/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
github.com/onsi/gomega v1.39.0 h1:y2ROC3hKFmQZJNFeGAMeHZKkjBL65mIZcvrLQBF9k6Q=
github.com/onsi/gomega v1.39.0/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/opencontainers/runc v1.2.3 h1:fxE7amCzfZflJO2lHXf4y/y8M1BoAqp+FVmG19oYB80=
github.com/opencontainers/runc v1.2.3/go.mod h1:nSxcWUydXrsBZVYNSkTjoQ/N6rcyTtn+1SD5D4+kRIM=
github.com/ory/dockertest/v3 v3.12.0 h1:3oV9d0sDzlSQfHtIaB5k6ghUCVMVLpAY8hwrqoCyRCw=
github.com/ory/dockertest/v3 v3.12.0/go.mod h1:aKNDTva3cp8dwOWwb9cWuX84aH5akkxXRvO7KCwWVjE=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/prometheus/client_golang v1.24.0 h1:5XStIklKuAtJSNpdD3s8XJj/Yv78IQmE1kbNk87JrAI=
github.com/prometheus/client_golang v1.24.0/go.mod h1:QcsNdotprC2nS4BTM2ucbcqxd2CeXTEa9jW7zHO9iDE=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.70.0 h1:bcpru3tWPVnxGnETLgOV5jbp/JRXgYEyv65CuBLAMMI=
github.com/prometheus/common v0.70.0/go.mod h1:S/SFasQmgGiYH6C81LKCtYa8QACgthGg5zxL2udV7SY=
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs=
github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/sirupsen/logrus v1.10.1 h1:xi4336Zh11WpU14fXR6I67V3yaTPQYwRx2WEtHbRg4Q=
github.com/sirupsen/logrus v1.10.1/go.mod h1:vsQHnG7xzNsxk3NrwboUiWPnIC3dmbjcGPykD7+tiHk=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/testcontainers/testcontainers-go v0.44.0 h1:/Fwh6HY1mIikhnm9e7HwoxGycx0lzRAE0f5VQpjFxzI=
github.com/testcontainers/testcontainers-go v0.44.0/go.mod h1:IcnwQrYTO86xHXu5bvMaBH7ATlbS3Qn1M1QWW3c66rE=
github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU=
github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI=
github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4=
github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo=
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 h1:LMuyCAyfalSjDyjdC65nK6N0zoTT63+E/u95X0JovZI=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0/go.mod h1:085m8qbm4hgc8rZWGDEa4vmyyo2c3nPxUslYUKUIU04=
go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU=
go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ=
go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M=
go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s=
go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw=
go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA=
go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o=
go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA=
go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag=
go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw=
gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY=
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI=
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo=
gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
k8s.io/api v0.37.1 h1:l6N77U7tjwB5L056bgrBTJIEdevac/naBZ3iSvDNfpM=
k8s.io/api v0.37.1/go.mod h1:zSlbB1YpJ1YQlFVQy20UYll81UJSJJUMLhkhvg6Z78M=
k8s.io/apiextensions-apiserver v0.37.1 h1:7fIQG8eThDSTVYBWg/DOpI8v5wYfKCo0N8/TDMjj+zY=
k8s.io/apiextensions-apiserver v0.37.1/go.mod h1:1Q3ujWwHgnhBoGsdmGORflwK1sDFmuaqH1OsMDWk2Gs=
k8s.io/apimachinery v0.37.1 h1:hGCYyvKHCwtwMitj2vU4vYx0Z16N9GyZk9BBnz0wDAE=
k8s.io/apimachinery v0.37.1/go.mod h1:jF84AyUi/IRIXRot5f+lm6MpxoWI+F1XgjaMmwCdTFw=
k8s.io/client-go v0.37.1 h1:QTv/5ha4jAHtW9qxxVBkQVFBRDb4jHfFopQqqMdc+wM=
k8s.io/client-go v0.37.1/go.mod h1:dnAPtTnCNY38Ho04D2KdY1F4IKausa9UbqaAZKl60SY=
k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc=
k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0=
k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad h1:oXImqH8mQNk7PmvzKhmN3ddJoY6OnyM225MXwGHPm0A=
k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad/go.mod h1:0/mqHCVhlumdJ3BhCfnjSZQE037nAhNodh1/hK0T8/I=
k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 h1:jVkFFVfXdXP74B/zbO3hM3hpSFD0xvhQ5U686DPurkE=
k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3/go.mod h1:M2s5JB1lIYP3jzZdorPLHXIPJzt9vv2muW5a6L9DtNM=
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
sigs.k8s.io/controller-runtime v0.25.2 h1:bEkK3PVOIVK9X8QWLGVhJgmFc++47vfT6wakSzAOLsQ=
sigs.k8s.io/controller-runtime v0.25.2/go.mod h1:4QqLdT6z/L6Olj8JJCtvztid4/fnIiYsfaTFScegctc=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
sigs.k8s.io/structured-merge-diff/v6 v6.4.2 h1:qdOxHwrl2Kaag1aQEarlYcOA9vSyGCp3CIki3aW8c4Q=
sigs.k8s.io/structured-merge-diff/v6 v6.4.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
+103
View File
@@ -0,0 +1,103 @@
// Package app wires the controller-runtime manager shared by the binary and
// the end-to-end tests.
package app
import (
"context"
"errors"
"fmt"
"net/http"
"time"
"k8s.io/apimachinery/pkg/runtime"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/rest"
"k8s.io/utils/ptr"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/config"
"sigs.k8s.io/controller-runtime/pkg/healthz"
"sigs.k8s.io/controller-runtime/pkg/manager"
metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
v1 "gitea.renner.uno/k.renner/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/k.renner/homelab/kuma-sync/internal/controller"
"gitea.renner.uno/k.renner/homelab/kuma-sync/internal/kumaclient"
)
// Options configures NewManager.
type Options struct {
Config *rest.Config
Kuma kumaclient.Client
// MetricsAddr and ProbeAddr are bind addresses; "0" and "" disable them.
MetricsAddr string
ProbeAddr string
// GCInterval is the orphan collection period; 0 means 5 minutes.
GCInterval time.Duration
// OrphanGrace is how long a monitor must be seen without its resource
// before the GC deletes it. Production uses 1h; 0 only requires two runs.
OrphanGrace time.Duration
// Resync is how often a healthy resource is reconciled again, which is
// what repairs drift made in Kuma; 0 means 5 minutes.
Resync time.Duration
// SkipNameValidation allows several managers in one process, as in tests
// that restart the manager.
SkipNameValidation bool
}
// NewManager builds the manager with the reconciler and the orphan GC.
// There is no leader election: a single replica runs.
func NewManager(o Options) (manager.Manager, error) {
scheme := runtime.NewScheme()
// Core types: the GC reads Namespaces.
if err := clientgoscheme.AddToScheme(scheme); err != nil {
return nil, err
}
if err := v1.AddToScheme(scheme); err != nil {
return nil, err
}
mopts := ctrl.Options{
Scheme: scheme,
Metrics: metricsserver.Options{BindAddress: o.MetricsAddr},
HealthProbeBindAddress: o.ProbeAddr,
LeaderElection: false,
}
if o.SkipNameValidation {
mopts.Controller = config.Controller{SkipNameValidation: ptr.To(true)}
}
mgr, err := ctrl.NewManager(o.Config, mopts)
if err != nil {
return nil, fmt.Errorf("create manager: %w", err)
}
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)
}
if err := mgr.Add(&controller.GC{
Client: mgr.GetClient(),
Kuma: o.Kuma,
Interval: o.GCInterval,
OrphanGrace: o.OrphanGrace,
Cache: mgr.GetCache(),
APIReader: mgr.GetAPIReader(),
}); err != nil {
return nil, fmt.Errorf("add GC: %w", err)
}
if err := mgr.AddHealthzCheck("ping", healthz.Ping); err != nil {
return nil, err
}
if err := mgr.AddReadyzCheck("cache-synced", func(req *http.Request) error {
ctx, cancel := context.WithTimeout(req.Context(), time.Second)
defer cancel()
if !mgr.GetCache().WaitForCacheSync(ctx) {
return errors.New("cache not synced")
}
return nil
}); err != nil {
return nil, err
}
return mgr, nil
}
@@ -0,0 +1,458 @@
//go:build integration
package controller_test
import (
"context"
"fmt"
"io"
"path/filepath"
"sync"
"testing"
"time"
kuma "github.com/breml/go-uptime-kuma-client"
"github.com/breml/go-uptime-kuma-client/monitor"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/util/retry"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/envtest"
v1 "gitea.renner.uno/k.renner/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/k.renner/homelab/kuma-sync/internal/app"
"gitea.renner.uno/k.renner/homelab/kuma-sync/internal/kumaclient"
)
const (
user = "admin"
pass = "Sup3r-Secret-Passw0rd!"
gcInterval = 2 * time.Second
resync = 2 * time.Second
)
// e2e is a real API server (envtest, CRD installed) next to a real Kuma.
type e2e struct {
t *testing.T
k8s client.Client
url string
raw *kuma.Client // library session used to change Kuma behind the controller's back
census kumaclient.Client // separate session used only to count monitors
}
func startKuma(t *testing.T) string {
t.Helper()
ctx := context.Background()
ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: testcontainers.ContainerRequest{
Image: "louislam/uptime-kuma:2.5.6-slim",
ExposedPorts: []string{"3001/tcp"},
Env: map[string]string{"UPTIME_KUMA_DB_TYPE": "sqlite"},
WaitingFor: wait.ForHTTP("/api/entry-page").WithPort("3001/tcp").
WithStatusCodeMatcher(func(s int) bool { return s == 200 }).
WithStartupTimeout(3 * time.Minute),
},
Started: true,
})
if err != nil {
t.Fatalf("start kuma: %v", err)
}
t.Cleanup(func() { _ = ctr.Terminate(context.Background()) })
url, err := ctr.PortEndpoint(ctx, "3001/tcp", "http")
if err != nil {
t.Fatal(err)
}
return url
}
func newE2E(t *testing.T) (*e2e, *envtest.Environment) {
t.Helper()
env := &envtest.Environment{
CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd")},
ErrorIfCRDPathMissing: true,
}
cfg, err := env.Start()
if err != nil {
t.Fatalf("start envtest: %v", err)
}
t.Cleanup(func() { _ = env.Stop() })
scheme := runtime.NewScheme()
if err := v1.AddToScheme(scheme); err != nil {
t.Fatal(err)
}
k8s, err := client.New(cfg, client.Options{Scheme: scheme})
if err != nil {
t.Fatal(err)
}
url := startKuma(t)
// The first login creates the admin account.
raw, err := kuma.New(t.Context(), url, user, pass, kuma.WithAutosetup(), kuma.WithConnectTimeout(2*time.Minute))
if err != nil {
t.Fatalf("kuma setup: %v", err)
}
t.Cleanup(func() { _ = raw.Disconnect() })
census, err := kumaclient.NewBreml(t.Context(), url, user, pass)
if err != nil {
t.Fatal(err)
}
if c, ok := census.(io.Closer); ok {
t.Cleanup(func() { _ = c.Close() })
}
return &e2e{t: t, k8s: k8s, url: url, raw: raw, census: census}, env
}
// manager is one run of the controller, built from scratch like a pod start.
type running struct {
cancel context.CancelFunc
done chan error
kuma io.Closer
once sync.Once
}
func (f *e2e) start(env *envtest.Environment) *running {
f.t.Helper()
ctx, cancel := context.WithCancel(f.t.Context())
kc, err := kumaclient.NewBreml(ctx, f.url, user, pass)
if err != nil {
cancel()
f.t.Fatalf("new kuma client: %v", err)
}
mgr, err := app.NewManager(app.Options{
Config: env.Config,
Kuma: kc,
MetricsAddr: "0",
GCInterval: gcInterval,
OrphanGrace: 3 * time.Second,
Resync: resync,
SkipNameValidation: true,
})
if err != nil {
cancel()
f.t.Fatalf("new manager: %v", err)
}
r := &running{cancel: cancel, done: make(chan error, 1)}
if c, ok := kc.(io.Closer); ok {
r.kuma = c
}
go func() { r.done <- mgr.Start(ctx) }()
// Runs before the envtest and Kuma teardown, also after an early Fatal.
f.t.Cleanup(func() { f.stop(r) })
return r
}
// stop cancels the manager and waits for it to return. It is idempotent.
func (f *e2e) stop(r *running) {
f.t.Helper()
r.once.Do(func() {
r.cancel()
select {
case err := <-r.done:
if err != nil {
f.t.Errorf("manager returned %v", err)
}
case <-time.After(30 * time.Second):
f.t.Errorf("manager did not stop")
}
if r.kuma != nil {
_ = r.kuma.Close()
}
})
}
func eventually(t *testing.T, timeout time.Duration, what string, cond func() (bool, string)) {
t.Helper()
deadline := time.Now().Add(timeout)
for {
ok, detail := cond()
if ok {
return
}
if time.Now().After(deadline) {
t.Fatalf("timed out after %v waiting for %s: %s", timeout, what, detail)
}
time.Sleep(100 * time.Millisecond)
}
}
func (f *e2e) owned() []kumaclient.Monitor {
f.t.Helper()
list, err := f.census.ListOwned(f.t.Context())
if err != nil {
f.t.Fatalf("census list: %v", err)
}
return list
}
func byKey(list []kumaclient.Monitor) map[string]kumaclient.Monitor {
out := map[string]kumaclient.Monitor{}
for _, m := range list {
out[m.OwnerKey] = m
}
return out
}
func group(name, parent string) *v1.KumaMonitor {
km := &v1.KumaMonitor{
ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: name},
Spec: v1.KumaMonitorSpec{Type: "group", DisplayName: name},
}
if parent != "" {
km.Spec.ParentRef = &v1.LocalRef{Name: parent}
}
return km
}
func dns(name, parent string) *v1.KumaMonitor {
km := &v1.KumaMonitor{
ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: name},
Spec: v1.KumaMonitorSpec{
Type: "dns", DisplayName: name,
DNS: &v1.DNSSpec{Hostname: "example.com", ResolveType: "A"},
},
}
if parent != "" {
km.Spec.ParentRef = &v1.LocalRef{Name: parent}
}
return km
}
func (f *e2e) apply(objs ...*v1.KumaMonitor) {
f.t.Helper()
for _, o := range objs {
if err := f.k8s.Create(f.t.Context(), o); err != nil {
f.t.Fatalf("create %s: %v", o.Name, err)
}
}
}
func TestE2EGroupNesting(t *testing.T) {
f, env := newE2E(t)
f.start(env)
f.apply(group("outer", ""), group("inner", "outer"), dns("probe", "inner"))
eventually(t, 60*time.Second, "outer > inner > probe in Kuma", func() (bool, string) {
m := byKey(f.owned())
outer, inner, probe := m["default/outer"], m["default/inner"], m["default/probe"]
if outer.ID == 0 || inner.ID == 0 || probe.ID == 0 {
return false, "not all three monitors exist yet"
}
if outer.Parent != nil {
return false, "outer must have no parent"
}
if inner.Parent == nil || *inner.Parent != outer.ID {
return false, "inner is not under outer"
}
if probe.Parent == nil || *probe.Parent != inner.ID {
return false, "probe is not under inner"
}
return true, ""
})
}
func TestE2ERestartNoDuplicates(t *testing.T) {
f, env := newE2E(t)
r := f.start(env)
f.apply(group("g", ""), dns("probe", "g"))
eventually(t, 30*time.Second, "both monitors in Kuma", func() (bool, string) {
return len(f.owned()) == 2, "monitors missing"
})
before := byKey(f.owned())
f.stop(r)
f.start(env)
// A spec change after the restart must land on the same Kuma monitor: the
// new manager reconciled and adopted it instead of creating another.
key := types.NamespacedName{Namespace: "default", Name: "g"}
if err := retry.RetryOnConflict(retry.DefaultRetry, func() error {
km := &v1.KumaMonitor{}
if err := f.k8s.Get(t.Context(), key, km); err != nil {
return err
}
km.Spec.DisplayName = "g renamed"
return f.k8s.Update(t.Context(), km)
}); err != nil {
t.Fatal(err)
}
eventually(t, 30*time.Second, "rename reflected on the original monitor", func() (bool, string) {
m := byKey(f.owned())["default/g"]
if m.ID != before["default/g"].ID {
return false, fmt.Sprintf("default/g has ID %d, want %d", m.ID, before["default/g"].ID)
}
return m.Name == "g renamed", "name is " + m.Name
})
// Two GC intervals and then some, checked continuously rather than once.
deadline := time.Now().Add(2*gcInterval + 3*time.Second)
for time.Now().Before(deadline) {
after := byKey(f.owned())
if len(after) != 2 {
t.Fatalf("monitor count changed after restart: %d", len(after))
}
for k, m := range before {
if after[k].ID != m.ID {
t.Fatalf("%s changed ID %d -> %d", k, m.ID, after[k].ID)
}
}
time.Sleep(500 * time.Millisecond)
}
// Both resources must still be reconciled by the new manager.
for _, name := range []string{"g", "probe"} {
km := &v1.KumaMonitor{}
if err := f.k8s.Get(t.Context(), types.NamespacedName{Namespace: "default", Name: name}, km); err != nil {
t.Fatal(err)
}
if km.Status.MonitorID != before["default/"+name].ID {
t.Errorf("%s status.monitorID = %d, want %d", name, km.Status.MonitorID, before["default/"+name].ID)
}
}
}
func TestDeleteWhileDownIsCollected(t *testing.T) {
f, env := newE2E(t)
r := f.start(env)
f.apply(dns("doomed", ""), dns("keeper", ""))
eventually(t, 30*time.Second, "both monitors in Kuma", func() (bool, string) {
return len(f.owned()) == 2, "monitors missing"
})
f.stop(r)
// With the controller down, drop the finalizer and delete the resource.
doomed := &v1.KumaMonitor{}
key := types.NamespacedName{Namespace: "default", Name: "doomed"}
if err := f.k8s.Get(t.Context(), key, doomed); err != nil {
t.Fatal(err)
}
doomed.Finalizers = nil
if err := f.k8s.Update(t.Context(), doomed); err != nil {
t.Fatal(err)
}
if err := f.k8s.Delete(t.Context(), doomed); err != nil {
t.Fatal(err)
}
f.start(env)
eventually(t, 30*time.Second, "orphan collected by GC", func() (bool, string) {
m := byKey(f.owned())
_, doomedLeft := m["default/doomed"]
_, keeperLeft := m["default/keeper"]
if doomedLeft {
return false, "orphan still in Kuma"
}
if !keeperLeft {
t.Fatal("GC deleted the monitor of a live resource")
}
return true, ""
})
}
func TestE2EDriftRepaired(t *testing.T) {
f, env := newE2E(t)
f.start(env)
f.apply(dns("probe", ""))
var id int64
eventually(t, 30*time.Second, "monitor in Kuma", func() (bool, string) {
m, ok := byKey(f.owned())["default/probe"]
id = m.ID
return ok, "monitor missing"
})
want := int64(byKey(f.owned())["default/probe"].Interval)
var live monitor.DNS
if err := f.raw.GetMonitorAs(t.Context(), id, &live); err != nil {
t.Fatal(err)
}
live.Interval = want + 60
if err := f.raw.UpdateMonitor(t.Context(), &live); err != nil {
t.Fatal(err)
}
// The controller may repair the drift at any moment, so observe both
// states in order instead of asserting either on its own.
sawDrift := false
eventually(t, 30*time.Second, "drift seen, then repaired by a resync", func() (bool, string) {
var cur monitor.DNS
if err := f.raw.GetMonitorAs(t.Context(), id, &cur); err != nil {
return false, err.Error()
}
switch {
case !sawDrift && cur.Interval == want+60:
sawDrift = true
case !sawDrift:
t.Fatalf("interval is %d: the drift was repaired before it was ever observed", cur.Interval)
case cur.Interval == want:
return true, ""
}
return false, fmt.Sprintf("interval is %d, want %d (drift seen: %v)", cur.Interval, want, sawDrift)
})
}
// A monitor that lost its ownership tag in the UI must not get a twin when the
// controller restarts: the resource reports NotOwned and nothing is created.
func TestE2ETagRemovedNeverDuplicates(t *testing.T) {
f, env := newE2E(t)
r := f.start(env)
f.apply(dns("probe", ""))
var id int64
eventually(t, 30*time.Second, "monitor in Kuma", func() (bool, string) {
m, ok := byKey(f.owned())["default/probe"]
id = m.ID
return ok, "monitor missing"
})
eventually(t, 30*time.Second, "status.monitorID recorded", func() (bool, string) {
km := &v1.KumaMonitor{}
if err := f.k8s.Get(t.Context(), types.NamespacedName{Namespace: "default", Name: "probe"}, km); err != nil {
return false, err.Error()
}
return km.Status.MonitorID == id, "status.monitorID not set"
})
f.stop(r)
tags, err := f.raw.GetTags(t.Context())
if err != nil {
t.Fatal(err)
}
var tagID int64
for _, tg := range tags {
if tg.Name == "kuma-sync" && (tagID == 0 || tg.ID < tagID) {
tagID = tg.ID
}
}
if err := f.raw.DeleteMonitorTagWithValue(t.Context(), tagID, id, "default/probe"); err != nil {
t.Fatal(err)
}
f.start(env)
eventually(t, 30*time.Second, "Ready=False/NotOwned", func() (bool, string) {
km := &v1.KumaMonitor{}
if err := f.k8s.Get(t.Context(), types.NamespacedName{Namespace: "default", Name: "probe"}, km); err != nil {
return false, err.Error()
}
c := meta.FindStatusCondition(km.Status.Conditions, v1.ConditionReady)
if c == nil {
return false, "no Ready condition"
}
return c.Status == metav1.ConditionFalse && c.Reason == "NotOwned", "Ready is " + string(c.Status) + "/" + c.Reason
})
// No twin appears, and the GC leaves the untagged monitor alone.
deadline := time.Now().Add(2*gcInterval + 3*time.Second)
for time.Now().Before(deadline) {
all, err := f.raw.GetMonitors(t.Context())
if err != nil {
t.Fatal(err)
}
if len(all) != 1 || all[0].ID != id {
t.Fatalf("Kuma holds %d monitors, want only the untagged original %d", len(all), id)
}
time.Sleep(500 * time.Millisecond)
}
}
@@ -0,0 +1,246 @@
package controller
import (
"context"
"errors"
"fmt"
"slices"
"strings"
"sync"
"time"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/manager"
v1 "gitea.renner.uno/k.renner/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/k.renner/homelab/kuma-sync/internal/kumaclient"
)
// +kubebuilder:rbac:groups="",resources=namespaces,verbs=get
// CacheSyncer is satisfied by the manager's cache.
type CacheSyncer interface {
WaitForCacheSync(ctx context.Context) bool
}
// errListFailed marks a run that stopped before deleting anything because a
// list failed.
var errListFailed = errors.New("list failed, nothing deleted")
// minOrphanRuns is how many consecutive runs must see a monitor orphaned
// before it is deleted, whatever the grace period.
const minOrphanRuns = 2
// GC deletes owned Kuma monitors that no KumaMonitor accounts for, and
// duplicates of one owner key beyond the lowest ID.
//
// A run deletes nothing unless both lists succeeded completely: any error from
// ListOwned (conflicts and partial results included) or from listing
// KumaMonitors aborts it. Start waits for Cache to sync first, so an empty
// cache at startup never looks like "every resource was deleted".
//
// An orphan, confirmed absent on the API server, is deleted only after it was
// seen orphaned in at least two consecutive runs spanning OrphanGrace; this
// keeps a cluster rebuild, where the resources are applied long after the
// controller starts, from wiping their monitors. The first-seen times live in
// memory, so a restart starts the grace period over. Orphans in a namespace
// that does not exist are never deleted. Duplicate extras are deleted at once.
type GC struct {
Client client.Client
Kuma kumaclient.Client
Interval time.Duration
// OrphanGrace is how long a monitor must have been seen orphaned.
OrphanGrace time.Duration
// Cache is waited on before the first run; nil skips the wait.
Cache CacheSyncer
// APIReader reads straight from the API server. Every orphan is confirmed
// against it before it is deleted.
APIReader client.Reader
// Now is the clock; nil means time.Now.
Now func() time.Time
mu sync.Mutex
pending map[int64]orphanSeen
}
type orphanSeen struct {
since time.Time
runs int
}
var (
_ manager.Runnable = (*GC)(nil)
_ manager.LeaderElectionRunnable = (*GC)(nil)
)
// NeedLeaderElection is false: there is a single replica, no election.
func (g *GC) NeedLeaderElection() bool { return false }
func (g *GC) now() time.Time {
if g.Now != nil {
return g.Now()
}
return time.Now()
}
func (g *GC) Start(ctx context.Context) error {
logger := log.FromContext(ctx).WithName("gc")
if g.Cache != nil && !g.Cache.WaitForCacheSync(ctx) {
if ctx.Err() != nil {
return nil
}
return errors.New("gc: cache did not sync")
}
interval := g.Interval
if interval <= 0 {
interval = 5 * time.Minute
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
if err := g.RunOnce(ctx); err != nil {
if errors.Is(err, errListFailed) {
logger.Error(err, "gc run aborted")
} else {
logger.Error(err, "gc run finished with errors")
}
}
select {
case <-ctx.Done():
return nil
case <-ticker.C:
}
}
}
// existsInAPI asks the API server, not the cache, whether the resource behind
// an owner key exists. A malformed key is an error, never "absent".
func (g *GC) existsInAPI(ctx context.Context, ownerKey string) (bool, error) {
ns, name, ok := strings.Cut(ownerKey, "/")
if !ok || ns == "" || name == "" || strings.Contains(name, "/") {
return false, fmt.Errorf("malformed owner key %q", ownerKey)
}
err := g.APIReader.Get(ctx, types.NamespacedName{Namespace: ns, Name: name}, &v1.KumaMonitor{})
switch {
case err == nil:
return true, nil
case apierrors.IsNotFound(err):
return false, nil
default:
return false, err
}
}
// namespaceExists reports whether the namespace of an owner key exists.
func (g *GC) namespaceExists(ctx context.Context, ownerKey string) (bool, error) {
ns, _, _ := strings.Cut(ownerKey, "/")
err := g.APIReader.Get(ctx, types.NamespacedName{Name: ns}, &corev1.Namespace{})
switch {
case err == nil:
return true, nil
case apierrors.IsNotFound(err):
return false, nil
default:
return false, err
}
}
// RunOnce performs one collection pass.
func (g *GC) RunOnce(ctx context.Context) error {
logger := log.FromContext(ctx).WithName("gc")
if g.APIReader == nil {
return errors.New("gc: APIReader is required to confirm orphans")
}
g.mu.Lock()
defer g.mu.Unlock()
if g.pending == nil {
g.pending = map[int64]orphanSeen{}
}
// Kuma list must precede the cache list.
owned, err := g.Kuma.ListOwned(ctx)
if err != nil {
clear(g.pending)
return fmt.Errorf("%w: list owned monitors: %w", errListFailed, err)
}
var resources v1.KumaMonitorList
if err := g.Client.List(ctx, &resources); err != nil {
clear(g.pending)
return fmt.Errorf("%w: list KumaMonitors: %w", errListFailed, err)
}
live := make(map[string]bool, len(resources.Items))
for i := range resources.Items {
live[OwnerKey(&resources.Items[i])] = true
}
now := g.now()
slices.SortFunc(owned, func(a, b kumaclient.Monitor) int { return int(a.ID - b.ID) })
seen := map[string]bool{}
candidates := map[int64]bool{}
var errs []error
del := func(m kumaclient.Monitor, reason string) bool {
logger.Info("deleting monitor", "monitorID", m.ID, "ownerKey", m.OwnerKey, "reason", reason)
if err := g.Kuma.Delete(ctx, m.ID); err != nil && !errors.Is(err, kumaclient.ErrNotFound) {
errs = append(errs, fmt.Errorf("delete monitor %d (%s): %w", m.ID, m.OwnerKey, err))
return false
}
return true
}
for _, m := range owned {
keep := live[m.OwnerKey] && !seen[m.OwnerKey]
seen[m.OwnerKey] = true
if keep {
continue
}
if live[m.OwnerKey] {
del(m, "duplicate of a lower monitor ID")
continue
}
exists, err := g.existsInAPI(ctx, m.OwnerKey)
if err != nil {
errs = append(errs, fmt.Errorf("confirm orphan monitor %d (%s): %w", m.ID, m.OwnerKey, err))
continue
}
if exists {
logger.Info("KumaMonitor exists on the API server, not an orphan", "monitorID", m.ID, "ownerKey", m.OwnerKey)
continue
}
nsOK, err := g.namespaceExists(ctx, m.OwnerKey)
if err != nil {
errs = append(errs, fmt.Errorf("check namespace of monitor %d (%s): %w", m.ID, m.OwnerKey, err))
continue
}
if !nsOK {
logger.Info("namespace does not exist, leaving the monitor alone", "monitorID", m.ID, "ownerKey", m.OwnerKey)
continue
}
candidates[m.ID] = true
st, ok := g.pending[m.ID]
if !ok {
st.since = now
}
st.runs++
g.pending[m.ID] = st
if st.runs < minOrphanRuns || now.Sub(st.since) < g.OrphanGrace {
logger.Info("orphan candidate, waiting out the grace period", "monitorID", m.ID, "ownerKey", m.OwnerKey,
"runs", st.runs, "orphanFor", now.Sub(st.since).String(), "grace", g.OrphanGrace.String())
continue
}
if del(m, "no matching KumaMonitor") {
delete(g.pending, m.ID)
}
}
// Whatever stopped being an orphan, or vanished, loses its history.
for id := range g.pending {
if !candidates[id] {
delete(g.pending, id)
}
}
return errors.Join(errs...)
}
@@ -0,0 +1,396 @@
package controller
import (
"context"
"errors"
"fmt"
"sync/atomic"
"testing"
"time"
corev1 "k8s.io/api/core/v1"
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/client/fake"
"sigs.k8s.io/controller-runtime/pkg/client/interceptor"
v1 "gitea.renner.uno/k.renner/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/k.renner/homelab/kuma-sync/internal/kumaclient"
)
type gcEnv struct {
gc *GC
kuma *kumaclient.Fake
cnt *counting
}
func newGCEnv(t *testing.T, funcs *interceptor.Funcs, objs ...client.Object) *gcEnv {
t.Helper()
s := gcScheme(t)
b := fake.NewClientBuilder().WithScheme(s).WithObjects(append(objs, namespace("default"))...)
if funcs != nil {
b = b.WithInterceptorFuncs(*funcs)
}
kf := kumaclient.NewFake()
cnt := &counting{Client: kf}
c := b.Build()
return &gcEnv{gc: &GC{Client: c, APIReader: c, Kuma: cnt, Interval: time.Hour}, kuma: kf, cnt: cnt}
}
func gcScheme(t *testing.T) *runtime.Scheme {
t.Helper()
s := runtime.NewScheme()
if err := v1.AddToScheme(s); err != nil {
t.Fatal(err)
}
if err := corev1.AddToScheme(s); err != nil {
t.Fatal(err)
}
return s
}
func namespace(name string) *corev1.Namespace {
return &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: name}}
}
// apiServerWith is a fake API server that always has the default namespace.
func apiServerWith(t *testing.T, objs ...client.Object) client.Reader {
t.Helper()
return fake.NewClientBuilder().WithScheme(gcScheme(t)).WithObjects(append(objs, namespace("default"))...).Build()
}
// runs calls RunOnce n times and returns the last error.
func (e *gcEnv) runs(t *testing.T, n int) error {
t.Helper()
var err error
for range n {
err = e.gc.RunOnce(t.Context())
}
return err
}
func TestGCConfirmsWithAPIReaderBeforeOrphanDelete(t *testing.T) {
e := newGCEnv(t, nil) // stale cache: knows no resources
id := owned(t, e, "default/a")
e.gc.APIReader = apiServerWith(t, httpMonitor("a"))
if err := e.gc.RunOnce(t.Context()); err != nil {
t.Fatal(err)
}
if e.kuma.Get(id) == nil || e.cnt.deletes != 0 {
t.Error("monitor of a resource that exists on the API server was deleted")
}
// Truly absent on the API server: deleted.
e.gc.APIReader = apiServerWith(t)
if err := e.runs(t, 2); err != nil {
t.Fatal(err)
}
if e.kuma.Get(id) != nil {
t.Error("confirmed orphan survived")
}
}
func TestGCSkipsOnAPIReaderError(t *testing.T) {
e := newGCEnv(t, nil)
id := owned(t, e, "default/gone")
s := runtime.NewScheme()
if err := v1.AddToScheme(s); err != nil {
t.Fatal(err)
}
e.gc.APIReader = fake.NewClientBuilder().WithScheme(s).WithInterceptorFuncs(interceptor.Funcs{
Get: func(context.Context, client.WithWatch, client.ObjectKey, client.Object, ...client.GetOption) error {
return errors.New("apiserver unavailable")
},
}).Build()
if err := e.gc.RunOnce(t.Context()); err == nil {
t.Fatal("expected the API reader error to be recorded")
}
if e.kuma.Get(id) == nil || e.cnt.deletes != 0 {
t.Error("deleted without confirmation")
}
}
func TestGCMalformedOwnerKeyNeverDeleted(t *testing.T) {
e := newGCEnv(t, nil)
var ids []int64
for _, key := range []string{"noslash", "/name", "ns/", "a/b/c"} {
ids = append(ids, owned(t, e, key))
}
if err := e.gc.RunOnce(t.Context()); err == nil {
t.Fatal("expected errors for malformed keys")
}
for _, id := range ids {
if e.kuma.Get(id) == nil {
t.Errorf("monitor %d with a malformed key was deleted", id)
}
}
}
func owned(t *testing.T, e *gcEnv, key string) int64 {
t.Helper()
id, err := e.kuma.Create(t.Context(), kumaclient.Monitor{Type: "group", Name: key, OwnerKey: key})
if err != nil {
t.Fatal(err)
}
return id
}
func TestGCDeletesOrphans(t *testing.T) {
e := newGCEnv(t, nil, httpMonitor("live"))
live := owned(t, e, "default/live")
gone := owned(t, e, "default/gone")
if err := e.runs(t, 2); err != nil {
t.Fatal(err)
}
if e.kuma.Get(gone) != nil {
t.Error("orphan survived")
}
if e.kuma.Get(live) == nil {
t.Error("live monitor deleted")
}
}
func TestGCIgnoresUntagged(t *testing.T) {
e := newGCEnv(t, nil)
foreign := e.kuma.AddForeign(kumaclient.Monitor{Name: "foreign"})
if err := e.gc.RunOnce(t.Context()); err != nil {
t.Fatal(err)
}
if e.kuma.Get(foreign) == nil || e.cnt.deletes != 0 {
t.Errorf("foreign monitor touched: deletes=%d", e.cnt.deletes)
}
}
func TestGCKeepsLiveResources(t *testing.T) {
e := newGCEnv(t, nil, httpMonitor("a"), httpMonitor("b"))
owned(t, e, "default/a")
owned(t, e, "default/b")
if err := e.gc.RunOnce(t.Context()); err != nil {
t.Fatal(err)
}
if e.cnt.deletes != 0 {
t.Errorf("deletes = %d", e.cnt.deletes)
}
}
func TestGCKeepsResourcesBeingDeleted(t *testing.T) {
e := newGCEnv(t, nil, deleting(httpMonitor("a")))
owned(t, e, "default/a")
if err := e.gc.RunOnce(t.Context()); err != nil {
t.Fatal(err)
}
if e.cnt.deletes != 0 {
t.Errorf("the finalizer path owns this deletion, deletes = %d", e.cnt.deletes)
}
}
func TestGCAbortsOnListError(t *testing.T) {
e := newGCEnv(t, &interceptor.Funcs{
List: func(context.Context, client.WithWatch, client.ObjectList, ...client.ListOption) error {
return errors.New("cache not ready")
},
})
owned(t, e, "default/gone")
if err := e.gc.RunOnce(t.Context()); err == nil {
t.Fatal("expected error")
}
if e.cnt.deletes != 0 {
t.Errorf("deletes = %d, want 0", e.cnt.deletes)
}
}
func TestGCAbortsOnKumaListError(t *testing.T) {
e := newGCEnv(t, nil)
owned(t, e, "default/gone")
e.kuma.SetErrs(errors.New("boom"), nil, nil, nil)
if err := e.gc.RunOnce(t.Context()); err == nil {
t.Fatal("expected error")
}
if e.cnt.deletes != 0 {
t.Errorf("deletes = %d, want 0", e.cnt.deletes)
}
}
func TestGCNoDeletesOnPartialList(t *testing.T) {
e := newGCEnv(t, nil)
owned(t, e, "default/gone")
e.kuma.SetListPartial(errors.Join(&kumaclient.ConflictError{MonitorID: 9, Keys: []string{"default/x", "default/y"}}))
if err := e.gc.RunOnce(t.Context()); err == nil {
t.Fatal("expected error")
}
if e.cnt.deletes != 0 {
t.Errorf("deletes = %d, want 0", e.cnt.deletes)
}
}
func TestGCDeletesDuplicateExtras(t *testing.T) {
e := newGCEnv(t, nil, httpMonitor("a"))
first := owned(t, e, "default/a")
second := owned(t, e, "default/a")
third := owned(t, e, "default/a")
if err := e.gc.RunOnce(t.Context()); err != nil {
t.Fatal(err)
}
if e.kuma.Get(first) == nil {
t.Error("lowest ID must stay")
}
if e.kuma.Get(second) != nil || e.kuma.Get(third) != nil {
t.Error("extras must be deleted")
}
}
func TestGCDeleteNotFoundIsSuccess(t *testing.T) {
e := newGCEnv(t, nil)
owned(t, e, "default/gone")
e.kuma.SetErrs(nil, nil, nil, fmt.Errorf("delete: %w", kumaclient.ErrNotFound))
if err := e.runs(t, 2); err != nil {
t.Fatalf("ErrNotFound must count as success: %v", err)
}
}
// waiter is a CacheSyncer whose result the test controls.
type waiter struct {
release chan struct{}
result bool
started atomic.Bool
}
func (w *waiter) WaitForCacheSync(ctx context.Context) bool {
w.started.Store(true)
select {
case <-w.release:
return w.result
case <-ctx.Done():
return false
}
}
func TestGCWaitsForCacheSync(t *testing.T) {
e := newGCEnv(t, nil)
owned(t, e, "default/gone") // would look like an orphan against an empty cache
w := &waiter{release: make(chan struct{}), result: true}
e.gc.Cache = w
e.gc.Interval = 20 * time.Millisecond
ctx, cancel := context.WithCancel(t.Context())
done := make(chan error, 1)
go func() { done <- e.gc.Start(ctx) }()
deadline := time.Now().Add(5 * time.Second)
for !w.started.Load() && time.Now().Before(deadline) {
time.Sleep(5 * time.Millisecond)
}
time.Sleep(100 * time.Millisecond)
if e.kuma.Get(1) == nil {
t.Fatal("GC ran before the cache synced")
}
close(w.release)
deadline = time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) && e.kuma.Get(1) != nil {
time.Sleep(5 * time.Millisecond)
}
if e.kuma.Get(1) != nil {
t.Error("GC did not run after the cache synced")
}
cancel()
if err := <-done; err != nil {
t.Errorf("Start returned %v on shutdown", err)
}
}
func TestGCOrphanGraceDelaysDelete(t *testing.T) {
e := newGCEnv(t, nil)
id := owned(t, e, "default/gone")
e.gc.OrphanGrace = time.Hour
clock := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
e.gc.Now = func() time.Time { return clock }
for _, step := range []struct {
at time.Duration
deleted bool
}{
{0, false}, // first sighting
{30 * time.Minute, false}, // second run, grace not over
{59 * time.Minute, false}, // still inside the grace period
{60 * time.Minute, true}, // third run, a full hour after the first sighting
} {
clock = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC).Add(step.at)
if err := e.gc.RunOnce(t.Context()); err != nil {
t.Fatal(err)
}
if gone := e.kuma.Get(id) == nil; gone != step.deleted {
t.Fatalf("at +%v: deleted = %v, want %v", step.at, gone, step.deleted)
}
}
}
func TestGCOrphanGraceResetsWhenResourceReturns(t *testing.T) {
e := newGCEnv(t, nil)
id := owned(t, e, "default/a")
e.gc.OrphanGrace = time.Hour
base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
var clock time.Time
e.gc.Now = func() time.Time { return clock }
run := func(at time.Duration) {
t.Helper()
clock = base.Add(at)
if err := e.gc.RunOnce(t.Context()); err != nil {
t.Fatal(err)
}
}
run(0) // orphan seen
km := httpMonitor("a")
if err := e.gc.Client.Create(t.Context(), km); err != nil {
t.Fatal(err)
}
run(40 * time.Minute) // resource is back: history dropped
if err := e.gc.Client.Delete(t.Context(), km); err != nil {
t.Fatal(err)
}
run(61 * time.Minute) // orphan again, first sighting of the new streak
run(90 * time.Minute) // only 29 minutes into the new streak
if e.kuma.Get(id) == nil {
t.Fatal("deleted although the grace period restarted when the resource returned")
}
run(122 * time.Minute) // 61 minutes into the new streak
if e.kuma.Get(id) != nil {
t.Error("orphan survived a full grace period")
}
}
func TestGCSkipsMissingNamespace(t *testing.T) {
e := newGCEnv(t, nil)
id := owned(t, e, "ghost/x")
if err := e.runs(t, 3); err != nil {
t.Fatal(err)
}
if e.kuma.Get(id) == nil || e.cnt.deletes != 0 {
t.Fatal("monitor of a missing namespace was deleted")
}
if err := e.gc.Client.Create(t.Context(), namespace("ghost")); err != nil {
t.Fatal(err)
}
if err := e.runs(t, 2); err != nil {
t.Fatal(err)
}
if e.kuma.Get(id) != nil {
t.Error("orphan in an existing namespace survived")
}
}
func TestGCStartFailsWhenCacheNeverSyncs(t *testing.T) {
e := newGCEnv(t, nil)
owned(t, e, "default/gone")
released := make(chan struct{})
close(released)
e.gc.Cache = &waiter{release: released, result: false}
if err := e.gc.Start(t.Context()); err == nil {
t.Fatal("expected an error when the cache does not sync")
}
if e.cnt.deletes != 0 {
t.Error("GC deleted without a synced cache")
}
}
@@ -0,0 +1,327 @@
// Package controller reconciles KumaMonitor resources into Uptime Kuma.
package controller
import (
"context"
"errors"
"fmt"
"reflect"
"slices"
"time"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"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/log"
"sigs.k8s.io/controller-runtime/pkg/predicate"
v1 "gitea.renner.uno/k.renner/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/k.renner/homelab/kuma-sync/internal/kumaclient"
)
const (
Finalizer = "uptime.renner.uno/kuma-sync"
ReasonSynced = "Synced"
ReasonParentPending = "ParentPending"
ReasonInvalidParent = "InvalidParent"
ReasonOwnerTagMissing = "OwnerTagMissing"
ReasonOwnerConflict = "OwnerConflict"
ReasonDuplicateOwner = "DuplicateOwner"
ReasonKumaUnavailable = "KumaUnavailable"
ReasonPossibleOrphan = "PossibleOrphan"
ReasonNotOwned = "NotOwned"
parentRetry = 10 * time.Second
resyncPeriod = 5 * time.Minute
maxParentDepth = 10
)
// +kubebuilder:rbac:groups=uptime.renner.uno,resources=kumamonitors,verbs=get;list;watch;update;patch
// +kubebuilder:rbac:groups=uptime.renner.uno,resources=kumamonitors/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=uptime.renner.uno,resources=kumamonitors/finalizers,verbs=update
// +kubebuilder:rbac:groups="",resources=events,verbs=create;patch
// Reconciler syncs KumaMonitor resources to Kuma.
type Reconciler struct {
Client client.Client
Kuma kumaclient.Client
// Resync is how often a healthy resource is reconciled again, which repairs
// drift made in Kuma. 0 means 5 minutes.
Resync time.Duration
}
func (r *Reconciler) resync() time.Duration {
if r.Resync > 0 {
return r.Resync
}
return resyncPeriod
}
// SetupWithManager ignores status-only updates, which keeps failure backoff
// intact. Deletion still reconciles: setting deletionTimestamp bumps
// metadata.generation.
func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&v1.KumaMonitor{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})).
Named("kumamonitor").
Complete(r)
}
// failure is a Ready=False outcome. The message is fixed per reason; the cause
// goes to the log and the returned error only.
type failure struct {
reason string
message string
err error
}
const (
msgKumaUnavailable = "Kuma request failed"
msgListIncomplete = "monitor list incomplete: owner conflict on another monitor"
msgOwnerTagMissing = "kuma-sync tag missing in Kuma"
msgOwnerConflict = "monitor carries conflicting kuma-sync tag values"
msgPossibleOrphan = "create outcome unknown, an untagged monitor may exist in Kuma"
msgDuplicateOwner = "several monitors carry this owner key; syncing the lowest ID"
msgParentPending = "parent has no monitor ID yet"
msgParentNotGroup = "parent must be of type group"
msgParentCycle = "parentRef forms a cycle"
msgParentTooDeep = "parentRef chain is too deep"
msgSynced = "monitor in sync"
msgNotOwned = "previous monitor exists without our ownership tag; restore the tag or delete it in Kuma"
)
func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
km := &v1.KumaMonitor{}
if err := r.Client.Get(ctx, req.NamespacedName, km); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
if !km.DeletionTimestamp.IsZero() {
return r.reconcileDelete(ctx, km)
}
if controllerutil.AddFinalizer(km, Finalizer) {
if err := r.Client.Update(ctx, km); err != nil {
return ctrl.Result{}, err
}
}
desired := Desired(km)
if km.Spec.ParentRef != nil {
p, err := r.resolveParent(ctx, km)
if err != nil {
return ctrl.Result{}, err
}
if p.reason != "" {
if err := r.setReady(ctx, km, metav1.ConditionFalse, p.reason, p.message, nil); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{RequeueAfter: p.requeue}, nil
}
desired.Parent = &p.id
}
res, fail := r.lookup(ctx, desired.OwnerKey)
if fail != nil {
return r.fail(ctx, km, fail)
}
var id int64
if res.mon == nil {
// The monitor this resource synced before may have lost its tag; a
// second one would then sit beside it.
if prev := km.Status.MonitorID; prev != 0 {
found, key, err := r.Kuma.Probe(ctx, prev)
switch {
case err != nil:
return r.fail(ctx, km, &failure{ReasonKumaUnavailable, msgKumaUnavailable, err})
case found && key != desired.OwnerKey:
log.FromContext(ctx).Error(nil, "previous monitor exists without our ownership tag, not creating another",
"ownerKey", desired.OwnerKey, "monitorID", prev, "tagValue", key)
err := r.setReady(ctx, km, metav1.ConditionFalse, ReasonNotOwned, msgNotOwned, nil)
return ctrl.Result{RequeueAfter: r.resync()}, err
case found:
return r.fail(ctx, km, &failure{ReasonKumaUnavailable, msgKumaUnavailable,
fmt.Errorf("monitor %d carries our key but is missing from the owned list", prev)})
}
}
var err error
id, err = r.Kuma.Create(ctx, desired)
if err != nil {
if errors.Is(err, kumaclient.ErrPossibleOrphan) {
log.FromContext(ctx).Error(err, "create outcome unknown, an untagged monitor may exist in Kuma",
"ownerKey", desired.OwnerKey)
return r.fail(ctx, km, &failure{ReasonPossibleOrphan, msgPossibleOrphan, err})
}
return r.fail(ctx, km, &failure{ReasonKumaUnavailable, msgKumaUnavailable, err})
}
} else {
id = res.mon.ID
desired.ID = id
if !reflect.DeepEqual(desired.Normalized(), *res.mon) {
if err := r.Kuma.Update(ctx, desired); err != nil {
return r.fail(ctx, km, &failure{ReasonKumaUnavailable, msgKumaUnavailable, err})
}
}
}
if len(res.ids) > 1 {
log.FromContext(ctx).Error(nil, "several Kuma monitors carry the same owner key, syncing the lowest ID",
"ownerKey", desired.OwnerKey, "monitorIDs", res.ids)
err := r.setReady(ctx, km, metav1.ConditionFalse, ReasonDuplicateOwner, msgDuplicateOwner, &id)
return ctrl.Result{RequeueAfter: r.resync()}, err
}
if err := r.setReady(ctx, km, metav1.ConditionTrue, ReasonSynced, msgSynced, &id); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{RequeueAfter: r.resync()}, nil
}
func (r *Reconciler) reconcileDelete(ctx context.Context, km *v1.KumaMonitor) (ctrl.Result, error) {
if !controllerutil.ContainsFinalizer(km, Finalizer) {
return ctrl.Result{}, nil
}
res, fail := r.lookup(ctx, OwnerKey(km))
if fail != nil {
return r.fail(ctx, km, fail)
}
// Remove every monitor carrying the key, duplicates included.
for _, id := range res.ids {
if err := r.Kuma.Delete(ctx, id); err != nil && !errors.Is(err, kumaclient.ErrNotFound) {
return r.fail(ctx, km, &failure{ReasonKumaUnavailable, msgKumaUnavailable, err})
}
}
controllerutil.RemoveFinalizer(km, Finalizer)
return ctrl.Result{}, r.Client.Update(ctx, km)
}
type lookupResult struct {
mon *kumaclient.Monitor // lowest ID carrying the key
ids []int64 // all IDs carrying the key, ascending
}
// lookup finds the monitors owned by key. A failed or incomplete list is a
// failure, never "absent": the one exception is a conflict on other monitors
// while the partial list already holds this key, which may be adopted but
// never leads to a create.
func (r *Reconciler) lookup(ctx context.Context, key string) (lookupResult, *failure) {
list, err := r.Kuma.ListOwned(ctx)
var res lookupResult
for i := range list {
if list[i].OwnerKey != key {
continue
}
res.ids = append(res.ids, list[i].ID)
if res.mon == nil || list[i].ID < res.mon.ID {
res.mon = &list[i]
}
}
slices.Sort(res.ids)
if err == nil {
return res, nil
}
if errors.Is(err, kumaclient.ErrOwnerTagMissing) {
return res, &failure{ReasonOwnerTagMissing, msgOwnerTagMissing, err}
}
conflicts, only := kumaclient.Conflicts(err)
for _, c := range conflicts {
if slices.Contains(c.Keys, key) {
return res, &failure{ReasonOwnerConflict, msgOwnerConflict, err}
}
}
if !only {
return res, &failure{ReasonKumaUnavailable, msgKumaUnavailable, err}
}
if res.mon != nil {
log.FromContext(ctx).Error(err, "owner conflict on another monitor, updating the monitor already found",
"ownerKey", key)
return res, nil
}
return res, &failure{ReasonKumaUnavailable, msgListIncomplete, err}
}
type parentOutcome struct {
id int64
reason string // empty: resolved
message string
requeue time.Duration
}
// resolveParent checks the whole parentRef chain: every ancestor must exist
// and be a group, without cycles or more than maxParentDepth levels.
func (r *Reconciler) resolveParent(ctx context.Context, km *v1.KumaMonitor) (parentOutcome, error) {
invalid := func(msg string) (parentOutcome, error) {
return parentOutcome{reason: ReasonInvalidParent, message: msg, requeue: r.resync()}, nil
}
pending := parentOutcome{reason: ReasonParentPending, message: msgParentPending, requeue: parentRetry}
visited := map[string]bool{km.Name: true}
cur := km
var directID int64
for depth := 0; cur.Spec.ParentRef != nil; depth++ {
if depth >= maxParentDepth {
return invalid(msgParentTooDeep)
}
name := cur.Spec.ParentRef.Name
if visited[name] {
return invalid(msgParentCycle)
}
visited[name] = true
parent := &v1.KumaMonitor{}
err := r.Client.Get(ctx, types.NamespacedName{Namespace: km.Namespace, Name: name}, parent)
if apierrors.IsNotFound(err) {
return pending, nil
}
if err != nil {
return parentOutcome{}, err
}
if parent.Spec.Type != "group" {
return invalid(msgParentNotGroup)
}
if depth == 0 {
directID = parent.Status.MonitorID
}
cur = parent
}
if directID == 0 {
return pending, nil
}
return parentOutcome{id: directID}, nil
}
// fail records a Ready=False outcome and returns its cause so the request is
// requeued with backoff.
func (r *Reconciler) fail(ctx context.Context, km *v1.KumaMonitor, f *failure) (ctrl.Result, error) {
log.FromContext(ctx).Info("reconcile failed", "reason", f.reason, "error", f.err.Error())
if serr := r.setReady(ctx, km, 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 monitor ID when given, through
// the status subresource. It writes nothing if the status is unchanged.
func (r *Reconciler) setReady(
ctx context.Context, km *v1.KumaMonitor, status metav1.ConditionStatus, reason, msg string, monitorID *int64,
) error {
orig := km.Status.DeepCopy()
if monitorID != nil {
km.Status.MonitorID = *monitorID
km.Status.ObservedGeneration = km.Generation
}
meta.SetStatusCondition(&km.Status.Conditions, metav1.Condition{
Type: v1.ConditionReady,
Status: status,
Reason: reason,
Message: msg,
ObservedGeneration: km.Generation,
})
if reflect.DeepEqual(*orig, km.Status) {
return nil
}
return r.Client.Status().Update(ctx, km)
}
@@ -0,0 +1,615 @@
package controller
import (
"context"
"errors"
"fmt"
"testing"
"time"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"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"
v1 "gitea.renner.uno/k.renner/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/k.renner/homelab/kuma-sync/internal/kumaclient"
)
// counting records calls on top of the Kuma fake.
type counting struct {
kumaclient.Client
creates, updates, deletes int
}
func (c *counting) Create(ctx context.Context, m kumaclient.Monitor) (int64, error) {
c.creates++
return c.Client.Create(ctx, m)
}
func (c *counting) Update(ctx context.Context, m kumaclient.Monitor) error {
c.updates++
return c.Client.Update(ctx, m)
}
func (c *counting) Delete(ctx context.Context, id int64) error {
c.deletes++
return c.Client.Delete(ctx, id)
}
type env struct {
r *Reconciler
k8s client.Client
kuma *kumaclient.Fake
cnt *counting
}
func newEnv(t *testing.T, objs ...client.Object) *env {
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{}).Build()
kf := kumaclient.NewFake()
cnt := &counting{Client: kf}
return &env{r: &Reconciler{Client: k8s, Kuma: cnt}, k8s: k8s, kuma: kf, cnt: cnt}
}
func httpMonitor(name string) *v1.KumaMonitor {
return &v1.KumaMonitor{
ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: name},
Spec: v1.KumaMonitorSpec{
Type: "http", DisplayName: name, Interval: 60, RetryInterval: 60,
HTTP: &v1.HTTPSpec{URL: "https://example.com"},
},
}
}
func (e *env) 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 *env) get(t *testing.T, name string) *v1.KumaMonitor {
t.Helper()
km := &v1.KumaMonitor{}
if err := e.k8s.Get(t.Context(), types.NamespacedName{Namespace: "default", Name: name}, km); err != nil {
t.Fatal(err)
}
return km
}
func ready(t *testing.T, km *v1.KumaMonitor) *metav1.Condition {
t.Helper()
c := meta.FindStatusCondition(km.Status.Conditions, v1.ConditionReady)
if c == nil {
t.Fatal("no Ready condition")
}
return c
}
func TestReconcileCreates(t *testing.T) {
e := newEnv(t, httpMonitor("a"))
res, err := e.reconcile(t, "a")
if err != nil {
t.Fatal(err)
}
if res.RequeueAfter != 5*time.Minute {
t.Errorf("RequeueAfter = %v", res.RequeueAfter)
}
if e.cnt.creates != 1 {
t.Fatalf("creates = %d", e.cnt.creates)
}
km := e.get(t, "a")
if km.Status.MonitorID == 0 || km.Status.ObservedGeneration != km.Generation {
t.Errorf("status = %+v", km.Status)
}
if c := ready(t, km); c.Status != metav1.ConditionTrue {
t.Errorf("Ready = %+v", c)
}
if len(km.Finalizers) != 1 {
t.Errorf("finalizers = %v", km.Finalizers)
}
if got := e.kuma.Get(km.Status.MonitorID); got == nil || got.OwnerKey != "default/a" {
t.Errorf("kuma monitor = %+v", got)
}
}
func TestReconcileAdoptsExisting(t *testing.T) {
km := httpMonitor("a")
e := newEnv(t, km)
existing := Desired(km)
id, err := e.kuma.Create(t.Context(), existing)
if err != nil {
t.Fatal(err)
}
if _, err := e.reconcile(t, "a"); err != nil {
t.Fatal(err)
}
if e.cnt.creates != 0 || e.cnt.updates != 0 {
t.Errorf("creates=%d updates=%d, want 0 and 0", e.cnt.creates, e.cnt.updates)
}
if got := e.get(t, "a").Status.MonitorID; got != id {
t.Errorf("monitorID = %d, want %d", got, id)
}
}
func TestReconcileUpdatesDrift(t *testing.T) {
km := httpMonitor("a")
e := newEnv(t, km)
drifted := Desired(km)
drifted.Interval = 30
id, _ := e.kuma.Create(t.Context(), drifted)
if _, err := e.reconcile(t, "a"); err != nil {
t.Fatal(err)
}
if e.cnt.updates != 1 || e.cnt.creates != 0 {
t.Fatalf("updates=%d creates=%d", e.cnt.updates, e.cnt.creates)
}
if got := e.kuma.Get(id); got.Interval != 60 {
t.Errorf("interval = %d", got.Interval)
}
}
func TestReconcileNoopWhenEqual(t *testing.T) {
km := httpMonitor("a")
e := newEnv(t, km)
e.kuma.Create(t.Context(), Desired(km))
if _, err := e.reconcile(t, "a"); err != nil {
t.Fatal(err)
}
if e.cnt.updates != 0 || e.cnt.creates != 0 {
t.Fatalf("updates=%d creates=%d", e.cnt.updates, e.cnt.creates)
}
}
func TestReconcileNormalizedNoSpuriousUpdate(t *testing.T) {
km := httpMonitor("a")
km.Spec.HTTP.AcceptedStatusCodes = nil
km.Spec.Timeout = nil
e := newEnv(t, km)
for range 2 {
if _, err := e.reconcile(t, "a"); err != nil {
t.Fatal(err)
}
}
if e.cnt.creates != 1 || e.cnt.updates != 0 {
t.Fatalf("creates=%d updates=%d, want 1 and 0", e.cnt.creates, e.cnt.updates)
}
}
func TestReconcileWaitsForParent(t *testing.T) {
group := &v1.KumaMonitor{
ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "grp"},
Spec: v1.KumaMonitorSpec{Type: "group", DisplayName: "grp", Interval: 60, RetryInterval: 60},
}
child := httpMonitor("child")
child.Spec.ParentRef = &v1.LocalRef{Name: "grp"}
e := newEnv(t, group, child)
for _, step := range []string{"parent without monitorID", "missing parent"} {
if step == "missing parent" {
if err := e.k8s.Delete(t.Context(), group); err != nil {
t.Fatal(err)
}
}
res, err := e.reconcile(t, "child")
if err != nil {
t.Fatalf("%s: %v", step, err)
}
if res.RequeueAfter != 10*time.Second {
t.Errorf("%s: RequeueAfter = %v", step, res.RequeueAfter)
}
if c := ready(t, e.get(t, "child")); c.Status != metav1.ConditionFalse || c.Reason != ReasonParentPending {
t.Errorf("%s: Ready = %+v", step, c)
}
}
if e.cnt.creates != 0 {
t.Errorf("creates = %d", e.cnt.creates)
}
}
func TestReconcileResolvesParent(t *testing.T) {
group := &v1.KumaMonitor{
ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "grp"},
Spec: v1.KumaMonitorSpec{Type: "group", DisplayName: "grp", Interval: 60, RetryInterval: 60},
Status: v1.KumaMonitorStatus{MonitorID: 42},
}
child := httpMonitor("child")
child.Spec.ParentRef = &v1.LocalRef{Name: "grp"}
e := newEnv(t, group, child)
if _, err := e.reconcile(t, "child"); err != nil {
t.Fatal(err)
}
got := e.kuma.Get(e.get(t, "child").Status.MonitorID)
if got == nil || got.Parent == nil || *got.Parent != 42 {
t.Fatalf("parent = %+v", got)
}
}
func deleting(km *v1.KumaMonitor) *v1.KumaMonitor {
now := metav1.Now()
km.DeletionTimestamp = &now
km.Finalizers = []string{Finalizer}
return km
}
func TestReconcileDeleteRemovesMonitorAndFinalizer(t *testing.T) {
km := deleting(httpMonitor("a"))
e := newEnv(t, km)
id, _ := e.kuma.Create(t.Context(), Desired(km))
if _, err := e.reconcile(t, "a"); err != nil {
t.Fatal(err)
}
if e.kuma.Get(id) != nil {
t.Error("kuma monitor not deleted")
}
err := e.k8s.Get(t.Context(), client.ObjectKeyFromObject(km), &v1.KumaMonitor{})
if !apierrors.IsNotFound(err) {
t.Errorf("resource should be gone after finalizer removal, got %v", err)
}
}
func TestReconcileDeleteToleratesMissingMonitor(t *testing.T) {
km := deleting(httpMonitor("a"))
e := newEnv(t, km)
if _, err := e.reconcile(t, "a"); err != nil {
t.Fatal(err)
}
if e.cnt.deletes != 0 {
t.Errorf("deletes = %d", e.cnt.deletes)
}
err := e.k8s.Get(t.Context(), client.ObjectKeyFromObject(km), &v1.KumaMonitor{})
if !apierrors.IsNotFound(err) {
t.Errorf("finalizer not removed: %v", err)
}
}
func TestReconcileDeleteToleratesNotFoundFromKuma(t *testing.T) {
km := deleting(httpMonitor("a"))
e := newEnv(t, km)
e.kuma.Create(t.Context(), Desired(km))
e.kuma.SetErrs(nil, nil, nil, fmt.Errorf("delete 1: %w", kumaclient.ErrNotFound))
if _, err := e.reconcile(t, "a"); err != nil {
t.Fatalf("ErrNotFound from Delete must count as success: %v", err)
}
if err := e.k8s.Get(t.Context(), client.ObjectKeyFromObject(km), &v1.KumaMonitor{}); !apierrors.IsNotFound(err) {
t.Errorf("finalizer not removed: %v", err)
}
}
func TestReconcileDeleteNotOwnedIsError(t *testing.T) {
km := deleting(httpMonitor("a"))
e := newEnv(t, km)
e.kuma.Create(t.Context(), Desired(km))
e.kuma.SetErrs(nil, nil, nil, fmt.Errorf("delete 1: %w", kumaclient.ErrNotOwned))
if _, err := e.reconcile(t, "a"); !errors.Is(err, kumaclient.ErrNotOwned) {
t.Fatalf("want ErrNotOwned, got %v", err)
}
if err := e.k8s.Get(t.Context(), client.ObjectKeyFromObject(km), &v1.KumaMonitor{}); err != nil {
t.Errorf("finalizer must stay: %v", err)
}
}
func TestReconcileListErrorNoCreate(t *testing.T) {
e := newEnv(t, httpMonitor("a"))
e.kuma.SetErrs(errors.New("boom"), nil, nil, nil)
if _, err := e.reconcile(t, "a"); err == nil {
t.Fatal("expected error")
}
if e.cnt.creates != 0 || e.cnt.updates != 0 {
t.Errorf("creates=%d updates=%d", e.cnt.creates, e.cnt.updates)
}
if c := ready(t, e.get(t, "a")); c.Status != metav1.ConditionFalse || c.Reason != ReasonKumaUnavailable {
t.Errorf("Ready = %+v", c)
}
}
func TestReconcileOwnerTagMissing(t *testing.T) {
e := newEnv(t, httpMonitor("a"))
e.kuma.SetErrs(kumaclient.ErrOwnerTagMissing, nil, nil, nil)
if _, err := e.reconcile(t, "a"); !errors.Is(err, kumaclient.ErrOwnerTagMissing) {
t.Fatalf("err = %v", err)
}
if e.cnt.creates != 0 {
t.Errorf("creates = %d", e.cnt.creates)
}
if c := ready(t, e.get(t, "a")); c.Reason != ReasonOwnerTagMissing {
t.Errorf("Ready = %+v", c)
}
}
func TestReconcileOwnerConflict(t *testing.T) {
e := newEnv(t, httpMonitor("a"))
// Three keys, ours last: a first-two-values message would miss it.
e.kuma.SetListPartial(errors.Join(&kumaclient.ConflictError{MonitorID: 3, Keys: []string{"default/other", "default/x", "default/a"}}))
if _, err := e.reconcile(t, "a"); !errors.Is(err, kumaclient.ErrOwnerConflict) {
t.Fatalf("err = %v", err)
}
if e.cnt.creates != 0 || e.cnt.updates != 0 {
t.Errorf("creates=%d updates=%d", e.cnt.creates, e.cnt.updates)
}
if c := ready(t, e.get(t, "a")); c.Reason != ReasonOwnerConflict {
t.Errorf("Ready = %+v", c)
}
}
func otherConflict() error {
return errors.Join(&kumaclient.ConflictError{MonitorID: 3, Keys: []string{"default/x", "default/y"}})
}
func TestReconcileConflictOnOtherMonitorNeverCreates(t *testing.T) {
e := newEnv(t, httpMonitor("a"))
e.kuma.SetListPartial(otherConflict())
if _, err := e.reconcile(t, "a"); !errors.Is(err, kumaclient.ErrOwnerConflict) {
t.Fatalf("err = %v", err)
}
if e.cnt.creates != 0 {
t.Errorf("creates = %d, want 0", e.cnt.creates)
}
c := ready(t, e.get(t, "a"))
if c.Status != metav1.ConditionFalse || c.Reason != ReasonKumaUnavailable ||
c.Message != "monitor list incomplete: owner conflict on another monitor" {
t.Errorf("Ready = %+v", c)
}
}
func TestReconcileConflictOnOtherMonitorAdoptsFoundByID(t *testing.T) {
km := httpMonitor("a")
e := newEnv(t, km)
drifted := Desired(km)
drifted.Interval = 30
id, _ := e.kuma.Create(t.Context(), drifted)
e.kuma.SetListPartial(otherConflict())
if _, err := e.reconcile(t, "a"); err != nil {
t.Fatal(err)
}
if e.cnt.creates != 0 || e.cnt.updates != 1 {
t.Fatalf("creates=%d updates=%d, want 0 and 1", e.cnt.creates, e.cnt.updates)
}
if got := e.get(t, "a").Status.MonitorID; got != id {
t.Errorf("monitorID = %d, want %d", got, id)
}
}
func TestReconcileFailureMessageIsStable(t *testing.T) {
e := newEnv(t, httpMonitor("a"))
e.kuma.SetErrs(errors.New("boom 1"), nil, nil, nil)
e.reconcile(t, "a")
first := e.get(t, "a")
e.kuma.SetErrs(errors.New("boom 2"), nil, nil, nil)
e.reconcile(t, "a")
second := e.get(t, "a")
if ready(t, first).Message != ready(t, second).Message || first.ResourceVersion != second.ResourceVersion {
t.Errorf("failure rewrote status: %q vs %q (rv %s vs %s)",
ready(t, first).Message, ready(t, second).Message, first.ResourceVersion, second.ResourceVersion)
}
}
func TestReconcileFinalizerSurvivesFailedCreate(t *testing.T) {
e := newEnv(t, httpMonitor("a"))
e.kuma.SetErrs(nil, errors.New("boom"), nil, nil)
if _, err := e.reconcile(t, "a"); err == nil {
t.Fatal("expected error")
}
if got := e.get(t, "a").Finalizers; len(got) != 1 || got[0] != Finalizer {
t.Errorf("finalizers = %v", got)
}
}
func withPreviousMonitor(id int64) *v1.KumaMonitor {
km := httpMonitor("a")
km.Status.MonitorID = id
return km
}
func TestReconcileRefusesCreateBesideUntaggedPrevious(t *testing.T) {
e := newEnv(t, withPreviousMonitor(1))
e.kuma.AddForeign(kumaclient.Monitor{ID: 1, Name: "was ours"})
res, err := e.reconcile(t, "a")
if err != nil {
t.Fatal(err)
}
if e.cnt.creates != 0 {
t.Fatalf("creates = %d, want 0", e.cnt.creates)
}
if res.RequeueAfter != 5*time.Minute {
t.Errorf("RequeueAfter = %v", res.RequeueAfter)
}
c := ready(t, e.get(t, "a"))
if c.Status != metav1.ConditionFalse || c.Reason != ReasonNotOwned ||
c.Message != "previous monitor exists without our ownership tag; restore the tag or delete it in Kuma" {
t.Errorf("Ready = %+v", c)
}
}
func TestReconcileRefusesCreateBesideOtherOwnersMonitor(t *testing.T) {
e := newEnv(t, withPreviousMonitor(1))
if _, err := e.kuma.Create(t.Context(), kumaclient.Monitor{Name: "x", OwnerKey: "default/other"}); err != nil {
t.Fatal(err)
}
if _, err := e.reconcile(t, "a"); err != nil {
t.Fatal(err)
}
if e.cnt.creates != 0 {
t.Errorf("creates = %d, want 0", e.cnt.creates)
}
if c := ready(t, e.get(t, "a")); c.Reason != ReasonNotOwned {
t.Errorf("Ready = %+v", c)
}
}
func TestReconcileCreatesWhenPreviousMonitorIsGone(t *testing.T) {
e := newEnv(t, withPreviousMonitor(77))
if _, err := e.reconcile(t, "a"); err != nil {
t.Fatal(err)
}
if e.cnt.creates != 1 {
t.Errorf("creates = %d, want 1", e.cnt.creates)
}
}
func TestReconcileProbeErrorNeverCreates(t *testing.T) {
e := newEnv(t, withPreviousMonitor(1))
e.kuma.SetProbeErr(errors.New("boom"))
if _, err := e.reconcile(t, "a"); err == nil {
t.Fatal("expected error")
}
if e.cnt.creates != 0 {
t.Errorf("creates = %d, want 0", e.cnt.creates)
}
if c := ready(t, e.get(t, "a")); c.Reason != ReasonKumaUnavailable {
t.Errorf("Ready = %+v", c)
}
}
func TestReconcileDuplicateOwner(t *testing.T) {
km := httpMonitor("a")
e := newEnv(t, km)
first, _ := e.kuma.Create(t.Context(), Desired(km))
second, _ := e.kuma.Create(t.Context(), Desired(km))
if _, err := e.reconcile(t, "a"); err != nil {
t.Fatal(err)
}
if e.cnt.creates != 0 || e.cnt.deletes != 0 {
t.Errorf("creates=%d deletes=%d, want 0 and 0", e.cnt.creates, e.cnt.deletes)
}
got := e.get(t, "a")
if got.Status.MonitorID != first || first > second {
t.Errorf("monitorID = %d, want lowest %d", got.Status.MonitorID, first)
}
if c := ready(t, got); c.Status != metav1.ConditionFalse || c.Reason != ReasonDuplicateOwner {
t.Errorf("Ready = %+v", c)
}
if e.kuma.Get(second) == nil {
t.Error("extra monitor must not be deleted by the reconciler")
}
}
func TestReconcileDeleteRemovesAllDuplicates(t *testing.T) {
km := deleting(httpMonitor("a"))
e := newEnv(t, km)
first, _ := e.kuma.Create(t.Context(), Desired(km))
second, _ := e.kuma.Create(t.Context(), Desired(km))
if _, err := e.reconcile(t, "a"); err != nil {
t.Fatal(err)
}
if e.kuma.Get(first) != nil || e.kuma.Get(second) != nil {
t.Error("all monitors carrying the key must be deleted")
}
if err := e.k8s.Get(t.Context(), client.ObjectKeyFromObject(km), &v1.KumaMonitor{}); !apierrors.IsNotFound(err) {
t.Errorf("finalizer not removed: %v", err)
}
}
func TestReconcileDeleteFailureIsVisibleInCondition(t *testing.T) {
km := deleting(httpMonitor("a"))
e := newEnv(t, km)
e.kuma.SetErrs(kumaclient.ErrOwnerTagMissing, nil, nil, nil)
if _, err := e.reconcile(t, "a"); !errors.Is(err, kumaclient.ErrOwnerTagMissing) {
t.Fatalf("err = %v", err)
}
got := e.get(t, "a")
if len(got.Finalizers) != 1 {
t.Errorf("finalizer must stay: %v", got.Finalizers)
}
if c := ready(t, got); c.Reason != ReasonOwnerTagMissing {
t.Errorf("Ready = %+v", c)
}
// Other delete failures: incomplete list without our key.
e2 := newEnv(t, deleting(httpMonitor("a")))
e2.kuma.SetListPartial(otherConflict())
if _, err := e2.reconcile(t, "a"); err == nil {
t.Fatal("deleting on an incomplete list must fail")
}
if err := e2.k8s.Get(t.Context(), client.ObjectKeyFromObject(km), &v1.KumaMonitor{}); err != nil {
t.Errorf("finalizer must stay: %v", err)
}
}
func group(name, parent string) *v1.KumaMonitor {
g := &v1.KumaMonitor{
ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: name},
Spec: v1.KumaMonitorSpec{Type: "group", DisplayName: name, Interval: 60, RetryInterval: 60},
Status: v1.KumaMonitorStatus{MonitorID: 100},
}
if parent != "" {
g.Spec.ParentRef = &v1.LocalRef{Name: parent}
}
return g
}
func TestReconcileInvalidParent(t *testing.T) {
notGroup := httpMonitor("web")
notGroup.Status.MonitorID = 5
selfRef := httpMonitor("self")
selfRef.Spec.ParentRef = &v1.LocalRef{Name: "self"}
cycleA, cycleB := group("ga", "gb"), group("gb", "ga")
underCycle := httpMonitor("underCycle")
underCycle.Spec.ParentRef = &v1.LocalRef{Name: "ga"}
// A chain of 12 groups is deeper than allowed.
var chain []client.Object
for i := range 12 {
parent := ""
if i < 11 {
parent = fmt.Sprintf("c%d", i+1)
}
chain = append(chain, group(fmt.Sprintf("c%d", i), parent))
}
deep := httpMonitor("deep")
deep.Spec.ParentRef = &v1.LocalRef{Name: "c0"}
underWeb := httpMonitor("underWeb")
underWeb.Spec.ParentRef = &v1.LocalRef{Name: "web"}
objs := append([]client.Object{notGroup, selfRef, cycleA, cycleB, underCycle, deep, underWeb}, chain...)
e := newEnv(t, objs...)
for _, name := range []string{"underWeb", "self", "underCycle", "deep"} {
res, err := e.reconcile(t, name)
if err != nil {
t.Fatalf("%s: %v", name, err)
}
if res.RequeueAfter != 5*time.Minute {
t.Errorf("%s: RequeueAfter = %v, want 5m", name, res.RequeueAfter)
}
if c := ready(t, e.get(t, name)); c.Status != metav1.ConditionFalse || c.Reason != ReasonInvalidParent {
t.Errorf("%s: Ready = %+v", name, c)
}
}
if e.cnt.creates != 0 {
t.Errorf("creates = %d", e.cnt.creates)
}
}
func TestReconcileNestedGroupsResolve(t *testing.T) {
child := httpMonitor("child")
child.Spec.ParentRef = &v1.LocalRef{Name: "inner"}
e := newEnv(t, group("outer", ""), group("inner", "outer"), child)
if _, err := e.reconcile(t, "child"); err != nil {
t.Fatal(err)
}
if e.cnt.creates != 1 {
t.Errorf("creates = %d", e.cnt.creates)
}
}
func TestReconcilePossibleOrphanStatus(t *testing.T) {
e := newEnv(t, httpMonitor("a"))
e.kuma.SetErrs(nil, fmt.Errorf("create: %w", kumaclient.ErrPossibleOrphan), nil, nil)
if _, err := e.reconcile(t, "a"); !errors.Is(err, kumaclient.ErrPossibleOrphan) {
t.Fatalf("err = %v", err)
}
c := ready(t, e.get(t, "a"))
if c.Status != metav1.ConditionFalse || c.Reason != ReasonPossibleOrphan {
t.Errorf("Ready = %+v", c)
}
}
@@ -0,0 +1,69 @@
package controller
import (
"maps"
"slices"
v1 "gitea.renner.uno/k.renner/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/k.renner/homelab/kuma-sync/internal/kumaclient"
)
// defaultMaxRedirects is Kuma's default for a monitor with no explicit value.
const defaultMaxRedirects = 10
// OwnerKey is the value of the kuma-sync tag for a resource.
func OwnerKey(km *v1.KumaMonitor) string {
return km.Namespace + "/" + km.Name
}
// Desired maps a spec to the monitor Kuma should hold. Parent is resolved by
// the reconciler.
func Desired(km *v1.KumaMonitor) kumaclient.Monitor {
s := km.Spec
m := kumaclient.Monitor{
Type: s.Type,
Name: s.DisplayName,
Description: s.Description,
Active: !s.Paused,
Interval: s.Interval,
RetryInterval: s.RetryInterval,
MaxRetries: s.MaxRetries,
OwnerKey: OwnerKey(km),
}
if s.Timeout != nil {
m.Timeout = *s.Timeout
}
if h := s.HTTP; h != nil {
m.URL = h.URL
m.Method = h.Method
m.AcceptedStatusCodes = slices.Clone(h.AcceptedStatusCodes)
m.Headers = maps.Clone(h.Headers)
m.Body = h.Body
m.MaxRedirects = defaultMaxRedirects
if h.MaxRedirects != nil {
m.MaxRedirects = *h.MaxRedirects
}
m.IgnoreTLS = h.IgnoreTLS
m.ExpiryNotification = h.ExpiryNotification
}
if k := s.Keyword; k != nil {
m.Keyword = k.Keyword
m.InvertKeyword = k.Invert
}
if q := s.JSONQuery; q != nil {
m.JSONPath = q.Expression
m.JSONPathOperator = q.Operator
m.ExpectedValue = q.Expected
}
if d := s.DNS; d != nil {
m.Hostname = d.Hostname
m.DNSResolveType = d.ResolveType
m.DNSResolveServer = d.ResolveServer
m.Port = d.Port
}
if p := s.Port; p != nil {
m.Hostname = p.Hostname
m.Port = p.Port
}
return m
}
@@ -0,0 +1,98 @@
package controller
import (
"reflect"
"testing"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
v1 "gitea.renner.uno/k.renner/homelab/kuma-sync/api/v1alpha1"
"gitea.renner.uno/k.renner/homelab/kuma-sync/internal/kumaclient"
)
func TestMappingAllTypes(t *testing.T) {
i32 := func(v int32) *int32 { return &v }
base := func(typ string) v1.KumaMonitorSpec {
return v1.KumaMonitorSpec{
Type: typ, DisplayName: "dn", Description: "desc",
Interval: 120, RetryInterval: 30, MaxRetries: 2,
}
}
httpSpec := &v1.HTTPSpec{
URL: "https://e.com", Method: "POST", AcceptedStatusCodes: []string{"200"},
Headers: map[string]string{"a": "b"}, Body: "x", MaxRedirects: i32(3),
IgnoreTLS: true, ExpiryNotification: true,
}
mk := func(s v1.KumaMonitorSpec) *v1.KumaMonitor {
return &v1.KumaMonitor{ObjectMeta: metav1.ObjectMeta{Namespace: "ns", Name: "n"}, Spec: s}
}
common := func(typ string) kumaclient.Monitor {
return kumaclient.Monitor{
Type: typ, Name: "dn", Description: "desc", Active: true,
Interval: 120, RetryInterval: 30, MaxRetries: 2, OwnerKey: "ns/n",
}
}
withHTTP := func(m kumaclient.Monitor) kumaclient.Monitor {
m.URL, m.Method, m.AcceptedStatusCodes = "https://e.com", "POST", []string{"200"}
m.Headers, m.Body, m.MaxRedirects = map[string]string{"a": "b"}, "x", 3
m.IgnoreTLS, m.ExpiryNotification, m.Timeout = true, true, 15
return m
}
group := base("group")
httpS := base("http")
httpS.HTTP, httpS.Timeout = httpSpec, i32(15)
kw := base("keyword")
kw.HTTP, kw.Timeout = httpSpec, i32(15)
kw.Keyword = &v1.KeywordSpec{Keyword: "ok", Invert: true}
jq := base("json-query")
jq.HTTP, jq.Timeout = httpSpec, i32(15)
jq.JSONQuery = &v1.JSONQuerySpec{Expression: "a.b", Operator: "contains", Expected: "z"}
dns := base("dns")
dns.DNS = &v1.DNSSpec{Hostname: "h.com", ResolveType: "AAAA", ResolveServer: "9.9.9.9", Port: 5353}
port := base("port")
port.Port = &v1.PortSpec{Hostname: "p.com", Port: 443}
paused := base("group")
paused.Paused = true
wantKW := withHTTP(common("keyword"))
wantKW.Keyword, wantKW.InvertKeyword = "ok", true
wantJQ := withHTTP(common("json-query"))
wantJQ.JSONPath, wantJQ.JSONPathOperator, wantJQ.ExpectedValue = "a.b", "contains", "z"
wantDNS := common("dns")
wantDNS.Hostname, wantDNS.DNSResolveType, wantDNS.DNSResolveServer, wantDNS.Port = "h.com", "AAAA", "9.9.9.9", 5353
wantPort := common("port")
wantPort.Hostname, wantPort.Port = "p.com", 443
wantPaused := common("group")
wantPaused.Active = false
cases := []struct {
name string
spec v1.KumaMonitorSpec
want kumaclient.Monitor
}{
{"group", group, common("group")},
{"http", httpS, withHTTP(common("http"))},
{"keyword", kw, wantKW},
{"json-query", jq, wantJQ},
{"dns", dns, wantDNS},
{"port", port, wantPort},
{"paused maps to inactive", paused, wantPaused},
}
for _, c := range cases {
got := Desired(mk(c.spec))
if !reflect.DeepEqual(got, c.want) {
t.Errorf("%s:\n got %+v\n want %+v", c.name, got, c.want)
}
}
}
func TestMappingMaxRedirectsDefault(t *testing.T) {
km := &v1.KumaMonitor{
ObjectMeta: metav1.ObjectMeta{Namespace: "ns", Name: "n"},
Spec: v1.KumaMonitorSpec{Type: "http", DisplayName: "d", HTTP: &v1.HTTPSpec{URL: "https://e.com"}},
}
if got := Desired(km).MaxRedirects; got != 10 {
t.Errorf("MaxRedirects = %d, want Kuma's default 10", got)
}
}
@@ -0,0 +1,592 @@
package kumaclient
import (
"context"
"encoding/json"
"errors"
"fmt"
"math"
"slices"
"strings"
"sync"
"time"
kuma "github.com/breml/go-uptime-kuma-client"
"github.com/breml/go-uptime-kuma-client/monitor"
"github.com/breml/go-uptime-kuma-client/tag"
)
const (
ownerTagName = "kuma-sync"
ownerTagColor = "#2563eb"
operationTimeout = 30 * time.Second
probeTimeout = 10 * time.Second
connectTimeout = 30 * time.Second
reconnectAttempts = 6
reconnectMaxDelay = 8 * time.Second
)
// Breml is a Client backed by github.com/breml/go-uptime-kuma-client.
// It serialises all calls on one session. The socket.io transport does not
// reconnect, so when a call fails and a probe shows the session is dead, the
// session is rebuilt (session token first, password as fallback; bounded
// backoff) and the call is retried once. Tag ownership caveats: the kuma-sync
// tag is the lowest-ID tag of that name, and the library clobbers monitor
// conditions and retryOnlyOnStatusCodeFailure on update.
type Breml struct {
mu sync.Mutex
c *kuma.Client
tagID int64
sessCtx context.Context
url string
user string
pass string
opts []kuma.Option
token string
backoff0 time.Duration
}
var _ Client = (*Breml)(nil)
// NewBreml connects and logs in once, then ensures the kuma-sync tag exists.
// ctx bounds the lifetime of the socket session, not just the connect call.
//
// After a Kuma restart or dropped connection, the next call that fails with a
// dead session reconnects and retries once. Create is the exception: it is
// never retried, because its outcome can be unknown. Two windows can leave an
// untagged duplicate in Kuma, each signalled by an error wrapping
// ErrPossibleOrphan (the controller should log it at error level):
// 1. Kuma stored the monitor but the ack was lost.
// 2. Kuma stored the monitor, then tagging and the cleanup delete failed.
//
// In both cases the untagged monitor is never adopted or touched, so it needs
// manual cleanup in the Kuma UI, and the next reconcile may create a tagged
// monitor beside it.
func NewBreml(ctx context.Context, url, username, password string) (Client, error) {
return newBreml(ctx, url, username, password)
}
func newBreml(ctx context.Context, url, username, password string, opts ...kuma.Option) (*Breml, error) {
opts = append([]kuma.Option{kuma.WithOperationTimeout(operationTimeout)}, opts...)
c, err := kuma.New(ctx, url, username, password, opts...)
if err != nil {
return nil, fmt.Errorf("connect to kuma: %w", err)
}
b := &Breml{
c: c, sessCtx: ctx, url: url, user: username, pass: password, opts: opts,
token: c.SessionToken(), backoff0: time.Second,
}
if b.tagID, err = b.ensureTag(ctx); err != nil {
_ = c.Disconnect()
return nil, err
}
return b, nil
}
// Close ends the session.
func (b *Breml) Close() error {
return b.c.Disconnect()
}
// pickOwnerTag returns the lowest-ID tag named kuma-sync. Kuma does not
// enforce unique tag names.
func pickOwnerTag(tags []tag.Tag) (int64, bool) {
var id int64
found := false
for _, t := range tags {
if t.Name == ownerTagName && (!found || t.ID < id) {
id, found = t.ID, true
}
}
return id, found
}
// ownerKeyOf returns the kuma-sync tag value of a monitor. Different values on
// one monitor are a conflict, never resolved by picking one.
// The error is a *ConflictError listing all distinct values.
func ownerKeyOf(monitorID int64, tags []tag.MonitorTag, tagID int64) (string, error) {
var keys []string
for _, t := range tags {
if t.TagID == tagID && t.Value != "" && !slices.Contains(keys, t.Value) {
keys = append(keys, t.Value)
}
}
switch len(keys) {
case 0:
return "", nil
case 1:
return keys[0], nil
}
slices.Sort(keys)
return "", &ConflictError{MonitorID: monitorID, Keys: keys}
}
// createdDespite reports whether a CreateMonitor result means the monitor
// exists. The library returns the ID together with ErrUpdateEventTimeout when
// the monitor was created and only the update event is missing (library
// monitor.go: CreateMonitor doc and the errors.Is(err, ErrUpdateEventTimeout)
// branch).
func createdDespite(id int64, err error) bool {
return err == nil || (id != 0 && errors.Is(err, kuma.ErrUpdateEventTimeout))
}
func (b *Breml) ensureTag(ctx context.Context) (int64, error) {
tags, err := b.c.GetTags(ctx)
if err != nil {
return 0, err
}
if id, ok := pickOwnerTag(tags); ok {
return id, nil
}
return b.c.CreateTag(ctx, tag.Tag{Name: ownerTagName, Color: ownerTagColor})
}
// recoverSession reports whether the session was rebuilt after err, so the
// caller may retry. Application-level errors and a probe on a healthy session
// return false.
func (b *Breml) recoverSession(ctx context.Context, err error) bool {
rebuilt, _ := b.checkSession(ctx, err)
return rebuilt
}
// checkSession probes the session after err. dead reports that the probe
// failed, i.e. err was a transport failure; rebuilt that it was then replaced.
func (b *Breml) checkSession(ctx context.Context, err error) (rebuilt, dead bool) {
if ctx.Err() != nil {
return false, false
}
for _, app := range []error{ErrNotFound, ErrNotOwned, ErrOwnerTagMissing, ErrOwnerConflict} {
if errors.Is(err, app) {
return false, false
}
}
pctx, cancel := context.WithTimeout(ctx, probeTimeout)
defer cancel()
if _, perr := b.c.GetTags(pctx); perr == nil {
return false, false
}
return b.reconnect(ctx) == nil, true
}
func (b *Breml) reconnect(ctx context.Context) error {
delay := b.backoff0
var last error
for attempt := 0; attempt < reconnectAttempts; attempt++ {
if attempt > 0 {
select {
case <-time.After(delay):
case <-ctx.Done():
return ctx.Err()
case <-b.sessCtx.Done():
return b.sessCtx.Err()
}
delay = min(delay*2, reconnectMaxDelay)
}
opts := append(slices.Clone(b.opts), kuma.WithConnectTimeout(connectTimeout))
if b.token != "" {
opts = append(opts, kuma.WithSessionToken(b.token))
}
c, err := kuma.New(b.sessCtx, b.url, b.user, b.pass, opts...)
if err != nil {
last = err
continue
}
_ = b.c.Disconnect()
b.c = c
if t := c.SessionToken(); t != "" {
b.token = t
}
return nil
}
return fmt.Errorf("reconnect to kuma: %w", last)
}
// withRecovery runs op and, if it fails on a dead session, retries it once on
// a rebuilt one.
func withRecovery[T any](ctx context.Context, b *Breml, op func() (T, error)) (T, error) {
v, err := op()
if err != nil && b.recoverSession(ctx, err) {
return op()
}
return v, err
}
func (b *Breml) ListOwned(ctx context.Context) ([]Monitor, error) {
b.mu.Lock()
defer b.mu.Unlock()
return withRecovery(ctx, b, func() ([]Monitor, error) { return b.listOwned(ctx) })
}
func (b *Breml) listOwned(ctx context.Context) ([]Monitor, error) {
tags, err := b.c.GetTags(ctx)
if err != nil {
return nil, err
}
if !slices.ContainsFunc(tags, func(t tag.Tag) bool { return t.ID == b.tagID && t.Name == ownerTagName }) {
return nil, ErrOwnerTagMissing
}
bases, err := b.c.GetMonitors(ctx)
if err != nil {
return nil, err
}
var out []Monitor
var conflicts []error
for _, base := range bases {
key, err := ownerKeyOf(base.ID, base.Tags, b.tagID)
if err != nil {
conflicts = append(conflicts, err)
continue
}
if key == "" {
continue
}
m, err := toDomain(base, key)
if err != nil {
return nil, err
}
out = append(out, m.Normalized())
}
return out, errors.Join(conflicts...)
}
// Create is never retried. Two windows can leave an untagged monitor in Kuma,
// both signalled by an error wrapping ErrPossibleOrphan:
// 1. Kuma stored the monitor but the ack was lost (transport or operation
// timeout error, no ID returned).
// 2. Kuma stored the monitor, then tagging failed and removing it failed too.
//
// Either way an untagged duplicate may exist; the controller never touches
// untagged monitors, so it needs manual cleanup in the Kuma UI. The next
// reconcile may create a tagged monitor beside it.
func (b *Breml) Create(ctx context.Context, m Monitor) (int64, error) {
if m.OwnerKey == "" {
return 0, errors.New("create: empty OwnerKey")
}
b.mu.Lock()
defer b.mu.Unlock()
return flagUnknownCreate(
func() (int64, error) { return b.createOnce(ctx, m) },
func(err error) bool { _, dead := b.checkSession(ctx, err); return dead },
)
}
// flagUnknownCreate runs create once. A failure without an ID that is a
// transport or operation-timeout error leaves the outcome unknown, so it is
// wrapped in ErrPossibleOrphan instead of being retried.
func flagUnknownCreate(create func() (int64, error), sessionDead func(error) bool) (int64, error) {
id, err := create()
if err == nil || id != 0 || errors.Is(err, ErrPossibleOrphan) {
return id, err
}
if errors.Is(err, kuma.ErrOperationTimeout) || sessionDead(err) {
return 0, errors.Join(err, fmt.Errorf("create outcome unknown: %w", ErrPossibleOrphan))
}
return 0, err
}
func (b *Breml) createOnce(ctx context.Context, m Monitor) (int64, error) {
wire, err := toWire(m, nil)
if err != nil {
return 0, err
}
id, err := b.c.CreateMonitor(ctx, wire)
if !createdDespite(id, err) {
return 0, err
}
// Here the monitor exists, even if err is an update-event timeout.
if _, terr := b.c.AddMonitorTag(ctx, b.tagID, id, m.OwnerKey); terr != nil {
// An untagged leftover would be invisible to ListOwned.
if derr := b.c.DeleteMonitor(ctx, id); derr != nil {
return 0, errors.Join(terr, fmt.Errorf("remove untagged monitor %d: %w", id, derr),
fmt.Errorf("monitor %d: %w", id, ErrPossibleOrphan))
}
return 0, terr
}
return id, nil
}
// lookupOwned returns the current monitor and verifies the owner tag.
func (b *Breml) lookupOwned(ctx context.Context, op string, id int64) (monitor.Base, string, error) {
bases, err := b.c.GetMonitors(ctx)
if err != nil {
return monitor.Base{}, "", err
}
for _, base := range bases {
if base.ID != id {
continue
}
key, err := ownerKeyOf(base.ID, base.Tags, b.tagID)
if err != nil {
return monitor.Base{}, "", fmt.Errorf("%s %d: %w", op, id, err)
}
if key == "" {
return monitor.Base{}, "", fmt.Errorf("%s %d: %w", op, id, ErrNotOwned)
}
return base, key, nil
}
return monitor.Base{}, "", fmt.Errorf("%s %d: %w", op, id, ErrNotFound)
}
type probeResult struct {
found bool
key string
}
// Probe reports whether a monitor with this ID exists and which owner key it
// carries; an untagged monitor has "". Conflicting tag values return the
// *ConflictError.
func (b *Breml) Probe(ctx context.Context, id int64) (bool, string, error) {
b.mu.Lock()
defer b.mu.Unlock()
res, err := withRecovery(ctx, b, func() (probeResult, error) {
bases, err := b.c.GetMonitors(ctx)
if err != nil {
return probeResult{}, err
}
for _, base := range bases {
if base.ID != id {
continue
}
key, err := ownerKeyOf(base.ID, base.Tags, b.tagID)
return probeResult{found: true, key: key}, err
}
return probeResult{}, nil
})
return res.found, res.key, err
}
func (b *Breml) Update(ctx context.Context, m Monitor) error {
b.mu.Lock()
defer b.mu.Unlock()
_, err := withRecovery(ctx, b, func() (struct{}, error) { return struct{}{}, b.update(ctx, m) })
return err
}
func (b *Breml) update(ctx context.Context, m Monitor) error {
cur, key, err := b.lookupOwned(ctx, "update", m.ID)
if err != nil {
return err
}
if key != m.OwnerKey {
return fmt.Errorf("update %d: monitor carries owner key %q, not %q: %w", m.ID, key, m.OwnerKey, ErrNotOwned)
}
// Start from the full current object, also across a type change, so
// fields we do not manage (notifications, proxy, resend interval, upside
// down, auth) survive the overwrite.
wire, err := toWire(m, &cur)
if err != nil {
return err
}
if err := b.c.UpdateMonitor(ctx, wire); err != nil {
return err
}
// editMonitor does not change the active state.
switch {
case cur.IsActive && !m.Active:
return b.c.PauseMonitor(ctx, m.ID)
case !cur.IsActive && m.Active:
return b.c.ResumeMonitor(ctx, m.ID)
}
return nil
}
func (b *Breml) Delete(ctx context.Context, id int64) error {
b.mu.Lock()
defer b.mu.Unlock()
_, err := withRecovery(ctx, b, func() (struct{}, error) {
if _, _, err := b.lookupOwned(ctx, "delete", id); err != nil {
return struct{}{}, err
}
return struct{}{}, b.c.DeleteMonitor(ctx, id)
})
return err
}
// toDomain converts a monitor from the Kuma monitor list.
func toDomain(base monitor.Base, ownerKey string) (Monitor, error) {
m := Monitor{
ID: base.ID,
Type: base.Type(),
Name: base.Name,
Parent: base.Parent,
Active: base.IsActive,
Interval: int32(base.Interval),
RetryInterval: int32(base.RetryInterval),
MaxRetries: int32(base.MaxRetries),
OwnerKey: ownerKey,
}
if base.Description != nil {
m.Description = *base.Description
}
switch m.Type {
case "http":
var v monitor.HTTP
if err := base.As(&v); err != nil {
return Monitor{}, err
}
if err := readHTTP(&m, v.HTTPDetails); err != nil {
return Monitor{}, err
}
case "keyword":
var v monitor.HTTPKeyword
if err := base.As(&v); err != nil {
return Monitor{}, err
}
if err := readHTTP(&m, v.HTTPDetails); err != nil {
return Monitor{}, err
}
m.Keyword = v.Keyword
m.InvertKeyword = v.InvertKeyword
case "json-query":
var v monitor.HTTPJSONQuery
if err := base.As(&v); err != nil {
return Monitor{}, err
}
if err := readHTTP(&m, v.HTTPDetails); err != nil {
return Monitor{}, err
}
m.JSONPath = v.JSONPath
m.JSONPathOperator = v.JSONPathOperator
m.ExpectedValue = v.ExpectedValue
case "dns":
var v monitor.DNS
if err := base.As(&v); err != nil {
return Monitor{}, err
}
m.Hostname = v.Hostname
m.Port = int32(v.Port)
m.DNSResolveType = string(v.ResolveType)
m.DNSResolveServer = v.ResolverServer
case "port":
var v monitor.TCPPort
if err := base.As(&v); err != nil {
return Monitor{}, err
}
m.Hostname = v.Hostname
m.Port = int32(v.Port)
}
return m, nil
}
func readHTTP(m *Monitor, d monitor.HTTPDetails) error {
m.URL = d.URL
m.Method = d.Method
m.Body = d.Body
m.AcceptedStatusCodes = slices.Clone(d.AcceptedStatusCodes)
m.MaxRedirects = int32(d.MaxRedirects)
m.IgnoreTLS = d.IgnoreTLS
m.ExpiryNotification = d.ExpiryNotification
m.Timeout = int32(math.Round(d.Timeout))
if strings.TrimSpace(d.Headers) != "" {
if err := json.Unmarshal([]byte(d.Headers), &m.Headers); err != nil {
return fmt.Errorf("monitor %d: parse headers: %w", m.ID, err)
}
}
return nil
}
// toWire builds the library monitor for m. With existing set, the typed
// object is first decoded from it so fields we do not manage are kept.
func toWire(m Monitor, existing *monitor.Base) (monitor.Monitor, error) {
m = m.Normalized()
load := func(target any) error {
if existing == nil {
return nil
}
return existing.As(target)
}
switch m.Type {
case "group":
v := &monitor.Group{}
if err := load(v); err != nil {
return nil, err
}
writeBase(&v.Base, m)
return v, nil
case "http":
v := &monitor.HTTP{}
if err := load(v); err != nil {
return nil, err
}
writeBase(&v.Base, m)
return v, writeHTTP(&v.HTTPDetails, m)
case "keyword":
v := &monitor.HTTPKeyword{}
if err := load(v); err != nil {
return nil, err
}
writeBase(&v.Base, m)
v.Keyword = m.Keyword
v.InvertKeyword = m.InvertKeyword
return v, writeHTTP(&v.HTTPDetails, m)
case "json-query":
v := &monitor.HTTPJSONQuery{}
if err := load(v); err != nil {
return nil, err
}
writeBase(&v.Base, m)
v.JSONPath = m.JSONPath
v.JSONPathOperator = m.JSONPathOperator
v.ExpectedValue = m.ExpectedValue
return v, writeHTTP(&v.HTTPDetails, m)
case "dns":
v := &monitor.DNS{}
if err := load(v); err != nil {
return nil, err
}
writeBase(&v.Base, m)
v.Hostname = m.Hostname
v.Port = int(m.Port)
v.ResolveType = monitor.DNSResolveType(m.DNSResolveType)
v.ResolverServer = m.DNSResolveServer
return v, nil
case "port":
v := &monitor.TCPPort{}
if err := load(v); err != nil {
return nil, err
}
writeBase(&v.Base, m)
v.Hostname = m.Hostname
v.Port = int(m.Port)
return v, nil
}
return nil, fmt.Errorf("unsupported monitor type %q", m.Type)
}
func writeBase(b *monitor.Base, m Monitor) {
b.ID = m.ID
b.Name = m.Name
desc := m.Description
b.Description = &desc
b.Parent = m.clone().Parent
b.Interval = int64(m.Interval)
b.RetryInterval = int64(m.RetryInterval)
b.MaxRetries = int64(m.MaxRetries)
b.IsActive = m.Active
}
// writeHTTP expects m to be normalized.
func writeHTTP(d *monitor.HTTPDetails, m Monitor) error {
d.URL = m.URL
d.Timeout = float64(m.Timeout)
d.ExpiryNotification = m.ExpiryNotification
d.IgnoreTLS = m.IgnoreTLS
d.MaxRedirects = int(m.MaxRedirects)
d.AcceptedStatusCodes = slices.Clone(m.AcceptedStatusCodes)
d.Method = m.Method
d.Body = m.Body
if d.HTTPBodyEncoding == "" {
d.HTTPBodyEncoding = "json"
}
d.Headers = ""
if len(m.Headers) > 0 {
raw, err := json.Marshal(m.Headers)
if err != nil {
return fmt.Errorf("encode headers: %w", err)
}
d.Headers = string(raw)
}
return nil
}
@@ -0,0 +1,572 @@
//go:build integration
package kumaclient
import (
"context"
"errors"
"io"
"net"
"reflect"
"testing"
"time"
kuma "github.com/breml/go-uptime-kuma-client"
"github.com/breml/go-uptime-kuma-client/monitor"
"github.com/breml/go-uptime-kuma-client/notification"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
)
const (
testUser = "admin"
testPass = "Sup3r-Secret-Passw0rd!"
)
// startKumaCtr runs a fresh Uptime Kuma and returns it with its base URL.
func startKumaCtr(t *testing.T) (testcontainers.Container, string) {
t.Helper()
ctx := context.Background()
ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: testcontainers.ContainerRequest{
Image: "louislam/uptime-kuma:2.5.6-slim",
ExposedPorts: []string{"3001/tcp"},
Env: map[string]string{"UPTIME_KUMA_DB_TYPE": "sqlite"},
WaitingFor: wait.ForHTTP("/api/entry-page").WithPort("3001/tcp").
WithStatusCodeMatcher(func(s int) bool { return s == 200 }).
WithStartupTimeout(3 * time.Minute),
},
Started: true,
})
if err != nil {
t.Fatalf("start kuma: %v", err)
}
t.Cleanup(func() { _ = ctr.Terminate(context.Background()) })
url, err := ctr.PortEndpoint(ctx, "3001/tcp", "http")
if err != nil {
t.Fatal(err)
}
return ctr, url
}
func startKuma(t *testing.T) string {
t.Helper()
_, url := startKumaCtr(t)
return url
}
// startProxy forwards a stable local address to the container's current
// mapped port, which can change when the container restarts.
func startProxy(t *testing.T, ctr testcontainers.Container) string {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = ln.Close() })
go func() {
for {
down, err := ln.Accept()
if err != nil {
return
}
go func() {
target, err := ctr.PortEndpoint(context.Background(), "3001/tcp", "")
if err != nil {
_ = down.Close()
return
}
up, err := net.Dial("tcp", target)
if err != nil {
_ = down.Close()
return
}
go func() { _, _ = io.Copy(up, down); _ = up.Close() }()
_, _ = io.Copy(down, up)
_ = down.Close()
}()
}
}()
return "http://" + ln.Addr().String()
}
// setupClient creates the admin account on first connect.
func setupClient(t *testing.T, url string) *Breml {
t.Helper()
b, err := newBreml(t.Context(), url, testUser, testPass, kuma.WithAutosetup(), kuma.WithConnectTimeout(2*time.Minute))
if err != nil {
t.Fatalf("setup client: %v", err)
}
t.Cleanup(func() { _ = b.Close() })
return b
}
// freshClient logs in again, as a restarted controller would.
func freshClient(t *testing.T, url string) *Breml {
t.Helper()
b, err := newBreml(t.Context(), url, testUser, testPass, kuma.WithConnectTimeout(time.Minute))
if err != nil {
t.Fatalf("fresh client: %v", err)
}
t.Cleanup(func() { _ = b.Close() })
return b
}
func byID(list []Monitor) map[int64]Monitor {
out := map[int64]Monitor{}
for _, m := range list {
out[m.ID] = m
}
return out
}
func TestCreateFieldsIntact(t *testing.T) {
ctx := context.Background()
url := startKuma(t)
b := setupClient(t, url)
group := Monitor{
Type: "group", Name: "grp", Description: "a group", Active: true,
Interval: 60, RetryInterval: 60, MaxRetries: 0, OwnerKey: "default/grp",
}
gid, err := b.Create(ctx, group)
if err != nil {
t.Fatal(err)
}
group.ID = gid
httpFields := func(m Monitor) Monitor {
m.Active = true
m.Interval, m.RetryInterval, m.MaxRetries, m.Timeout = 120, 30, 2, 25
m.URL = "https://example.com/health"
m.Method = "POST"
m.Body = `{"a":1}`
m.Headers = map[string]string{"X-One": "1", "X-Two": "2"}
m.AcceptedStatusCodes = []string{"200-299", "301"}
m.MaxRedirects = 5
m.IgnoreTLS = true
m.ExpiryNotification = true
m.Parent = &gid
return m
}
keyword := httpFields(Monitor{Type: "keyword", Name: "kw", Description: "kw d", OwnerKey: "default/kw"})
keyword.Keyword = "healthy"
keyword.InvertKeyword = true
jq := httpFields(Monitor{Type: "json-query", Name: "jq", OwnerKey: "default/jq"})
jq.JSONPath = "status"
jq.JSONPathOperator = "contains"
jq.ExpectedValue = "ok"
dns := Monitor{
Type: "dns", Name: "dns", Description: "dns d", Active: true,
Interval: 90, RetryInterval: 45, MaxRetries: 1, Parent: &gid, OwnerKey: "default/dns",
Hostname: "example.com", DNSResolveType: "AAAA", DNSResolveServer: "1.1.1.1", Port: 53,
}
port := Monitor{
Type: "port", Name: "port", Active: false,
Interval: 60, RetryInterval: 60, MaxRetries: 3, OwnerKey: "default/port",
Hostname: "example.com", Port: 443,
}
want := map[int64]Monitor{gid: group.Normalized()}
for _, m := range []*Monitor{&keyword, &jq, &dns, &port} {
id, err := b.Create(ctx, *m)
if err != nil {
t.Fatalf("create %s: %v", m.Type, err)
}
m.ID = id
want[id] = m.Normalized()
}
got, err := freshClient(t, url).ListOwned(ctx)
if err != nil {
t.Fatal(err)
}
gotByID := byID(got)
if len(gotByID) != len(want) {
t.Fatalf("got %d monitors, want %d: %+v", len(gotByID), len(want), got)
}
for id, w := range want {
if !reflect.DeepEqual(gotByID[id], w) {
t.Errorf("%s mismatch:\n got %+v\n want %+v", w.Type, gotByID[id], w)
}
}
}
func TestDefaultTimeoutReadsBack(t *testing.T) {
ctx := context.Background()
url := startKuma(t)
b := setupClient(t, url)
kw := Monitor{
Type: "keyword", Name: "kw", Active: true, Interval: 60, RetryInterval: 60,
URL: "https://example.com", Keyword: "ok", OwnerKey: "default/kw",
}
id, err := b.Create(ctx, kw)
if err != nil {
t.Fatal(err)
}
got, err := freshClient(t, url).ListOwned(ctx)
if err != nil {
t.Fatal(err)
}
m := byID(got)[id]
if m.Timeout != 48 || m.Method != "GET" || !reflect.DeepEqual(m.AcceptedStatusCodes, []string{"200-299"}) {
t.Errorf("defaults not applied on read-back: %+v", m)
}
kw.ID = id
if !reflect.DeepEqual(m, kw.Normalized()) {
t.Errorf("read-back differs from Normalized():\n got %+v\n want %+v", m, kw.Normalized())
}
}
func TestUpdateKeepsUnchangedFields(t *testing.T) {
ctx := context.Background()
url := startKuma(t)
b := setupClient(t, url)
gid, err := b.Create(ctx, Monitor{Type: "group", Name: "grp", Active: true, Interval: 60, RetryInterval: 60, OwnerKey: "default/grp"})
if err != nil {
t.Fatal(err)
}
orig := Monitor{
Type: "keyword", Name: "kw", Active: true, Parent: &gid,
Interval: 60, RetryInterval: 60, MaxRetries: 1, Timeout: 30,
URL: "https://example.com", Method: "GET",
AcceptedStatusCodes: []string{"200-299", "404"},
Keyword: "up", ExpiryNotification: true, OwnerKey: "default/kw",
}
id, err := b.Create(ctx, orig)
if err != nil {
t.Fatal(err)
}
orig.ID = id
updated := orig
updated.Interval = 120
if err := b.Update(ctx, updated); err != nil {
t.Fatal(err)
}
got, err := freshClient(t, url).ListOwned(ctx)
if err != nil {
t.Fatal(err)
}
m, ok := byID(got)[id]
if !ok {
t.Fatalf("monitor %d missing after update", id)
}
if m.Interval != 120 {
t.Errorf("interval = %d, want 120", m.Interval)
}
if m.Parent == nil || *m.Parent != gid {
t.Errorf("parent = %v, want %d", m.Parent, gid)
}
if !reflect.DeepEqual(m.AcceptedStatusCodes, orig.AcceptedStatusCodes) {
t.Errorf("acceptedStatusCodes = %v", m.AcceptedStatusCodes)
}
if m.Keyword != "up" {
t.Errorf("keyword = %q", m.Keyword)
}
if !m.ExpiryNotification {
t.Error("expiryNotification lost")
}
if m.OwnerKey != "default/kw" {
t.Errorf("ownerKey = %q", m.OwnerKey)
}
updated.Active = false
if err := b.Update(ctx, updated); err != nil {
t.Fatal(err)
}
got, err = freshClient(t, url).ListOwned(ctx)
if err != nil {
t.Fatal(err)
}
if byID(got)[id].Active {
t.Error("monitor still active after pausing update")
}
}
// setUnmanaged sets state through the raw library that Monitor does not model.
func setUnmanaged(t *testing.T, b *Breml, id int64) (notificationID int64) {
t.Helper()
ctx := context.Background()
nid, err := b.c.CreateNotification(ctx, &notification.Webhook{
Base: notification.Base{Name: "n", IsActive: true},
WebhookDetails: notification.WebhookDetails{
WebhookURL: "http://example.com/hook", WebhookContentType: "application/json",
},
})
if err != nil {
t.Fatalf("create notification: %v", err)
}
var kw monitor.HTTPKeyword
if err := b.c.GetMonitorAs(ctx, id, &kw); err != nil {
t.Fatal(err)
}
kw.UpsideDown = true
kw.ResendInterval = 7
kw.NotificationIDs = []int64{nid}
if err := b.c.UpdateMonitor(ctx, &kw); err != nil {
t.Fatal(err)
}
return nid
}
func assertUnmanaged(t *testing.T, c *Breml, id, nid int64, when string) {
t.Helper()
var base monitor.Base
base, err := c.c.GetMonitor(t.Context(), id)
if err != nil {
t.Fatal(err)
}
if !base.UpsideDown || base.ResendInterval != 7 || !reflect.DeepEqual(base.NotificationIDs, []int64{nid}) {
t.Errorf("%s: unmanaged state lost: upsideDown=%v resend=%d notifications=%v",
when, base.UpsideDown, base.ResendInterval, base.NotificationIDs)
}
}
func TestUpdatePreservesUnmanagedAndClearsManaged(t *testing.T) {
ctx := context.Background()
url := startKuma(t)
b := setupClient(t, url)
gid, err := b.Create(ctx, Monitor{Type: "group", Name: "grp", Active: true, Interval: 60, RetryInterval: 60, OwnerKey: "default/grp"})
if err != nil {
t.Fatal(err)
}
orig := Monitor{
Type: "keyword", Name: "kw", Active: true, Parent: &gid,
Interval: 60, RetryInterval: 60, URL: "https://example.com",
Headers: map[string]string{"A": "1"}, Keyword: "up", OwnerKey: "default/kw",
}
id, err := b.Create(ctx, orig)
if err != nil {
t.Fatal(err)
}
nid := setUnmanaged(t, b, id)
// Clear managed fields.
cleared := orig
cleared.ID = id
cleared.Parent = nil
cleared.Headers = nil
cleared.Keyword = ""
cleared.Interval = 90
if err := b.Update(ctx, cleared); err != nil {
t.Fatal(err)
}
fresh := freshClient(t, url)
got, err := fresh.ListOwned(ctx)
if err != nil {
t.Fatal(err)
}
m := byID(got)[id]
if m.Parent != nil || m.Headers != nil || m.Keyword != "" || m.Interval != 90 {
t.Errorf("managed fields not cleared: %+v", m)
}
assertUnmanaged(t, fresh, id, nid, "after plain update")
// Type change must not wipe unmanaged state either.
changed := cleared
changed.Type = "http"
if err := b.Update(ctx, changed); err != nil {
t.Fatal(err)
}
got, err = fresh.ListOwned(ctx)
if err != nil {
t.Fatal(err)
}
if m := byID(got)[id]; m.Type != "http" {
t.Errorf("type = %q, want http", m.Type)
}
assertUnmanaged(t, fresh, id, nid, "after type change")
}
func TestRestartAdoptsByTag(t *testing.T) {
ctx := context.Background()
url := startKuma(t)
first := setupClient(t, url)
ids := map[int64]string{}
for _, key := range []string{"ns1/a", "ns2/b", "ns1/c"} {
id, err := first.Create(ctx, Monitor{
Type: "port", Name: key, Active: true, Interval: 60, RetryInterval: 60,
Hostname: "example.com", Port: 80, OwnerKey: key,
})
if err != nil {
t.Fatal(err)
}
ids[id] = key
}
if err := first.Close(); err != nil {
t.Fatal(err)
}
got, err := freshClient(t, url).ListOwned(ctx)
if err != nil {
t.Fatal(err)
}
gotKeys := map[int64]string{}
for _, m := range got {
gotKeys[m.ID] = m.OwnerKey
}
if !reflect.DeepEqual(gotKeys, ids) {
t.Fatalf("got %v, want %v", gotKeys, ids)
}
}
func TestUntaggedMonitorUntouched(t *testing.T) {
ctx := context.Background()
url := startKuma(t)
b := setupClient(t, url)
foreign := &monitor.TCPPort{}
foreign.Name = "foreign"
foreign.Interval, foreign.RetryInterval = 60, 60
foreign.IsActive = true
foreign.Hostname, foreign.Port = "example.com", 80
fid, err := b.c.CreateMonitor(ctx, foreign)
if err != nil {
t.Fatal(err)
}
mine, err := b.Create(ctx, Monitor{
Type: "port", Name: "mine", Active: true, Interval: 60, RetryInterval: 60,
Hostname: "example.com", Port: 80, OwnerKey: "default/mine",
})
if err != nil {
t.Fatal(err)
}
if err := b.Update(ctx, Monitor{ID: mine, Type: "port", Name: "x", Hostname: "example.com", Port: 80, OwnerKey: "default/other"}); !errors.Is(err, ErrNotOwned) {
t.Errorf("update with a different owner key: want ErrNotOwned, got %v", err)
}
if found, key, err := b.Probe(ctx, fid); err != nil || !found || key != "" {
t.Errorf("probe untagged: %v %q %v", found, key, err)
}
if found, key, err := b.Probe(ctx, mine); err != nil || !found || key != "default/mine" {
t.Errorf("probe owned: %v %q %v", found, key, err)
}
if found, _, err := b.Probe(ctx, 99999); err != nil || found {
t.Errorf("probe unknown: %v %v", found, err)
}
got, err := b.ListOwned(ctx)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0].Name != "mine" {
t.Fatalf("ListOwned = %+v", got)
}
if err := b.Update(ctx, Monitor{ID: fid, Type: "port", Name: "hijacked", Hostname: "example.com", Port: 80}); !errors.Is(err, ErrNotOwned) {
t.Errorf("update: want ErrNotOwned, got %v", err)
}
if err := b.Delete(ctx, fid); !errors.Is(err, ErrNotOwned) {
t.Errorf("delete: want ErrNotOwned, got %v", err)
}
if err := b.Delete(ctx, 99999); !errors.Is(err, ErrNotFound) {
t.Errorf("delete unknown: want ErrNotFound, got %v", err)
}
if err := b.Update(ctx, Monitor{ID: 99999, Type: "port"}); !errors.Is(err, ErrNotFound) {
t.Errorf("update unknown: want ErrNotFound, got %v", err)
}
still, err := b.c.GetMonitor(ctx, fid)
if err != nil {
t.Fatal(err)
}
if still.Name != "foreign" {
t.Errorf("foreign monitor changed: %q", still.Name)
}
}
func TestOwnerTagMissing(t *testing.T) {
ctx := context.Background()
url := startKuma(t)
b := setupClient(t, url)
if _, err := b.Create(ctx, Monitor{
Type: "port", Name: "mine", Active: true, Interval: 60, RetryInterval: 60,
Hostname: "example.com", Port: 80, OwnerKey: "default/mine",
}); err != nil {
t.Fatal(err)
}
if err := b.c.DeleteTag(ctx, b.tagID); err != nil {
t.Fatal(err)
}
got, err := b.ListOwned(ctx)
if !errors.Is(err, ErrOwnerTagMissing) || got != nil {
t.Fatalf("got %+v, %v; want nil list and ErrOwnerTagMissing", got, err)
}
}
func TestOwnerConflictExcluded(t *testing.T) {
ctx := context.Background()
url := startKuma(t)
b := setupClient(t, url)
create := func(key string) int64 {
id, err := b.Create(ctx, Monitor{
Type: "port", Name: key, Active: true, Interval: 60, RetryInterval: 60,
Hostname: "example.com", Port: 80, OwnerKey: key,
})
if err != nil {
t.Fatal(err)
}
return id
}
good, bad := create("default/good"), create("default/bad")
if _, err := b.c.AddMonitorTag(ctx, b.tagID, bad, "default/other"); err != nil {
t.Fatal(err)
}
got, err := b.ListOwned(ctx)
if !errors.Is(err, ErrOwnerConflict) {
t.Fatalf("want ErrOwnerConflict, got %v", err)
}
if len(got) != 1 || got[0].ID != good {
t.Fatalf("conflicting monitor must be excluded: %+v", got)
}
if err := b.Delete(ctx, bad); !errors.Is(err, ErrOwnerConflict) {
t.Errorf("delete of conflicting monitor: want ErrOwnerConflict, got %v", err)
}
if err := b.Update(ctx, Monitor{ID: bad, Type: "port", Name: "x", Hostname: "example.com", Port: 80}); !errors.Is(err, ErrOwnerConflict) {
t.Errorf("update of conflicting monitor: want ErrOwnerConflict, got %v", err)
}
if _, _, err := b.Probe(ctx, bad); !errors.Is(err, ErrOwnerConflict) {
t.Errorf("probe of conflicting monitor: want ErrOwnerConflict, got %v", err)
}
}
func TestReconnectAfterKumaRestart(t *testing.T) {
ctr, _ := startKumaCtr(t)
url := startProxy(t, ctr)
b := setupClient(t, url)
id, err := b.Create(t.Context(), Monitor{
Type: "port", Name: "survivor", Active: true, Interval: 60, RetryInterval: 60,
Hostname: "example.com", Port: 80, OwnerKey: "default/survivor",
})
if err != nil {
t.Fatal(err)
}
stop := 10 * time.Second
if err := ctr.Stop(t.Context(), &stop); err != nil {
t.Fatal(err)
}
if err := ctr.Start(t.Context()); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(t.Context(), 60*time.Second)
defer cancel()
got, err := b.ListOwned(ctx)
if err != nil {
t.Fatalf("ListOwned after restart: %v", err)
}
if len(got) != 1 || got[0].ID != id {
t.Fatalf("ListOwned = %+v, want monitor %d", got, id)
}
if _, err := b.Create(ctx, Monitor{
Type: "port", Name: "after", Active: true, Interval: 60, RetryInterval: 60,
Hostname: "example.com", Port: 80, OwnerKey: "default/after",
}); err != nil {
t.Fatalf("Create after restart: %v", err)
}
}
@@ -0,0 +1,173 @@
// Package kumaclient is the boundary to Uptime Kuma.
package kumaclient
import (
"context"
"errors"
"fmt"
"maps"
"math"
"slices"
)
var (
ErrNotFound = errors.New("monitor not found")
ErrNotOwned = errors.New("monitor not owned by kuma-sync")
// ErrOwnerTagMissing means the kuma-sync tag no longer exists in Kuma.
ErrOwnerTagMissing = errors.New("kuma-sync tag missing in Kuma")
// ErrOwnerConflict means a monitor carries more than one kuma-sync tag value.
ErrOwnerConflict = errors.New("monitor carries conflicting kuma-sync tag values")
// ErrPossibleOrphan means Create may have left an untagged monitor in Kuma.
ErrPossibleOrphan = errors.New("possible untagged orphan monitor left in Kuma")
)
// ConflictError reports a monitor carrying several different kuma-sync tag
// values. Keys lists all of them, sorted. It matches ErrOwnerConflict.
type ConflictError struct {
MonitorID int64
Keys []string
}
func (e *ConflictError) Error() string {
return fmt.Sprintf("monitor %d carries %d owner keys %q", e.MonitorID, len(e.Keys), e.Keys)
}
func (e *ConflictError) Unwrap() error { return ErrOwnerConflict }
// Conflicts returns every ConflictError inside err, and whether err consists
// of nothing but conflicts. ListOwned joins one per conflicting monitor.
func Conflicts(err error) (conflicts []*ConflictError, only bool) {
only = err != nil
var walk func(error)
walk = func(e error) {
switch x := e.(type) {
case nil:
case *ConflictError:
conflicts = append(conflicts, x)
case interface{ Unwrap() []error }:
for _, s := range x.Unwrap() {
walk(s)
}
default:
if u, ok := e.(interface{ Unwrap() error }); ok && u.Unwrap() != nil {
walk(u.Unwrap())
return
}
only = false
}
}
walk(err)
return conflicts, only && len(conflicts) > 0
}
func (m Monitor) clone() Monitor {
m.Headers = maps.Clone(m.Headers)
m.AcceptedStatusCodes = slices.Clone(m.AcceptedStatusCodes)
if m.Parent != nil {
p := *m.Parent
m.Parent = &p
}
return m
}
func isHTTPFamily(typ string) bool {
return typ == "http" || typ == "keyword" || typ == "json-query"
}
// Normalized returns m with the defaults the real client applies on write, so
// a desired monitor can be compared with an observed one.
//
// For http, keyword and json-query: an empty Method becomes GET, empty
// AcceptedStatusCodes become ["200-299"], and Timeout 0 becomes
// round(0.8*Interval). For all other types Timeout is stored as 0 and the
// HTTP-only slices are nil. Empty Headers become nil. It is idempotent.
func (m Monitor) Normalized() Monitor {
m = m.clone()
if len(m.Headers) == 0 {
m.Headers = nil
}
if len(m.AcceptedStatusCodes) == 0 {
m.AcceptedStatusCodes = nil
}
if !isHTTPFamily(m.Type) {
m.Timeout = 0
return m
}
if m.Method == "" {
m.Method = "GET"
}
if m.AcceptedStatusCodes == nil {
m.AcceptedStatusCodes = []string{"200-299"}
}
if m.Timeout == 0 {
m.Timeout = int32(math.Round(0.8 * float64(m.Interval)))
}
return m
}
// Monitor is the domain view of a Kuma monitor.
type Monitor struct {
ID int64
Type string
Name string
Description string
Parent *int64
Active bool
Interval int32
RetryInterval int32
MaxRetries int32
// Timeout is in seconds and only applies to http, keyword and json-query.
// 0 is written as round(0.8*Interval), because Kuma treats a stored 0 as a
// huge request timeout. For other types it is ignored and reads back as 0.
Timeout int32
URL string
Method string
Body string
Headers map[string]string
AcceptedStatusCodes []string
MaxRedirects int32
IgnoreTLS bool
ExpiryNotification bool
Keyword string
InvertKeyword bool
JSONPath string
JSONPathOperator string
ExpectedValue string
Hostname string
Port int32
DNSResolveType string
DNSResolveServer string
// OwnerKey is the value of the kuma-sync tag; "" means not ours.
OwnerKey string
}
// Client manages only monitors carrying the kuma-sync tag.
type Client interface {
// ListOwned returns monitors carrying the kuma-sync tag, normalized.
// It returns ErrOwnerTagMissing, with no list, if the tag was deleted in
// Kuma, so callers never mistake that for "no monitors". A monitor with
// conflicting tag values is left out and reported as a *ConflictError (all
// of them joined; see Conflicts) together with the rest of the list. Any
// error means the list is incomplete: callers must not conclude a monitor
// is absent from it.
ListOwned(ctx context.Context) ([]Monitor, error)
// Create creates the monitor and tags it with m.OwnerKey.
Create(ctx context.Context, m Monitor) (int64, error)
// Probe reports whether a monitor with this ID exists, owned or not, and
// the owner key it carries ("" when untagged). A monitor with conflicting
// tag values returns an error that wraps ErrOwnerConflict.
Probe(ctx context.Context, id int64) (found bool, ownerKey string, err error)
// Update overwrites an owned monitor. It wraps ErrNotFound for an unknown ID
// and ErrNotOwned for a monitor without the kuma-sync tag or whose tag
// value differs from m.OwnerKey.
Update(ctx context.Context, m Monitor) error
// Delete removes an owned monitor. It wraps ErrNotFound for an unknown ID
// and ErrNotOwned for a monitor without the kuma-sync tag.
Delete(ctx context.Context, id int64) error
}
@@ -0,0 +1,164 @@
package kumaclient
import (
"context"
"fmt"
"sort"
"sync"
)
// Fake is an in-memory Client safe for concurrent use.
// ListErr, CreateErr, UpdateErr and DeleteErr, when set, are returned by the
// matching method; set them via the Set*Err methods while the fake is shared.
type Fake struct {
mu sync.Mutex
monitors map[int64]Monitor
nextID int64
ListErr error
CreateErr error
UpdateErr error
DeleteErr error
ProbeErr error
// ListPartial makes ListOwned return the stored monitors together with
// ListErr, as the real client does for owner conflicts.
ListPartial bool
}
// SetListPartial makes ListOwned return the stored monitors and err.
func (f *Fake) SetListPartial(err error) {
f.mu.Lock()
defer f.mu.Unlock()
f.ListErr, f.ListPartial = err, true
}
var _ Client = (*Fake)(nil)
func NewFake() *Fake {
return &Fake{monitors: map[int64]Monitor{}, nextID: 1}
}
// AddForeign adds an untagged monitor. A zero ID is assigned the next free one.
func (f *Fake) AddForeign(m Monitor) int64 {
f.mu.Lock()
defer f.mu.Unlock()
m.OwnerKey = ""
if m.ID == 0 {
m.ID = f.allocID()
} else if m.ID >= f.nextID {
f.nextID = m.ID + 1
}
f.monitors[m.ID] = m.clone()
return m.ID
}
// Get returns a copy of the monitor with the given ID, owned or not, or nil.
func (f *Fake) Get(id int64) *Monitor {
f.mu.Lock()
defer f.mu.Unlock()
m, ok := f.monitors[id]
if !ok {
return nil
}
c := m.clone()
return &c
}
// SetErrs sets all four injected errors under the lock.
func (f *Fake) SetErrs(list, create, update, del error) {
f.mu.Lock()
defer f.mu.Unlock()
f.ListErr, f.CreateErr, f.UpdateErr, f.DeleteErr = list, create, update, del
f.ListPartial = false
}
// SetProbeErr makes Probe return err.
func (f *Fake) SetProbeErr(err error) {
f.mu.Lock()
defer f.mu.Unlock()
f.ProbeErr = err
}
// Probe reports whether a monitor exists, owned or not, and its owner key.
func (f *Fake) Probe(_ context.Context, id int64) (bool, string, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.ProbeErr != nil {
return false, "", f.ProbeErr
}
m, ok := f.monitors[id]
return ok, m.OwnerKey, nil
}
func (f *Fake) allocID() int64 {
id := f.nextID
f.nextID++
return id
}
func (f *Fake) ListOwned(_ context.Context) ([]Monitor, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.ListErr != nil && !f.ListPartial {
return nil, f.ListErr
}
var out []Monitor
for _, m := range f.monitors {
if m.OwnerKey != "" {
out = append(out, m.clone())
}
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out, f.ListErr
}
func (f *Fake) Create(_ context.Context, m Monitor) (int64, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.CreateErr != nil {
return 0, f.CreateErr
}
if m.OwnerKey == "" {
return 0, fmt.Errorf("create: empty OwnerKey")
}
m.ID = f.allocID()
f.monitors[m.ID] = m.Normalized()
return m.ID, nil
}
func (f *Fake) Update(_ context.Context, m Monitor) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.UpdateErr != nil {
return f.UpdateErr
}
cur, ok := f.monitors[m.ID]
if !ok {
return fmt.Errorf("update %d: %w", m.ID, ErrNotFound)
}
if cur.OwnerKey == "" {
return fmt.Errorf("update %d: %w", m.ID, ErrNotOwned)
}
if m.OwnerKey != cur.OwnerKey {
return fmt.Errorf("update %d: owner key %q, not %q: %w", m.ID, cur.OwnerKey, m.OwnerKey, ErrNotOwned)
}
f.monitors[m.ID] = m.Normalized()
return nil
}
func (f *Fake) Delete(_ context.Context, id int64) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.DeleteErr != nil {
return f.DeleteErr
}
cur, ok := f.monitors[id]
if !ok {
return fmt.Errorf("delete %d: %w", id, ErrNotFound)
}
if cur.OwnerKey == "" {
return fmt.Errorf("delete %d: %w", id, ErrNotOwned)
}
delete(f.monitors, id)
return nil
}
@@ -0,0 +1,223 @@
package kumaclient
import (
"context"
"errors"
"testing"
)
func TestFakeCreateAssignsIDAndTag(t *testing.T) {
f := NewFake()
id, err := f.Create(context.Background(), Monitor{Name: "a", OwnerKey: "ns/a"})
if err != nil {
t.Fatal(err)
}
if id == 0 {
t.Fatal("expected non-zero id")
}
got, err := f.ListOwned(context.Background())
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0].ID != id || got[0].OwnerKey != "ns/a" {
t.Fatalf("unexpected list: %+v", got)
}
id2, err := f.Create(context.Background(), Monitor{Name: "b", OwnerKey: "ns/b"})
if err != nil {
t.Fatal(err)
}
if id2 == id {
t.Fatal("ids must be unique")
}
}
func TestFakeCreateRequiresOwnerKey(t *testing.T) {
f := NewFake()
if _, err := f.Create(context.Background(), Monitor{Name: "a"}); err == nil {
t.Fatal("expected error for empty OwnerKey")
}
}
func TestFakeListOwnedHidesForeign(t *testing.T) {
f := NewFake()
f.AddForeign(Monitor{ID: 99, Name: "foreign"})
if _, err := f.Create(context.Background(), Monitor{Name: "mine", OwnerKey: "ns/m"}); err != nil {
t.Fatal(err)
}
got, err := f.ListOwned(context.Background())
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0].Name != "mine" {
t.Fatalf("unexpected list: %+v", got)
}
}
func TestFakeUpdateRejectsForeign(t *testing.T) {
f := NewFake()
f.AddForeign(Monitor{ID: 99, Name: "foreign"})
if err := f.Update(context.Background(), Monitor{ID: 99, Name: "changed", OwnerKey: "ns/x"}); !errors.Is(err, ErrNotOwned) {
t.Fatalf("expected ErrNotOwned, got %v", err)
}
if got := f.Get(99); got == nil || got.Name != "foreign" {
t.Fatalf("foreign monitor modified: %+v", got)
}
}
func TestFakeUpdateOverwritesOwned(t *testing.T) {
f := NewFake()
id, _ := f.Create(context.Background(), Monitor{Name: "a", URL: "u", OwnerKey: "ns/a"})
if err := f.Update(context.Background(), Monitor{ID: id, Name: "b", OwnerKey: "ns/a"}); err != nil {
t.Fatal(err)
}
got := f.Get(id)
if got.Name != "b" || got.URL != "" {
t.Fatalf("not overwritten: %+v", got)
}
}
func TestFakeDeleteRejectsForeign(t *testing.T) {
f := NewFake()
f.AddForeign(Monitor{ID: 99, Name: "foreign"})
if err := f.Delete(context.Background(), 99); !errors.Is(err, ErrNotOwned) {
t.Fatalf("expected ErrNotOwned, got %v", err)
}
if f.Get(99) == nil {
t.Fatal("foreign monitor deleted")
}
}
func TestFakeDeleteOwned(t *testing.T) {
f := NewFake()
id, _ := f.Create(context.Background(), Monitor{Name: "a", OwnerKey: "ns/a"})
if err := f.Delete(context.Background(), id); err != nil {
t.Fatal(err)
}
if f.Get(id) != nil {
t.Fatal("not deleted")
}
}
func TestFakeInjectedErrors(t *testing.T) {
boom := errors.New("boom")
f := NewFake()
id, _ := f.Create(context.Background(), Monitor{Name: "a", OwnerKey: "ns/a"})
f.ListErr, f.CreateErr, f.UpdateErr, f.DeleteErr = boom, boom, boom, boom
ctx := context.Background()
if _, err := f.ListOwned(ctx); !errors.Is(err, boom) {
t.Fatalf("list: %v", err)
}
if _, err := f.Create(ctx, Monitor{OwnerKey: "x"}); !errors.Is(err, boom) {
t.Fatalf("create: %v", err)
}
if err := f.Update(ctx, Monitor{ID: id, OwnerKey: "ns/a"}); !errors.Is(err, boom) {
t.Fatalf("update: %v", err)
}
if err := f.Delete(ctx, id); !errors.Is(err, boom) {
t.Fatalf("delete: %v", err)
}
}
func TestFakeUpdateRejectsOtherOwnerKey(t *testing.T) {
f := NewFake()
id, _ := f.Create(context.Background(), Monitor{Name: "a", OwnerKey: "ns/a"})
if err := f.Update(context.Background(), Monitor{ID: id, Name: "b", OwnerKey: "ns/other"}); !errors.Is(err, ErrNotOwned) {
t.Fatalf("want ErrNotOwned, got %v", err)
}
if got := f.Get(id); got.Name != "a" {
t.Errorf("monitor changed: %+v", got)
}
}
func TestFakeProbe(t *testing.T) {
f := NewFake()
owned, _ := f.Create(context.Background(), Monitor{Name: "a", OwnerKey: "ns/a"})
foreign := f.AddForeign(Monitor{Name: "x"})
if found, key, err := f.Probe(context.Background(), owned); err != nil || !found || key != "ns/a" {
t.Errorf("owned: %v %q %v", found, key, err)
}
if found, key, err := f.Probe(context.Background(), foreign); err != nil || !found || key != "" {
t.Errorf("foreign: %v %q %v", found, key, err)
}
if found, _, err := f.Probe(context.Background(), 999); err != nil || found {
t.Errorf("missing: %v %v", found, err)
}
f.SetProbeErr(errors.New("boom"))
if _, _, err := f.Probe(context.Background(), owned); err == nil {
t.Error("expected injected error")
}
}
func TestFakeDeleteTwiceNotFound(t *testing.T) {
f := NewFake()
id, _ := f.Create(context.Background(), Monitor{Name: "a", OwnerKey: "ns/a"})
if err := f.Delete(context.Background(), id); err != nil {
t.Fatal(err)
}
if err := f.Delete(context.Background(), id); !errors.Is(err, ErrNotFound) {
t.Fatalf("expected ErrNotFound, got %v", err)
}
}
func TestFakeUpdateUnknownNotFound(t *testing.T) {
f := NewFake()
if err := f.Update(context.Background(), Monitor{ID: 42, OwnerKey: "ns/a"}); !errors.Is(err, ErrNotFound) {
t.Fatalf("expected ErrNotFound, got %v", err)
}
}
func TestFakeDoesNotAlias(t *testing.T) {
ctx := context.Background()
f := NewFake()
parent := int64(7)
in := Monitor{
Name: "a", OwnerKey: "ns/a", Parent: &parent,
Headers: map[string]string{"k": "v"},
AcceptedStatusCodes: []string{"200-299"},
}
id, _ := f.Create(ctx, in)
check := func(m Monitor, where string) {
t.Helper()
if m.Headers["k"] != "v" || m.AcceptedStatusCodes[0] != "200-299" || *m.Parent != 7 {
t.Fatalf("%s: state aliased: %+v parent=%d", where, m, *m.Parent)
}
}
// Mutating the input after Create must not affect stored state.
in.Headers["k"] = "changed"
in.AcceptedStatusCodes[0] = "changed"
parent = 8
check(*f.Get(id), "after create mutation")
// Mutating returned values must not affect stored state.
got := f.Get(id)
got.Headers["k"] = "changed"
got.AcceptedStatusCodes[0] = "changed"
*got.Parent = 9
listed, _ := f.ListOwned(ctx)
listed[0].Headers["k"] = "changed"
listed[0].AcceptedStatusCodes[0] = "changed"
*listed[0].Parent = 9
check(*f.Get(id), "after return mutation")
// Update copies too.
up := Monitor{ID: id, OwnerKey: "ns/a", Parent: new(int64), Headers: map[string]string{"k": "v"}, AcceptedStatusCodes: []string{"200-299"}}
*up.Parent = 7
if err := f.Update(ctx, up); err != nil {
t.Fatal(err)
}
up.Headers["k"] = "changed"
up.AcceptedStatusCodes[0] = "changed"
*up.Parent = 10
check(*f.Get(id), "after update mutation")
// AddForeign copies too.
fm := Monitor{Parent: new(int64), Headers: map[string]string{"k": "v"}, AcceptedStatusCodes: []string{"200-299"}}
*fm.Parent = 7
fid := f.AddForeign(fm)
fm.Headers["k"] = "changed"
fm.AcceptedStatusCodes[0] = "changed"
*fm.Parent = 10
check(*f.Get(fid), "after foreign mutation")
}
@@ -0,0 +1,169 @@
package kumaclient
import (
"errors"
"fmt"
"reflect"
"testing"
kuma "github.com/breml/go-uptime-kuma-client"
"github.com/breml/go-uptime-kuma-client/tag"
)
func TestPickOwnerTagLowestID(t *testing.T) {
tags := []tag.Tag{
{ID: 9, Name: "kuma-sync"},
{ID: 3, Name: "other"},
{ID: 4, Name: "kuma-sync"},
{ID: 7, Name: "kuma-sync"},
}
id, ok := pickOwnerTag(tags)
if !ok || id != 4 {
t.Fatalf("got %d %v, want 4 true", id, ok)
}
if _, ok := pickOwnerTag([]tag.Tag{{ID: 1, Name: "other"}}); ok {
t.Fatal("expected no match")
}
}
func TestOwnerKeyOf(t *testing.T) {
mt := func(tagID int64, v string) tag.MonitorTag { return tag.MonitorTag{TagID: tagID, Value: v} }
cases := []struct {
name string
tags []tag.MonitorTag
want string
conflict bool
}{
{"none", nil, "", false},
{"other tag only", []tag.MonitorTag{mt(2, "x")}, "", false},
{"single", []tag.MonitorTag{mt(1, "ns/a"), mt(2, "x")}, "ns/a", false},
{"same value twice", []tag.MonitorTag{mt(1, "ns/a"), mt(1, "ns/a")}, "ns/a", false},
{"conflict", []tag.MonitorTag{mt(1, "ns/a"), mt(1, "ns/b")}, "", true},
}
for _, c := range cases {
got, err := ownerKeyOf(5, c.tags, 1)
if c.conflict != errors.Is(err, ErrOwnerConflict) {
t.Errorf("%s: err = %v, want conflict=%v", c.name, err, c.conflict)
}
if got != c.want {
t.Errorf("%s: key = %q, want %q", c.name, got, c.want)
}
}
}
func TestConflictErrorListsAllKeys(t *testing.T) {
mt := func(v string) tag.MonitorTag { return tag.MonitorTag{TagID: 1, Value: v} }
_, err := ownerKeyOf(7, []tag.MonitorTag{mt("default/x"), mt("default/y"), mt("default/a"), mt("default/x")}, 1)
var ce *ConflictError
if !errors.As(err, &ce) || !errors.Is(err, ErrOwnerConflict) {
t.Fatalf("err = %v", err)
}
if ce.MonitorID != 7 || !reflect.DeepEqual(ce.Keys, []string{"default/a", "default/x", "default/y"}) {
t.Errorf("conflict = %+v", ce)
}
}
func TestConflicts(t *testing.T) {
a := &ConflictError{MonitorID: 1, Keys: []string{"a", "b"}}
b := &ConflictError{MonitorID: 2, Keys: []string{"c", "d", "e"}}
got, only := Conflicts(errors.Join(a, fmt.Errorf("wrapped: %w", b)))
if !only || len(got) != 2 || got[0] != a || got[1] != b {
t.Errorf("got %v only=%v", got, only)
}
if _, only := Conflicts(errors.Join(a, errors.New("other"))); only {
t.Error("mixed error must not be conflict-only")
}
if got, only := Conflicts(errors.New("plain")); len(got) != 0 || only {
t.Errorf("plain: %v %v", got, only)
}
if _, only := Conflicts(nil); only {
t.Error("nil is not conflict-only")
}
}
func TestCreatedDespite(t *testing.T) {
timeout := fmt.Errorf("create monitor: %w", kuma.ErrUpdateEventTimeout)
if !createdDespite(5, nil) {
t.Error("success must proceed")
}
if !createdDespite(5, timeout) {
t.Error("update-event timeout with an ID must proceed to tagging")
}
if createdDespite(0, timeout) {
t.Error("no ID must not proceed")
}
if createdDespite(0, errors.New("boom")) || createdDespite(5, errors.New("boom")) {
t.Error("other errors must not proceed")
}
}
func TestFlagUnknownCreate(t *testing.T) {
run := func(id int64, err error, dead bool) (int64, error, int) {
calls := 0
gotID, gotErr := flagUnknownCreate(
func() (int64, error) { calls++; return id, err },
func(error) bool { return dead },
)
return gotID, gotErr, calls
}
transport := errors.New("use of closed network connection")
if id, err, calls := run(0, transport, true); !errors.Is(err, ErrPossibleOrphan) || !errors.Is(err, transport) || id != 0 || calls != 1 {
t.Errorf("transport failure: id=%d err=%v calls=%d; want ErrPossibleOrphan and exactly one create", id, err, calls)
}
timeout := fmt.Errorf("add: %w", kuma.ErrOperationTimeout)
if _, err, calls := run(0, timeout, false); !errors.Is(err, ErrPossibleOrphan) || calls != 1 {
t.Errorf("operation timeout: err=%v calls=%d; want ErrPossibleOrphan and exactly one create", err, calls)
}
app := errors.New("add: validation failed")
if _, err, _ := run(0, app, false); errors.Is(err, ErrPossibleOrphan) || !errors.Is(err, app) {
t.Errorf("application error must pass through unflagged: %v", err)
}
if id, err, _ := run(5, nil, false); err != nil || id != 5 {
t.Errorf("success: id=%d err=%v", id, err)
}
already := fmt.Errorf("x: %w", ErrPossibleOrphan)
if _, err, _ := run(0, already, true); !errors.Is(err, ErrPossibleOrphan) {
t.Errorf("existing orphan error lost: %v", err)
}
}
func TestNormalized(t *testing.T) {
in := Monitor{Type: "keyword", Interval: 60, Headers: map[string]string{}}
got := in.Normalized()
if got.Method != "GET" || !reflect.DeepEqual(got.AcceptedStatusCodes, []string{"200-299"}) {
t.Errorf("defaults not applied: %+v", got)
}
if got.Timeout != 48 {
t.Errorf("timeout = %d, want 48", got.Timeout)
}
if got.Headers != nil {
t.Errorf("empty headers must be nil: %v", got.Headers)
}
if !reflect.DeepEqual(got.Normalized(), got) {
t.Error("Normalized is not idempotent")
}
explicit := Monitor{Type: "http", Interval: 60, Timeout: 10, Method: "POST", AcceptedStatusCodes: []string{"301"}}
if e := explicit.Normalized(); e.Timeout != 10 || e.Method != "POST" || e.AcceptedStatusCodes[0] != "301" {
t.Errorf("explicit values changed: %+v", e)
}
dns := Monitor{Type: "dns", Interval: 60, Timeout: 30}.Normalized()
if dns.Timeout != 0 || dns.Method != "" || dns.AcceptedStatusCodes != nil {
t.Errorf("non-http type must not gain http defaults: %+v", dns)
}
if !reflect.DeepEqual(dns.Normalized(), dns) {
t.Error("Normalized is not idempotent for dns")
}
}
func TestFakeStoresNormalized(t *testing.T) {
f := NewFake()
id, err := f.Create(t.Context(), Monitor{Type: "http", Interval: 100, OwnerKey: "a"})
if err != nil {
t.Fatal(err)
}
got := f.Get(id)
if got.Method != "GET" || got.Timeout != 80 {
t.Fatalf("fake did not normalize: %+v", got)
}
}
@@ -0,0 +1,250 @@
# kuma-sync Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace AutoKuma with an in-house Go controller that reconciles typed `KumaMonitor` resources into Uptime Kuma 2.5.6, with ownership stored as a Kuma tag.
**Architecture:** A controller-runtime manager watches `KumaMonitor` (cluster-wide) and talks to Kuma through `breml/go-uptime-kuma-client` wrapped behind an internal `kumaclient.Client` interface. Each resource maps to one Kuma monitor, found by the tag `kuma-sync=<namespace>/<name>`. Finalizers and a periodic garbage collection keep Kuma free of orphans. The controller has no local state.
**Tech Stack:** Go 1.27, controller-runtime v0.25.2, controller-gen (CRD + deepcopy), `github.com/breml/go-uptime-kuma-client` v0.6.2, envtest, testcontainers-go, kaniko (Woodpecker), Flux (Kustomization + image automation), ESO/OpenBao, Cilium.
**Spec:** `docs/superpowers/specs/2026-10-18-kuma-sync-design.md`
## Global Constraints
- **Latest stable versions everywhere** (owner requirement). As of 2026-10-18 that is:
- Go 1.27 (`go` directive and builder image)
- controller-runtime v0.25.2, with the k8s.io libraries and controller-gen/envtest versions it requires
- go-uptime-kuma-client v0.6.2
- Uptime Kuma 2.5.6 (latest stable; 3.0 is nightly only)
- the latest testcontainers-go
Each task that adds a dependency first checks its latest release (GitHub releases / `go list -m -versions`) and uses that. Before the final commit of each task, run `go list -m -u all` and record that it reports no available updates for direct dependencies. If Kuma ships a newer stable release before Task 7, bump the VPS role and the test image together.
- Module path `gitea.renner.uno/k.renner/homelab/kuma-sync`, source in `ci/images/kuma-sync/`.
- Kuma client: `github.com/breml/go-uptime-kuma-client v0.6.2`, exact version. Copy its `replace github.com/maldikhan/go.socket.io => github.com/breml/go.socket.io <same pseudo-version>` from that release's `go.mod`.
- Kuma under test: `louislam/uptime-kuma:2.5.6-slim`. That is the same image the VPS runs (`ansible/roles/uptime-kuma/defaults/main.yml`).
- CRD: group `uptime.renner.uno`, version `v1alpha1`, kind `KumaMonitor`, plural `kumamonitors`, namespaced.
- Ownership tag: Kuma tag name `kuma-sync`; value `<namespace>/<name>`. Never read, edit or delete a monitor without that tag.
- Finalizer name: `uptime.renner.uno/kuma-sync`.
- Requeue when a parent group does not exist yet: 10 s. Full resync and orphan GC: every 5 min.
- Kuma URL `https://kuma.renner.uno`; credentials from OpenBao KV v2 mount `homelab`, path `infrastructure/uptime-kuma`, keys `username`, `password` (reuse the existing secret; this controller is Kuma's only API client).
- Image `gitea.renner.uno/gitea_admin/kuma-sync`, tag `YYYY-MM-DD-HHMM`, Flux ImagePolicy picks the newest (pattern of `kubernetes/infrastructure/image-automation/authentik-gate-sync.yaml`).
- Unit tests run as a stage of the image build (house pattern for authentik-gate-sync). Integration tests (build tag `integration`) need Docker and run locally, not in CI.
- No `2>/dev/null`. Config comments are terse factual lines. Commits as claude-bot. Every change goes through a PR. No force-push.
## Review Focus
1. **Restart or rebuild duplicates monitors.** That was the AutoKuma failure mode. The adopt-by-tag path is pinned by integration test `TestRestartAdoptsByTag` (Task 3) and reconciler unit test `TestReconcileAdoptsExisting` (Task 4).
2. **Deletion while the controller is down orphans a monitor.** The GC pass is pinned by `TestGCDeletesOrphans` (Task 5) and integration `TestDeleteWhileDownIsCollected` (Task 6).
3. **A hand-made Kuma monitor gets touched.** `TestGCIgnoresUntagged` (Task 5) and integration `TestUntaggedMonitorUntouched` (Task 6).
4. **`editMonitor` is a full overwrite.** Sending a partial object silently clears fields such as `parent` or `accepted_statuscodes`. Pinned by integration `TestUpdateKeepsUnchangedFields` (Task 3).
5. **Children are created before their group.** They must requeue after 10 s, not fail for minutes. Pinned by `TestReconcileWaitsForParent` (Task 4).
---
### Task 1: Module, API types, generated CRD
**Files:**
- Create: `ci/images/kuma-sync/go.mod`, `go.sum`
- Create: `ci/images/kuma-sync/api/v1alpha1/kumamonitor_types.go`, `groupversion_info.go`, generated `zz_generated.deepcopy.go`
- Create: `ci/images/kuma-sync/config/crd/uptime.renner.uno_kumamonitors.yaml` (generated)
- Create: `ci/images/kuma-sync/Makefile` with targets `generate` (controller-gen object + crd), `test`, `test-integration`
- Test: `ci/images/kuma-sync/api/v1alpha1/validation_test.go` (envtest)
**Interfaces:**
- Produces the type `KumaMonitorSpec`:
- `Type` enum: `group|http|keyword|json-query|dns|port|push`.
- `DisplayName string` (required).
- `ParentRef *LocalRef{Name}`.
- `Interval`, `RetryInterval` (`int32` seconds, min 20, defaults 60).
- `MaxRetries` (`int32`, default 0).
- `Timeout` (`int32` seconds, min 1).
- `Paused bool`.
- `Description string`.
- `HTTP *HTTPSpec` holds `URL` (required), `Method` (default GET), `AcceptedStatusCodes []string` (default `["200-299"]`), `Headers map[string]string`, `Body string`, `MaxRedirects *int32`, `IgnoreTLS bool`, `ExpiryNotification bool`. It is used by `http`, `keyword` and `json-query`.
- `Keyword *KeywordSpec{Keyword, Invert bool}`.
- `JSONQuery *JSONQuerySpec`: `Expression string` (JSONata), `Operator` enum `==|!=|<|<=|>|>=|contains`, `Expected string`.
- `DNS *DNSSpec{Hostname, ResolveType enum A|AAAA|CNAME|MX|NS|PTR|SOA|SRV|TXT|CAA, ResolveServer default "1.1.1.1", Port default 53}`.
- `Port *PortSpec{Hostname, Port int32 1-65535}`.
- `Push *PushSpec{}`, an empty marker.
- Produces the type `KumaMonitorStatus{MonitorID int64, ObservedGeneration int64, Conditions []metav1.Condition}`, where `Ready` is the only condition type.
- CEL rules on the spec:
- `type in ['http','keyword','json-query'] == has(self.http)`
- `(type == 'keyword') == has(self.keyword)`
- `(type == 'json-query') == has(self.jsonQuery)`
- `(type == 'dns') == has(self.dns)`
- `(type == 'port') == has(self.port)`
- `(type == 'push') == has(self.push)`
- `type == 'group'` implies none of the blocks are set.
- [ ] **Step 1: Write failing envtest validation tests.** Each test applies a `KumaMonitor` and asserts the API server's answer:
- `TestValidKeyword`: type keyword with http+keyword is accepted.
- `TestKeywordWithoutBlock`: rejected; the error contains `keyword`.
- `TestGroupWithHTTP`: rejected.
- `TestJSONQueryBadOperator`: operator `=~` is rejected.
- `TestIntervalBelowMin`: interval 5 is rejected.
- `TestDefaults`: an applied http monitor reads back `interval 60` and `acceptedStatusCodes ["200-299"]`.
- [ ] **Step 2: Run** `make test`. Expected: FAIL, because the types are missing.
- [ ] **Step 3: Write the types with kubebuilder markers, then run** `make generate`.
- [ ] **Step 4: Run** `make test`. Expected: all 6 PASS.
- [ ] **Step 5: Commit** `feat(kuma-sync): KumaMonitor API types and CRD`.
### Task 2: Kuma client interface and fake
**Files:**
- Create: `ci/images/kuma-sync/internal/kumaclient/client.go` (interface + domain types)
- Create: `ci/images/kuma-sync/internal/kumaclient/fake.go` (in-memory fake used by reconciler tests)
- Test: `ci/images/kuma-sync/internal/kumaclient/fake_test.go`
**Interfaces:**
- Produces:
```go
type Monitor struct { ID int64; Type, Name, Description string; Parent *int64; Active bool
Interval, RetryInterval, MaxRetries, Timeout int32
URL, Method, Body string; Headers map[string]string; AcceptedStatusCodes []string; MaxRedirects int32; IgnoreTLS, ExpiryNotification bool
Keyword string; InvertKeyword bool; JSONPath, JSONPathOperator, ExpectedValue string
Hostname string; Port int32; DNSResolveType, DNSResolveServer string
OwnerKey string } // value of the kuma-sync tag; "" = not ours
type Client interface {
ListOwned(ctx) ([]Monitor, error) // only monitors carrying the kuma-sync tag
Create(ctx, m Monitor) (int64, error) // creates and tags with m.OwnerKey
Update(ctx, m Monitor) error // full overwrite of an owned monitor; errors if not owned
Delete(ctx, id int64) error // errors if not owned
}
```
- The fake also holds untagged monitors (`AddForeign(Monitor)`), so the "never touch untagged" rule can be tested.
- [ ] **Step 1: Write failing tests:**
- `TestFakeListOwnedHidesForeign`
- `TestFakeUpdateRejectsForeign`
- `TestFakeDeleteRejectsForeign`
- `TestFakeCreateAssignsIDAndTag`
- [ ] **Step 2: Run** `go test ./internal/kumaclient/...`. Expected: FAIL.
- [ ] **Step 3: Implement the interface and the fake.**
- [ ] **Step 4: Run** the tests. Expected: PASS.
- [ ] **Step 5: Commit** `feat(kuma-sync): Kuma client interface and fake`.
### Task 3: Real client on breml/go-uptime-kuma-client + integration tests
**Files:**
- Create: `ci/images/kuma-sync/internal/kumaclient/breml.go`
- Test: `ci/images/kuma-sync/internal/kumaclient/breml_integration_test.go` (`//go:build integration`)
**Interfaces:**
- Consumes the `Client` interface and `Monitor` from Task 2.
- Produces `NewBreml(ctx, url, username, password string) (Client, error)`. It logs in once and reuses the session (Kuma rate-limits logins). It ensures the tag `kuma-sync` exists, creating it on first use with color `#2563eb`.
Integration harness: testcontainers-go starts `louislam/uptime-kuma:2.5.6-slim` with `UPTIME_KUMA_DB_TYPE=sqlite` and waits for port 3001. Setup must then create the admin. Use the client's setup call if it has one; otherwise emit `needSetup`/`setup` after the `info` event, exactly as `ansible/roles/uptime-kuma/files/kuma-setup.js` does.
- [ ] **Step 1: Write failing integration tests:**
- `TestCreateFieldsIntact`: creates a keyword, a json-query (operator `contains`), a dns, a port and a group monitor, then reads them back through a fresh client. Every field from Task 2's `Monitor` must equal the input.
- `TestUpdateKeepsUnchangedFields`: updates only `Interval` from 60 to 120. On re-read, `Parent`, `AcceptedStatusCodes`, `Keyword` and `ExpiryNotification` are unchanged.
- `TestRestartAdoptsByTag`: creates monitors, discards the client, builds a new one. `ListOwned` returns the same IDs with the same `OwnerKey`s.
- `TestUntaggedMonitorUntouched`: a monitor created through the raw library without a tag is absent from `ListOwned`, and `Update`/`Delete` on its ID return an error.
- [ ] **Step 2: Run** `go test -tags integration ./internal/kumaclient/...`. Expected: FAIL.
- [ ] **Step 3: Implement `breml.go`.** Map `Monitor` to the library's per-type monitor structs. Tag with `AddMonitorTag(tagID, monitorID, ownerKey)`. For `Update`, read the current full object and overwrite only the fields we manage, as Review Focus 4 requires.
- [ ] **Step 4: Run** the integration tests. Expected: all 4 PASS.
- [ ] **Step 5: Commit** `feat(kuma-sync): Kuma client on go-uptime-kuma-client`.
### Task 4: Reconciler
**Files:**
- Create: `ci/images/kuma-sync/internal/controller/kumamonitor_controller.go`
- Create: `ci/images/kuma-sync/internal/controller/mapping.go` (spec → `kumaclient.Monitor`)
- Test: `ci/images/kuma-sync/internal/controller/kumamonitor_controller_test.go` (controller-runtime fake client + Kuma fake)
- Test: `ci/images/kuma-sync/internal/controller/mapping_test.go`
**Interfaces:**
- Consumes `kumaclient.Client` and the API types.
- Produces `Reconciler{Client client.Client; Kuma kumaclient.Client}` with `Reconcile(ctx, req) (ctrl.Result, error)` and `SetupWithManager(mgr)`.
- Algorithm:
1. Get the resource.
2. If it is being deleted, delete the owned monitor by OwnerKey (absent counts as success) and remove the finalizer.
3. Ensure the finalizer is present.
4. Resolve `parentRef`: the parent `KumaMonitor` must exist and have `status.monitorID`. If not, set `Ready=False` with reason `ParentPending` and requeue after 10 s.
5. Map the spec to the desired `Monitor`, with `OwnerKey = ns/name`.
6. Find the existing monitor via `ListOwned` by OwnerKey. If there is none, `Create`. If it differs on managed fields, `Update`.
7. Set the status: monitorID, observedGeneration, `Ready=True`.
8. Requeue after 5 min.
- [ ] **Step 1: Write failing tests:**
- `TestMappingAllTypes`: a table test, one row per type, showing that spec fields land on the right `Monitor` fields; `Paused` maps to `Active=false`.
- `TestReconcileCreates`
- `TestReconcileAdoptsExisting`: the fake already holds a monitor with the same OwnerKey. Expected: no second create, and status.monitorID equals the existing ID.
- `TestReconcileUpdatesDrift`: the fake monitor's interval differs. Expected: one `Update`.
- `TestReconcileNoopWhenEqual`: zero Update calls.
- `TestReconcileWaitsForParent`: a child whose group has no monitorID gets `RequeueAfter == 10s` and `Ready=False/ParentPending`.
- `TestReconcileDeleteRemovesMonitorAndFinalizer`
- `TestReconcileDeleteToleratesMissingMonitor`
- [ ] **Step 2: Run** `go test ./internal/controller/...`. Expected: FAIL.
- [ ] **Step 3: Implement the mapping and the reconciler.**
- [ ] **Step 4: Run** the tests. Expected: PASS.
- [ ] **Step 5: Commit** `feat(kuma-sync): KumaMonitor reconciler`.
### Task 5: Orphan garbage collection, manager, image
**Files:**
- Create: `ci/images/kuma-sync/internal/controller/gc.go` (a `manager.Runnable` ticking every 5 min)
- Create: `ci/images/kuma-sync/cmd/kuma-sync/main.go`
- Create: `ci/images/kuma-sync/Containerfile`. Stage 1 is `golang` (pinned digest), runs `go test ./...` without tags, then builds a static binary. Stage 2 is distroless static nonroot.
- Modify: `.woodpecker/build-ci-images.yml`. Add a `build-kuma-sync` step that mirrors `build-authentik-gate-sync`, with tag `YYYY-MM-DD-HHMM` plus `latest`.
- Test: `ci/images/kuma-sync/internal/controller/gc_test.go`
**Interfaces:**
- Produces `GC{Client client.Client; Kuma kumaclient.Client; Interval time.Duration}`. Each run deletes every owned monitor whose OwnerKey has no matching `KumaMonitor`. A list error from either side aborts the run, so nothing is deleted on partial information.
- `main.go` reads env `KUMA_URL`, `KUMA_USERNAME` and `KUMA_PASSWORD`. Missing env makes it exit non-zero with a message naming the variable. Health probes run on `:8081`, metrics on `:8080`, and there is no leader election.
- [ ] **Step 1: Write failing tests:**
- `TestGCDeletesOrphans`
- `TestGCIgnoresUntagged`: a foreign monitor in the fake survives.
- `TestGCKeepsLiveResources`
- `TestGCAbortsOnListError`: the fake k8s list fails, and zero deletes happen.
- [ ] **Step 2: Run** the tests. Expected: FAIL.
- [ ] **Step 3: Implement the GC and `main.go`.**
- [ ] **Step 4: Run** `go test ./...` (expected: PASS), then `docker build -f Containerfile .` in `ci/images/kuma-sync` (expected: the build succeeds, with the test stage passing).
- [ ] **Step 5: Commit** `feat(kuma-sync): orphan GC, manager and image build`. Open a PR. After the owner merges it, the controller confirms the first image tag exists in the registry before Task 7.
### Task 6: End-to-end integration test (automated pilot)
**Files:**
- Test: `ci/images/kuma-sync/internal/controller/e2e_integration_test.go` (`//go:build integration`)
The test runs envtest (an API server with the CRD) plus a Kuma 2.5.6 container plus the real manager, with the breml client and GC interval 2 s.
- [ ] **Step 1: Write the tests:**
- `TestE2EGroupNesting`: apply group `outer`, group `inner` (parent outer), and a dns monitor (parent inner), all at once. Within 60 s, all three are in Kuma with the right parent IDs.
- `TestE2ERestartNoDuplicates`: stop the manager, start a new one, wait 2 GC intervals. The monitor count in Kuma is unchanged.
- `TestDeleteWhileDownIsCollected`: stop the manager, remove the finalizer, delete the resource, start the manager. The monitor is gone from Kuma within 2 GC intervals.
- `TestE2EDriftRepaired`: change the interval directly in Kuma via the raw library. The next resync restores the spec value.
- [ ] **Step 2: Run** `go test -tags integration ./internal/controller/...`. Expected: PASS. Any failure is a defect, not something to retry.
- [ ] **Step 3: Commit** `test(kuma-sync): end-to-end integration suite`.
### Task 7: Kubernetes deployment
**Files:**
- Create: `kubernetes/apps/kuma-sync/{crd-kumamonitors.yaml (copied from config/crd), rbac.yaml, externalsecret.yaml, deployment.yaml, kustomization.yaml}`
- Create: `kubernetes/clusters/homelab/kuma-sync.yaml`. It is a Flux Kustomization modeled on the removed `autokuma.yaml`: dependsOn infrastructure and infrastructure-crds, with the CEL ExternalSecret health check.
- Create: `kubernetes/infrastructure/network-policies/kuma-sync-ns.yaml`, the same shape as the removed `autokuma-ns.yaml`: ingress `[{}]`, DNS with `rules.dns`, kube-apiserver, `toFQDNs kuma.renner.uno:443`. Allow kubelet probes from `host`.
- Create: `kubernetes/infrastructure/image-automation/kuma-sync.yaml` (ImageRepository + ImagePolicy).
- Modify: `namespaces.yaml` (add `kuma-sync`), `network-policies/kustomization.yaml`, and the `openbao-clustersecretstore.yaml` allowlist (sorted).
**Interfaces:**
- Deployment: image `gitea.renner.uno/gitea_admin/kuma-sync:<first tag from Task 5>` with the setter `# {"$imagepolicy": "flux-system:kuma-sync"}`. 1 replica, `Recreate`. Security context as in `kubernetes/apps/uptime-monitor/deployment.yaml`, with a read-only root filesystem.
- ClusterRole: `kumamonitors` get/list/watch/update/patch, `kumamonitors/status` get/update/patch, `kumamonitors/finalizers` update, and `events` create/patch.
- [ ] **Step 1: Write the manifests.**
- [ ] **Step 2: Verify:**
- `kubectl kustomize` on every touched directory renders.
- `kubectl apply --dry-run=server -f kubernetes/apps/kuma-sync/crd-kumamonitors.yaml` succeeds.
- [ ] **Step 3: Commit** `feat(kuma-sync): deploy controller` and open a PR. After merge, the controller checks:
- `flux get kustomization kuma-sync` reports Ready.
- The pod is Running.
- The Kuma log shows a login from the controller.
- [ ] **Step 4: Live smoke test** in a follow-up PR: one `KumaMonitor` (keyword on www.renner.uno) in namespace `kuma-sync`. Expected:
- It shows up in Kuma, tagged `kuma-sync=kuma-sync/www-renner-uno`.
- Deleting the pod changes nothing in Kuma.
- Removing it from git deletes the monitor.
## Follow-up (parent plan)
Parent plan `2026-10-18-uptime-kuma.md` Task 3 (pilot) is replaced by Tasks 6 and 7 here. Its Tasks 4 and 5 then use `KumaMonitor` instead of `KumaEntity`.
@@ -0,0 +1,48 @@
# kuma-sync — in-house Uptime Kuma controller (design)
## Why
AutoKuma failed the pilot gate (plan `2026-10-18-uptime-kuma.md`, Task 3). It never applied its ownership tag against Uptime Kuma 2.5.6, so ownership lived only in the pod's local `/data`, and every restart duplicated every monitor. Its CRD is also untyped (`spec.config: object`), so typos are only noticed when a monitor silently misbehaves. The owner decided to replace it with an in-house controller.
## Goals
- Uptime Kuma monitors are declared in git as **typed** Kubernetes resources, validated by the API server when they are applied.
- **Ownership is stored in Kuma itself**: a tag on each managed monitor. Restart, cluster rebuild or a lost pod state never duplicates or orphans a monitor.
- The controller converges on its own: creation order (groups before children), drift (a monitor edited in the UI is set back), and deletion (including deletion while the controller was down).
- It supports the detailed checks the owner asked for: groups with nesting, HTTP status codes, keyword (with invert), JSON query (JSONata expression + operator + expected value), DNS, TCP port, certificate-expiry notification, and timeouts per check.
## Non-goals
- Notifications and status pages are not managed as resources (Task 4 of the parent plan creates them via the Ansible bootstrap). Monitors reference notifications by name only if Task 4 needs it; not in v1.
- No support for other Kuma versions than the pinned one. Kuma and the client library are bumped together.
- No Ingress-driven auto-monitors. Every monitor is an explicit resource.
- Push monitors (Kuma does not generate push tokens via the API; needs token handling) — later version.
## Anti-goals
- Never touch a Kuma monitor that does not carry this controller's tag (hand-made monitors stay untouched).
- No local state the controller depends on. Restarting with nothing but the cluster and Kuma must give the same result.
## Decisions (owner)
- Language: Go.
- Typed CRD.
- Ownership via a Kuma tag.
## Decisions (my calls, open to review)
- **Name** `kuma-sync`, source in `ci/images/kuma-sync/` (house pattern: `netbird-service-sync`), image `gitea.renner.uno/gitea_admin/kuma-sync`, built by kaniko in `.woodpecker/build-ci-images.yml`, rolled out by Flux image automation.
- **Kuma client**: `github.com/breml/go-uptime-kuma-client` v0.6.2 (MIT, tested against Kuma 2.5.6), pinned, with its `replace` for the go.socket.io fork copied into our `go.mod`. Wrapped behind a small internal interface so the reconciler can be tested against a fake and the library can be swapped.
- **CRD** `KumaMonitor`, group `uptime.renner.uno`, version `v1alpha1`, namespaced, generated by `controller-gen` from Go types with kubebuilder markers. One kind with `spec.type` (enum) and one optional block per type; CEL rules enforce that exactly the block matching `type` is set. Groups are `KumaMonitor`s of type `group`; children reference them with `spec.parentRef.name` (same namespace).
- **Ownership tag**: Kuma tag named `kuma-sync`, value `<namespace>/<name>` of the resource. Not the UID: a rebuilt cluster creates new UIDs and must adopt the existing monitors.
- **Reconcile**: find the monitor by tag value → none: create, then tag → exists: compare the desired fields with the actual ones and edit if they differ (full-object `editMonitor`, as Kuma requires). Parent not created yet → requeue after 10 s, not minutes.
- **Deletion**: a finalizer deletes the Kuma monitor. A periodic garbage collection (every resync) deletes tagged monitors whose resource no longer exists, which also covers deletion while the controller was down.
- **Resync** every 5 minutes, to repair drift.
- **Scope**: watches `KumaMonitor` cluster-wide (apps can declare their own monitors in their namespace). ClusterRole limited to `kumamonitors`, `kumamonitors/status`, `kumamonitors/finalizers` and `events`. Credentials come from a Secret in its own namespace.
- **Network**: egress only to DNS, kube-apiserver and `kuma.renner.uno:443` (FQDN policy, same as the AutoKuma one the owner asked for).
- **Single replica, Recreate, no leader election** (house pattern from the IngressMonitorController deployment).
- **Tests**: unit tests for the reconciler against a fake Kuma; CRD validation tests with envtest; integration tests (build tag `integration`) against a real `louislam/uptime-kuma:2.5.6-slim` container via testcontainers-go. The integration suite reproduces the AutoKuma pilot: fields intact, update, groups + nesting, json-query operator, restart without duplicates, deletion while down.
## Left to the builder
Internal package layout, naming inside the code, log format, metric names.
@@ -1,37 +0,0 @@
# Vendored from https://raw.githubusercontent.com/BigBoot/AutoKuma/v2.0.0/autokuma/kubernetes/crds-autokuma.yml (tag v2.0.0), with the upstream-master x-kubernetes-preserve-unknown-fields line in place of additionalProperties: true
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: kumaentities.autokuma.bigboot.dev
spec:
group: autokuma.bigboot.dev
names:
categories: []
kind: KumaEntity
plural: kumaentities
shortNames: []
singular: kumaentity
scope: Namespaced
versions:
- additionalPrinterColumns: []
name: v1
schema:
openAPIV3Schema:
description: Auto-generated derived type for KumaEntitySpec via `CustomResource`
properties:
spec:
properties:
config:
type: object
x-kubernetes-preserve-unknown-fields: true
required:
- config
type: object
required:
- spec
title: KumaEntity
type: object
served: true
storage: true
subresources: {}
-84
View File
@@ -1,84 +0,0 @@
---
# AutoKuma: turns KumaEntity resources into Uptime Kuma monitors.
# Image pinned and not under Flux image automation; bumped together with Kuma.
apiVersion: apps/v1
kind: Deployment
metadata:
name: autokuma
namespace: autokuma
labels:
app.kubernetes.io/name: autokuma
annotations:
secret.reloader.stakater.com/reload: autokuma-kuma
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: autokuma
template:
metadata:
labels:
app.kubernetes.io/name: autokuma
spec:
serviceAccountName: autokuma
automountServiceAccountToken: true
securityContext:
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
seccompProfile:
type: RuntimeDefault
containers:
- name: autokuma
image: ghcr.io/bigboot/autokuma:2.0.0
env:
- name: AUTOKUMA__KUMA__URL
value: https://kuma.renner.uno
- name: AUTOKUMA__KUMA__USERNAME
valueFrom:
secretKeyRef:
name: autokuma-kuma
key: username
- name: AUTOKUMA__KUMA__PASSWORD
valueFrom:
secretKeyRef:
name: autokuma-kuma
key: password
- name: AUTOKUMA__KUBERNETES__ENABLED
value: "true"
- name: AUTOKUMA__DOCKER__ENABLED
value: "false"
- name: AUTOKUMA__FILES__ENABLED
value: "false"
- name: AUTOKUMA__TAG_NAME
value: AutoKuma
- name: AUTOKUMA__ON_DELETE
value: delete
- name: AUTOKUMA__DATA_PATH
value: /data
- name: HOME
value: /data
resources:
requests:
cpu: 10m
memory: 64Mi
limits:
memory: 256Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: data
mountPath: /data
- name: tmp
mountPath: /tmp
volumes:
- name: data
emptyDir: {}
- name: tmp
emptyDir: {}
terminationGracePeriodSeconds: 10
@@ -1,77 +0,0 @@
apiVersion: autokuma.bigboot.dev/v1
kind: KumaEntity
metadata:
name: pilot
namespace: autokuma
spec:
config:
type: group
name: Pilot
---
apiVersion: autokuma.bigboot.dev/v1
kind: KumaEntity
metadata:
name: pilot-inner
namespace: autokuma
spec:
config:
type: group
name: Pilot inner
parent_name: pilot
---
apiVersion: autokuma.bigboot.dev/v1
kind: KumaEntity
metadata:
name: www-renner-uno
namespace: autokuma
spec:
config:
type: keyword
name: www.renner.uno
url: https://www.renner.uno/
keyword: Familie Renner
interval: 60
expiry_notification: true
parent_name: pilot
---
apiVersion: autokuma.bigboot.dev/v1
kind: KumaEntity
metadata:
name: pilot-json
namespace: autokuma
spec:
config:
type: json-query
name: Pilot JSON
url: https://gitea.renner.uno/api/v1/version
json_path: $.version
json_path_operator: contains
expected_value: "."
parent_name: pilot
---
apiVersion: autokuma.bigboot.dev/v1
kind: KumaEntity
metadata:
name: pilot-dns
namespace: autokuma
spec:
config:
type: dns
name: Pilot DNS
hostname: renner.uno
dns_resolve_type: A
dns_resolve_server: 1.1.1.1
parent_name: pilot-inner
---
apiVersion: autokuma.bigboot.dev/v1
kind: KumaEntity
metadata:
name: pilot-port
namespace: autokuma
spec:
config:
type: port
name: Pilot port
hostname: proxy-1.renner.uno
port: 33080
parent_name: pilot
-29
View File
@@ -1,29 +0,0 @@
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: autokuma
namespace: autokuma
---
# Cluster-wide: the Kubernetes source lists and watches KumaEntity across all namespaces.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: autokuma
rules:
- apiGroups: ["autokuma.bigboot.dev"]
resources: ["kumaentities"]
verbs: ["get", "list", "watch", "patch", "update"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: autokuma
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: autokuma
subjects:
- kind: ServiceAccount
name: autokuma
namespace: autokuma
+1 -1
View File
@@ -23,7 +23,7 @@ spec:
type: RuntimeDefault
containers:
- name: gitea-mcp
image: docker.gitea.com/gitea-mcp-server:1.9.0 # {"$imagepolicy": "flux-system:gitea-mcp"}
image: docker.gitea.com/gitea-mcp-server:1.10.0 # {"$imagepolicy": "flux-system:gitea-mcp"}
# Image has no ENTRYPOINT (binary is in CMD), so args alone would replace it.
command:
- /app/gitea-mcp
@@ -0,0 +1,296 @@
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.23.0
kustomize.toolkit.fluxcd.io/prune: disabled
name: kumamonitors.uptime.renner.uno
spec:
group: uptime.renner.uno
names:
kind: KumaMonitor
listKind: KumaMonitorList
plural: kumamonitors
singular: kumamonitor
scope: Namespaced
versions:
- additionalPrinterColumns:
- jsonPath: .spec.type
name: Type
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
displayName:
minLength: 1
type: string
dns:
properties:
hostname:
minLength: 1
type: string
port:
default: 53
format: int32
maximum: 65535
minimum: 1
type: integer
resolveServer:
default: 1.1.1.1
type: string
resolveType:
enum:
- A
- AAAA
- CNAME
- MX
- NS
- PTR
- SOA
- SRV
- TXT
- CAA
type: string
required:
- hostname
- resolveType
type: object
http:
description: HTTPSpec configures http, keyword and json-query monitors.
properties:
acceptedStatusCodes:
default:
- 200-299
items:
type: string
type: array
body:
type: string
expiryNotification:
type: boolean
headers:
additionalProperties:
type: string
type: object
ignoreTLS:
type: boolean
maxRedirects:
format: int32
type: integer
method:
default: GET
type: string
url:
minLength: 1
type: string
required:
- url
type: object
interval:
default: 60
description: Seconds.
format: int32
minimum: 20
type: integer
jsonQuery:
properties:
expected:
type: string
expression:
description: JSONata expression.
minLength: 1
type: string
operator:
enum:
- ==
- '!='
- <
- <=
- '>'
- '>='
- contains
type: string
required:
- expected
- expression
- operator
type: object
keyword:
properties:
invert:
type: boolean
keyword:
minLength: 1
type: string
required:
- keyword
type: object
maxRetries:
default: 0
format: int32
minimum: 0
type: integer
parentRef:
description: LocalRef references a KumaMonitor in the same namespace.
properties:
name:
type: string
required:
- name
type: object
paused:
type: boolean
port:
properties:
hostname:
minLength: 1
type: string
port:
format: int32
maximum: 65535
minimum: 1
type: integer
required:
- hostname
- port
type: object
retryInterval:
default: 60
description: Seconds.
format: int32
minimum: 20
type: integer
timeout:
description: Seconds.
format: int32
minimum: 1
type: integer
type:
enum:
- group
- http
- keyword
- json-query
- dns
- port
type: string
required:
- displayName
- type
type: object
x-kubernetes-validations:
- message: http is required for types http, keyword and json-query, and
forbidden otherwise
rule: (self.type in ['http','keyword','json-query']) == has(self.http)
- message: keyword is required for type keyword and forbidden otherwise
rule: (self.type == 'keyword') == has(self.keyword)
- message: jsonQuery is required for type json-query and forbidden otherwise
rule: (self.type == 'json-query') == has(self.jsonQuery)
- message: dns is required for type dns and forbidden otherwise
rule: (self.type == 'dns') == has(self.dns)
- message: port is required for type port and forbidden otherwise
rule: (self.type == 'port') == has(self.port)
- message: type group takes no type blocks
rule: self.type != 'group' || !(has(self.http) || has(self.keyword)
|| has(self.jsonQuery) || has(self.dns) || has(self.port))
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
monitorID:
format: int64
type: integer
observedGeneration:
format: int64
type: integer
type: object
required:
- spec
type: object
served: true
storage: true
subresources:
status: {}
+72
View File
@@ -0,0 +1,72 @@
---
# kuma-sync: KumaMonitor -> Uptime Kuma monitors on kuma.renner.uno (ci/images/kuma-sync).
apiVersion: apps/v1
kind: Deployment
metadata:
name: kuma-sync
namespace: kuma-sync
labels:
app.kubernetes.io/name: kuma-sync
annotations:
secret.reloader.stakater.com/reload: kuma-sync-kuma
spec:
replicas: 1
# One writer against Kuma at a time; the controller runs without leader election.
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: kuma-sync
template:
metadata:
labels:
app.kubernetes.io/name: kuma-sync
spec:
serviceAccountName: kuma-sync
securityContext:
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
seccompProfile:
type: RuntimeDefault
containers:
- name: controller
image: gitea.renner.uno/gitea_admin/kuma-sync:2026-10-19-0545 # {"$imagepolicy": "flux-system:kuma-sync"}
env:
- name: KUMA_URL
value: https://kuma.renner.uno
- name: KUMA_USERNAME
valueFrom:
secretKeyRef:
name: kuma-sync-kuma
key: username
- name: KUMA_PASSWORD
valueFrom:
secretKeyRef:
name: kuma-sync-kuma
key: password
ports:
- name: metrics
containerPort: 8080
- name: probes
containerPort: 8081
livenessProbe:
httpGet:
path: /healthz
port: probes
readinessProbe:
httpGet:
path: /readyz
port: probes
resources:
requests:
cpu: 10m
memory: 64Mi
limits:
memory: 256Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
terminationGracePeriodSeconds: 30
@@ -1,23 +1,23 @@
---
# Uptime Kuma login for AutoKuma; OpenBao homelab/infrastructure/uptime-kuma.
# Uptime Kuma login for kuma-sync; OpenBao homelab/infrastructure/kuma-sync.
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: autokuma-kuma
namespace: autokuma
name: kuma-sync-kuma
namespace: kuma-sync
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: openbao
target:
name: autokuma-kuma
name: kuma-sync-kuma
data:
- secretKey: username
remoteRef:
key: infrastructure/uptime-kuma
key: infrastructure/kuma-sync
property: username
- secretKey: password
remoteRef:
key: infrastructure/uptime-kuma
key: infrastructure/kuma-sync
property: password
@@ -1,8 +1,7 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- crd-kumaentities.yaml
- crd-kumamonitors.yaml
- externalsecret.yaml
- rbac.yaml
- deployment.yaml
- pilot-kumaentities.yaml
+43
View File
@@ -0,0 +1,43 @@
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: kuma-sync
namespace: kuma-sync
labels:
app.kubernetes.io/name: kuma-sync
---
# Cluster-wide: KumaMonitor objects live in every app namespace.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: kuma-sync
rules:
- apiGroups: ["uptime.renner.uno"]
resources: ["kumamonitors"]
verbs: ["get", "list", "watch", "update", "patch"]
- apiGroups: ["uptime.renner.uno"]
resources: ["kumamonitors/status"]
verbs: ["get", "update", "patch"]
- apiGroups: ["uptime.renner.uno"]
resources: ["kumamonitors/finalizers"]
verbs: ["update"]
- apiGroups: [""]
resources: ["events"]
verbs: ["create", "patch"]
- apiGroups: [""]
resources: ["namespaces"]
verbs: ["get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: kuma-sync
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: kuma-sync
subjects:
- kind: ServiceAccount
name: kuma-sync
namespace: kuma-sync
@@ -317,9 +317,7 @@ spec:
- matchers:
- alertname = "InfoInhibitor"
receiver: 'null'
# Flux notification-controller posts info-severity events
# here (e.g. image-automation commits). Keep them visible
# but don't repeat-spam every 12h on the same event.
# Flux notification-controller error events (flux-failures Alert).
- matchers:
- source = "flux"
receiver: email
@@ -1,9 +1,9 @@
# AutoKuma controller: KumaEntity -> Uptime Kuma monitors. Ships no KumaEntity objects.
# kuma-sync controller: KumaMonitor -> Uptime Kuma monitors. Ships no KumaMonitor objects.
# healthCheckExprs: Flux has no status reader for ExternalSecret; fail only on the non-retrying reasons.
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: autokuma
name: kuma-sync
namespace: flux-system
spec:
interval: 10m
@@ -12,7 +12,7 @@ spec:
sourceRef:
kind: GitRepository
name: flux-system
path: ./kubernetes/apps/autokuma
path: ./kubernetes/apps/kuma-sync
prune: true
wait: true
healthCheckExprs:
@@ -1,25 +1,3 @@
# Image-automation commits: informational events from the IUA when it
# actually writes a bump commit. The 30-minute "no change since last
# reconciliation" heartbeat is filtered out via exclusionList so we
# don't email-spam every IUA pass that did nothing.
apiVersion: notification.toolkit.fluxcd.io/v1beta3
kind: Alert
metadata:
name: flux-image-automation
namespace: flux-system
spec:
providerRef:
name: alertmanager
eventSeverity: info
eventSources:
- kind: ImageUpdateAutomation
name: '*'
exclusionList:
- 'no change since last reconciliation'
eventMetadata:
source: flux
category: image-automation
---
# Failures across Flux: any error-severity event from a source/sync
# resource. Sends a single alert per error so cluster reconcile breakage
# surfaces in email regardless of which controller emitted it.
@@ -44,6 +44,7 @@ existing `flux-system` GitRepository whose Secret was created by
| netbird-ingress | `0.x` | `-rootless` suffix filter; pre-1.0, bounded to the `0.x` major |
| wyoming-{whisper,openwakeword,speech-to-phrase} | `3.x` / `2.x` / `1.x` | |
| authentik-gate-sync | `YYYY-MM-DD-HHMM` | In-house (build-ci-images.yml); newest tag wins |
| kuma-sync | `YYYY-MM-DD-HHMM` | In-house (build-ci-images.yml); newest tag wins |
| gatus | `5.x` | v-prefix filter |
| velero-plugin-for-aws | `1.13.x` | Tight: must match velero server major.minor |
| gotenberg | `8.x` | Plain-variant filter (rejects `-libreoffice`/`-chromium`/`-cloudrun`/`-aws-lambda`) |
@@ -0,0 +1,23 @@
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImageRepository
metadata:
name: kuma-sync
namespace: flux-system
spec:
image: gitea.renner.uno/gitea_admin/kuma-sync
interval: 30m
---
# build-ci-images.yml tags each build YYYY-MM-DD-HHMM; newest wins.
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImagePolicy
metadata:
name: kuma-sync
namespace: flux-system
spec:
imageRepositoryRef:
name: kuma-sync
filterTags:
pattern: '^\d{4}-\d{2}-\d{2}-\d{4}$'
policy:
alphabetical:
order: asc
@@ -12,6 +12,7 @@ resources:
- gotenberg.yaml
- home-assistant.yaml
- immich.yaml
- kuma-sync.yaml
- lidarr.yaml
- matter-server.yaml
- mmwave-configurator.yaml
@@ -289,8 +289,8 @@ kind: Namespace
metadata:
name: renovate
---
# AutoKuma controller; declared here because it has a CiliumNetworkPolicy applied by infrastructure-crds.
# kuma-sync controller; declared here because it has a CiliumNetworkPolicy applied by infrastructure-crds.
apiVersion: v1
kind: Namespace
metadata:
name: autokuma
name: kuma-sync
@@ -1,13 +1,18 @@
# Baseline policy for the `autokuma` namespace: egress to kube-dns, kube-apiserver and kuma.renner.uno:443. No ingress (`ingress: [{}]` enables ingress default-deny).
# Baseline policy for the `kuma-sync` namespace: egress to kube-dns, kube-apiserver and kuma.renner.uno:443; ingress only kubelet probes.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: default-deny
namespace: autokuma
namespace: kuma-sync
spec:
endpointSelector: {}
ingress:
- {}
- fromEntities:
- host
toPorts:
- ports:
- port: "8081"
protocol: TCP
egress:
# rules.dns lets Cilium learn the toFQDNs mapping
- toEndpoints:
@@ -32,7 +32,7 @@ resources:
- ai-ns.yaml
- navidrome-ns.yaml
- uptime-monitor-ns.yaml
- autokuma-ns.yaml
- kuma-sync-ns.yaml
- renovate-ns.yaml
- allow-cilium-health.yaml
- kube-system-hubble-relay.yaml
@@ -17,7 +17,6 @@ spec:
- ai # wiki-embed-basic-auth (apps/llama-server/wiki-embed-ingress.yaml)
- apps
- audiobookshelf
- autokuma # autokuma-kuma (apps/autokuma/externalsecret.yaml)
- authentik
- cert-manager
- crowdsec
@@ -28,6 +27,7 @@ spec:
- immich
- influxdb
- ingress
- kuma-sync # kuma-sync-kuma (apps/kuma-sync/externalsecret.yaml)
- matrix # synapse-secrets, mx-pg barman creds (apps/matrix)
- monitoring
- paperless