Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
790e258894 | ||
|
|
5e99ca4370 | ||
|
|
e1c0e73f61 | ||
|
|
59ccd74a89 | ||
|
|
15a4e2a0dc | ||
|
|
2af1d880e4 | ||
|
|
18ef262d93 | ||
|
|
185738bd2c | ||
|
|
0c487656e8 | ||
|
|
b3f68753d1 | ||
|
|
1968e91516 | ||
|
|
4f0c7c414e | ||
|
|
734f546d8b | ||
|
|
90534f8ce7 | ||
|
|
3a684d5452 | ||
|
|
ee0d416b80 | ||
|
|
fdb381305e | ||
|
|
5ebf7a04c4 | ||
|
|
7f4c9c60c3 | ||
|
|
fde532b6a4 | ||
|
|
333f331aed | ||
|
|
1c4f4583fa | ||
|
|
b3a32435a8 | ||
|
|
b31ee3189a | ||
|
|
64a5281bd0 | ||
|
|
f7a0719524 | ||
|
|
4a302c2cbe | ||
|
|
7617a6725b | ||
|
|
c863b9845c |
@@ -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 }}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
bin/
|
||||
@@ -0,0 +1 @@
|
||||
/bin/
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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: {}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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=
|
||||
@@ -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, ¬ification.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: {}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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: {}
|
||||
@@ -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
|
||||
+6
-6
@@ -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
-2
@@ -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
|
||||
@@ -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
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
+8
-3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user