Compare commits

..
Author SHA1 Message Date
hermes-bot 08004a508a Merge remote-tracking branch 'origin/main' into fix/gitea-webhook-sync-org-scope
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
# Conflicts:
#	kubernetes/apps/hermes/skills/gitea-issues/SKILL.md
2026-10-22 06:01:33 +00:00
hermes-bot 3d3510b7bd fix(hermes): list hermes-bot's orgs via the admin route the PAT's scopes cover
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
review-bot: /users/{u}/orgs requires the user and organization scope
categories (Gitea 1.27.3 api.go, HasScope needs all), and the sync's PAT
has write:admin,write:organization,write:issue,read:repository, no
read:user. /admin/users/{u}/orgs needs only the admin category and lists
private orgs for a site admin.

Also: drop paged()'s dead /repos/search unwrapping, test the team-mode
fallback of team_writes_issues, and say in SKILL.md that direct-collaborator
and user repos still get cards via the system hook.
2026-10-21 07:47:13 +00:00
hermes-bot bd9f0cb18d fix(hermes): gitea-webhook-sync covers every konairius repo via team membership
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was canceled
The sync reached only konairius/homelab ("repos: 1 with issues writable by
hermes-bot") although hermes-bot has write on all 10 org repos through the
org team Bots (includes_all_repositories, repo.issues write).

Cause: GET /repos/{o}/{r}/collaborators/{user}/permission (Gitea 1.27.3
GetRepoPermissions) returns permission.AccessMode, the repo-wide mode from
the collaborator/access level. Rights from a unit-based team land only in
the per-unit modes and are not part of that field, so it said "none" for
the 9 repos where hermes-bot is not a direct collaborator (checked live:
homelab "write", shelly-manager and ical-proxy "none", while
/orgs/konairius/repos reports push: true for all 10).

Scope is now derived from org membership: orgs of hermes-bot
(/users/hermes-bot/orgs) -> teams granting repo.issues write that it is a
member of -> all org repos (includes_all_repositories) or the team's repos,
non-archived with issues on. User-owned repos are out of scope, so labels
are org labels only and the repo-label branch is gone.

Tests: team scope, read-only team skipped, selected-repo team, user repos
out of scope, failed team lookups, org-labels-only. Live read-only probe
with hermes-bot's token: 10 repos, label target konairius.
2026-10-21 07:40:33 +00:00
105 changed files with 206 additions and 4534 deletions
-26
View File
@@ -454,32 +454,6 @@ steps:
--destination gitea.renner.uno/gitea_admin/kuma-sync:$TAG \
--destination gitea.renner.uno/gitea_admin/kuma-sync:latest
# cloudflare-lb-sync: reconciles CloudflareLoadBalancer resources into
# Cloudflare. Its unit tests run as a stage of the image build.
- name: build-cloudflare-lb-sync
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.19-debug@sha256:7795cec509a50a71378cc2c365f55c555ee3a05834d8eb1cd2552df5e66d44d8
directory: ci/images/cloudflare-lb-sync
when:
- event: deployment
- event: push
path: ["ci/images/cloudflare-lb-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/cloudflare-lb-sync:$TAG \
--destination gitea.renner.uno/gitea_admin/cloudflare-lb-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
-28
View File
@@ -1,28 +0,0 @@
when:
# Manual only: reboots edge-1 (Z-Wave, heating CAN, sole NTP server) and the
# NetBird control plane. Run inside the 02:00-05:00 Europe/Berlin window.
- event: deployment
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "update-os"'
timeout: 120
steps:
- name: update-os
image: gitea.renner.uno/gitea_admin/ci-ansible:2026-04-29
environment:
ANSIBLE_FORCE_COLOR: "true"
ANSIBLE_HOST_KEY_CHECKING: "false"
ANSIBLE_ROLES_PATH: "../roles:./roles"
SSH_PRIVATE_KEY:
from_secret: ssh_private_key
VAULT_PASS:
from_secret: vault_pass
commands:
- mkdir -p ~/.ssh
- echo "$SSH_PRIVATE_KEY" | base64 -d > ~/.ssh/id_ed25519
- chmod 600 ~/.ssh/id_ed25519
- printf "Host *\n\tStrictHostKeyChecking no\n\tUserKnownHostsFile /dev/null\n" > ~/.ssh/config
- cd ansible
- echo "$VAULT_PASS" > .vault_pass && chmod 600 .vault_pass
- export ANSIBLE_VAULT_PASSWORD_FILE=.vault_pass
- ansible-playbook -i inventory/ plays/update-os.yml -v
-30
View File
@@ -1,30 +0,0 @@
---
# Package upgrade + reboot for netbird_proxy, edge_host and netbird_server,
# one host at a time.
# Usage: ansible-playbook -i inventory/ plays/update-os.yml
#
# Plays run in order; a failed host stops the whole run (max_fail_percentage: 0).
- name: Update NetBird proxies
hosts: netbird_proxy
serial: 1
max_fail_percentage: 0
become: true
roles:
- os-update
- name: Update edge hosts
hosts: edge_host
serial: 1
max_fail_percentage: 0
become: true
roles:
- os-update
- name: Update NetBird server
hosts: netbird_server
serial: 1
max_fail_percentage: 0
become: true
roles:
- os-update
-142
View File
@@ -1,142 +0,0 @@
---
- name: Fail on unsupported distribution
ansible.builtin.fail:
msg: >-
os-update supports Debian and Archlinux only;
this host reports os_family={{ ansible_facts['os_family'] }}
when: ansible_facts['os_family'] not in ['Debian', 'Archlinux']
# --- Baseline: what is healthy before the upgrade must be healthy after ---
- name: Record failed units before upgrade
ansible.builtin.command: systemctl list-units --state=failed --plain --no-legend --no-pager
register: os_update_failed_before
changed_when: false
check_mode: false
- name: Check for docker
ansible.builtin.stat:
path: /usr/bin/docker
register: os_update_docker
- name: Record running containers before upgrade
ansible.builtin.command: docker ps --format "{{ '{{' }}.Names{{ '}}' }}"
register: os_update_containers_before
changed_when: false
check_mode: false
when: os_update_docker.stat.exists
- name: Assert at least 1 GiB free on /
ansible.builtin.assert:
that: >-
(ansible_facts['mounts'] | selectattr('mount', 'equalto', '/') | first)['size_available']
>= 1073741824
fail_msg: "Less than 1 GiB free on / -- free space before upgrading"
# --- Arch Linux ---
- name: Upgrade keyring first (Arch)
community.general.pacman:
name: archlinux-keyring
state: latest
update_cache: true
when: ansible_facts['os_family'] == 'Archlinux'
- name: Upgrade all packages (Arch)
community.general.pacman:
upgrade: true
register: os_update_pacman
when: ansible_facts['os_family'] == 'Archlinux'
# --- Debian/Ubuntu ---
- name: Upgrade all packages (Debian)
ansible.builtin.apt:
update_cache: true
upgrade: full
autoremove: true
environment:
DEBIAN_FRONTEND: noninteractive
when: ansible_facts['os_family'] == 'Debian'
- name: Check reboot-required flag (Debian)
ansible.builtin.stat:
path: /var/run/reboot-required
register: os_update_reboot_flag
when: ansible_facts['os_family'] == 'Debian'
# --- Reboot ---
- name: Read running kernel before reboot (Arch)
ansible.builtin.command: uname -r
register: os_update_kernel_before
changed_when: false
check_mode: false
when: ansible_facts['os_family'] == 'Archlinux'
# Missing modules dir: a newer kernel is installed but not booted, including
# from an earlier interrupted run where pacman now reports no change.
- name: Check running kernel is still installed (Arch)
ansible.builtin.stat:
path: "/usr/lib/modules/{{ os_update_kernel_before.stdout }}/vmlinuz"
register: os_update_kernel_present
when: ansible_facts['os_family'] == 'Archlinux'
- name: Reboot
ansible.builtin.reboot:
msg: "Rebooting after package upgrade"
reboot_timeout: 900
post_reboot_delay: 30
when: >-
(ansible_facts['os_family'] == 'Archlinux' and
(os_update_pacman is changed or not os_update_kernel_present.stat.exists)) or
(ansible_facts['os_family'] == 'Debian' and os_update_reboot_flag.stat.exists)
# --- Verify ---
- name: Read running kernel
ansible.builtin.command: uname -r
register: os_update_kernel
changed_when: false
check_mode: false
# Arch keeps only the installed kernel's modules; the directory existing means
# the host booted the kernel pacman installed.
- name: Assert running kernel is the installed one (Arch)
ansible.builtin.stat:
path: "/usr/lib/modules/{{ os_update_kernel.stdout }}/vmlinuz"
register: os_update_kernel_installed
failed_when: not os_update_kernel_installed.stat.exists
when: ansible_facts['os_family'] == 'Archlinux'
- name: Assert no reboot pending (Debian)
ansible.builtin.stat:
path: /var/run/reboot-required
register: os_update_reboot_flag_after
failed_when: os_update_reboot_flag_after.stat.exists
when: ansible_facts['os_family'] == 'Debian'
- name: Wait for previously running containers
ansible.builtin.command: docker ps --format "{{ '{{' }}.Names{{ '}}' }}"
register: os_update_containers_after
changed_when: false
check_mode: false
until: >-
os_update_containers_before.stdout_lines
| difference(os_update_containers_after.stdout_lines)
| length == 0
retries: 20
delay: 15
when: os_update_docker.stat.exists
- name: Wait for no new failed units
ansible.builtin.command: systemctl list-units --state=failed --plain --no-legend --no-pager
register: os_update_failed_after
changed_when: false
check_mode: false
until: >-
os_update_failed_after.stdout_lines
| map('split') | map('first')
| difference(os_update_failed_before.stdout_lines | map('split') | map('first'))
| length == 0
retries: 10
delay: 15
- name: Report kernel
ansible.builtin.debug:
msg: "{{ inventory_hostname }} runs {{ os_update_kernel.stdout }}"
@@ -1 +0,0 @@
bin/
-1
View File
@@ -1 +0,0 @@
/bin/
@@ -1,16 +0,0 @@
# cloudflare-lb-sync: reconciles CloudflareLoadBalancer resources into
# Cloudflare. The unit tests (no tags, so 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/cloudflare-lb-sync ./cmd/cloudflare-lb-sync
FROM gcr.io/distroless/static:nonroot@sha256:e2e927ec666bae08560abb3c55d0659eceabb657f56b6782ab500a9fc7f555e3
COPY --from=build /out/cloudflare-lb-sync /cloudflare-lb-sync
USER 65532
ENTRYPOINT ["/cloudflare-lb-sync"]
-26
View File
@@ -1,26 +0,0 @@
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
$(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=cloudflare-lb-sync paths=./internal/... output:rbac:artifacts:config=config/rbac
test:
go test ./...
test-envtest:$(SETUP_ENVTEST)
KUBEBUILDER_ASSETS="$$($(SETUP_ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(BIN)/k8s -p path)" go test -tags envtest ./...
@@ -1,132 +0,0 @@
package v1alpha1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
const ConditionReady = "Ready"
// MonitorSpec is the health monitor attached to the pool.
// +kubebuilder:validation:XValidation:rule="self.type != 'tcp' || !has(self.host)",message="host is not allowed for type tcp"
type MonitorSpec struct {
// +kubebuilder:validation:Enum=http;https;tcp
// +kubebuilder:default=https
Type string `json:"type,omitempty"`
// +kubebuilder:validation:Minimum=1
// +kubebuilder:validation:Maximum=65535
// +kubebuilder:default=443
Port int32 `json:"port,omitempty"`
// Ignored for type tcp.
// +kubebuilder:validation:MinLength=1
// +kubebuilder:default="/"
Path string `json:"path,omitempty"`
// Ignored for type tcp.
// +kubebuilder:validation:MinLength=1
// +kubebuilder:default="200"
ExpectedCodes string `json:"expectedCodes,omitempty"`
// Host header sent with the check.
// +kubebuilder:validation:MinLength=1
Host string `json:"host,omitempty"`
// Seconds.
// +kubebuilder:validation:Minimum=1
// +kubebuilder:default=60
Interval int32 `json:"interval,omitempty"`
// +kubebuilder:validation:Minimum=0
// +kubebuilder:default=2
Retries int32 `json:"retries,omitempty"`
// Seconds.
// +kubebuilder:validation:Minimum=1
// +kubebuilder:default=5
Timeout int32 `json:"timeout,omitempty"`
}
// OriginSpec is one origin of the pool.
type OriginSpec struct {
// +kubebuilder:validation:Pattern=`^[A-Za-z0-9_-]+$`
// +kubebuilder:validation:MaxLength=63
Name string `json:"name"`
// IP address or hostname.
// +kubebuilder:validation:MinLength=1
Address string `json:"address"`
// Percent; sent to Cloudflare as weight/100.
// +kubebuilder:validation:Minimum=0
// +kubebuilder:validation:Maximum=100
// +kubebuilder:default=100
Weight int32 `json:"weight,omitempty"`
// Unset means the default port of the protocol.
// +kubebuilder:validation:Minimum=1
// +kubebuilder:validation:Maximum=65535
Port *int32 `json:"port,omitempty"`
}
// +kubebuilder:validation:XValidation:rule="self.hostname == self.zone || self.hostname.endsWith('.' + self.zone)",message="hostname must be the zone or a name below it"
type CloudflareLoadBalancerSpec struct {
// Zone name, e.g. renner.uno.
// +kubebuilder:validation:Pattern=`^([a-z0-9]([-a-z0-9]*[a-z0-9])?\.)+[a-z]{2,}$`
// +kubebuilder:validation:MaxLength=253
Zone string `json:"zone"`
// +kubebuilder:validation:Pattern=`^([a-z0-9]([-a-z0-9]*[a-z0-9])?\.)+[a-z]{2,}$`
// +kubebuilder:validation:MaxLength=253
// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="hostname is immutable"
Hostname string `json:"hostname"`
// Seconds.
// +kubebuilder:validation:Minimum=1
// +kubebuilder:default=30
TTL int32 `json:"ttl,omitempty"`
// +kubebuilder:validation:Enum=off;random
// +kubebuilder:default=random
SteeringPolicy string `json:"steeringPolicy,omitempty"`
// +kubebuilder:default={}
Monitor MonitorSpec `json:"monitor,omitempty"`
// +listType=map
// +listMapKey=name
// +kubebuilder:validation:MinItems=1
Origins []OriginSpec `json:"origins"`
}
// OriginStatus is the health Cloudflare reports for one origin.
type OriginStatus struct {
Name string `json:"name"`
Healthy *bool `json:"healthy,omitempty"`
FailureReason string `json:"failureReason,omitempty"`
}
type CloudflareLoadBalancerStatus struct {
LoadBalancerID string `json:"loadBalancerID,omitempty"`
PoolID string `json:"poolID,omitempty"`
MonitorID string `json:"monitorID,omitempty"`
// Pool health as reported by Cloudflare; unset until reported.
Healthy *bool `json:"healthy,omitempty"`
// +listType=map
// +listMapKey=name
Origins []OriginStatus `json:"origins,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=cloudflareloadbalancers,scope=Namespaced,shortName=cflb
// +kubebuilder:printcolumn:name="Hostname",type=string,JSONPath=`.spec.hostname`
// +kubebuilder:printcolumn:name="Healthy",type=boolean,JSONPath=`.status.healthy`
// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status`
type CloudflareLoadBalancer struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec CloudflareLoadBalancerSpec `json:"spec"`
Status CloudflareLoadBalancerStatus `json:"status,omitempty"`
}
// +kubebuilder:object:root=true
type CloudflareLoadBalancerList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []CloudflareLoadBalancer `json:"items"`
}
func init() {
SchemeBuilder.Register(&CloudflareLoadBalancer{}, &CloudflareLoadBalancerList{})
}
@@ -1,15 +0,0 @@
// +kubebuilder:object:generate=true
// +groupName=cloudflare.renner.uno
package v1alpha1
import (
"k8s.io/apimachinery/pkg/runtime/schema"
"sigs.k8s.io/controller-runtime/pkg/scheme"
)
var (
GroupVersion = schema.GroupVersion{Group: "cloudflare.renner.uno", Version: "v1alpha1"}
SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion}
AddToScheme = SchemeBuilder.AddToScheme
)
@@ -1,191 +0,0 @@
//go:build envtest
package v1alpha1
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/envtest"
"sigs.k8s.io/yaml"
)
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 lb(name string, mutate func(*CloudflareLoadBalancerSpec)) *CloudflareLoadBalancer {
cl := &CloudflareLoadBalancer{
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default"},
Spec: CloudflareLoadBalancerSpec{
Zone: "renner.uno",
Hostname: "proxy.renner.uno",
Origins: []OriginSpec{{Name: "proxy-1", Address: "proxy-1.renner.uno"}},
},
}
if mutate != nil {
mutate(&cl.Spec)
}
return cl
}
func expectInvalid(t *testing.T, cl *CloudflareLoadBalancer, wants ...string) {
t.Helper()
err := k8s.Create(context.Background(), cl)
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 TestDefaults(t *testing.T) {
ctx := context.Background()
cl := lb("defaults", nil)
if err := k8s.Create(ctx, cl); err != nil {
t.Fatal(err)
}
got := &CloudflareLoadBalancer{}
if err := k8s.Get(ctx, client.ObjectKeyFromObject(cl), got); err != nil {
t.Fatal(err)
}
s := got.Spec
want := MonitorSpec{Type: "https", Port: 443, Path: "/", ExpectedCodes: "200", Interval: 60, Retries: 2, Timeout: 5}
if s.TTL != 30 || s.SteeringPolicy != "random" || s.Monitor != want || s.Origins[0].Weight != 100 || s.Origins[0].Port != nil {
t.Fatalf("spec = %+v", s)
}
}
func TestZoneApexAccepted(t *testing.T) {
cl := lb("apex", func(s *CloudflareLoadBalancerSpec) { s.Hostname = "renner.uno" })
if err := k8s.Create(context.Background(), cl); err != nil {
t.Fatalf("expected accept, got %v", err)
}
}
func TestHostnameImmutable(t *testing.T) {
ctx := context.Background()
cl := lb("immutable", nil)
if err := k8s.Create(ctx, cl); err != nil {
t.Fatal(err)
}
cl.Spec.Hostname = "other.renner.uno"
err := k8s.Update(ctx, cl)
if err == nil || !strings.Contains(err.Error(), "hostname is immutable") {
t.Fatalf("expected immutable error, got %v", err)
}
}
func TestInvalidSpecs(t *testing.T) {
cases := map[string]struct {
mutate func(*CloudflareLoadBalancerSpec)
want string
}{
"hostname outside zone": {func(s *CloudflareLoadBalancerSpec) { s.Hostname = "proxy.example.org" }, "hostname must be the zone or a name below it"},
"suffix not a label": {func(s *CloudflareLoadBalancerSpec) { s.Hostname = "proxyrenner.uno" }, "hostname must be the zone or a name below it"},
"wildcard hostname": {func(s *CloudflareLoadBalancerSpec) { s.Hostname = "*.renner.uno" }, "spec.hostname"},
"uppercase hostname": {func(s *CloudflareLoadBalancerSpec) { s.Hostname = "Proxy.renner.uno" }, "spec.hostname"},
"bad steering": {func(s *CloudflareLoadBalancerSpec) { s.SteeringPolicy = "geo" }, "spec.steeringPolicy"},
"bad monitor type": {func(s *CloudflareLoadBalancerSpec) { s.Monitor.Type = "icmp_ping" }, "spec.monitor.type"},
"tcp with host": {func(s *CloudflareLoadBalancerSpec) {
s.Monitor = MonitorSpec{Type: "tcp", Port: 443, Path: "/", ExpectedCodes: "200", Host: "x", Interval: 60, Timeout: 5}
}, "host is not allowed for type tcp"},
"no origins": {func(s *CloudflareLoadBalancerSpec) { s.Origins = nil }, "spec.origins"},
"weight too high": {func(s *CloudflareLoadBalancerSpec) { s.Origins[0].Weight = 101 }, "spec.origins[0].weight"},
"bad origin name": {func(s *CloudflareLoadBalancerSpec) { s.Origins[0].Name = "a.b" }, "spec.origins[0].name"},
"duplicate origin": {func(s *CloudflareLoadBalancerSpec) {
s.Origins = append(s.Origins, OriginSpec{Name: "proxy-1", Address: "x"})
}, "spec.origins"},
"origin port zero": {func(s *CloudflareLoadBalancerSpec) { p := int32(0); s.Origins[0].Port = &p }, "spec.origins[0].port"},
}
i := 0
for name, c := range cases {
i++
t.Run(name, func(t *testing.T) {
expectInvalid(t, lb(fmt.Sprintf("invalid-%d", i), c.mutate), c.want)
})
}
}
// 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()
cl := lb("deletion-gen", nil)
cl.Finalizers = []string{"test/finalizer"}
if err := k8s.Create(ctx, cl); err != nil {
t.Fatal(err)
}
before := cl.Generation
if err := k8s.Delete(ctx, cl); err != nil {
t.Fatal(err)
}
got := &CloudflareLoadBalancer{}
if err := k8s.Get(ctx, client.ObjectKeyFromObject(cl), 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)
}
}
// The load balancer deployed by Flux must pass the CRD schema.
func TestDeployedLoadBalancerValid(t *testing.T) {
raw, err := os.ReadFile(filepath.Join("..", "..", "..", "..", "..", "kubernetes", "apps", "cloudflare-lb-sync", "proxy-renner-uno.yaml"))
if err != nil {
t.Fatal(err)
}
cl := &CloudflareLoadBalancer{}
if err := yaml.UnmarshalStrict(raw, cl); err != nil {
t.Fatal(err)
}
ns := &unstructured.Unstructured{}
ns.SetAPIVersion("v1")
ns.SetKind("Namespace")
ns.SetName(cl.Namespace)
if err := k8s.Create(context.Background(), ns); err != nil && !errors.IsAlreadyExists(err) {
t.Fatal(err)
}
if err := k8s.Create(context.Background(), cl, client.DryRunAll); err != nil {
t.Fatalf("deployed load balancer rejected: %v", err)
}
}
@@ -1,181 +0,0 @@
//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 *CloudflareLoadBalancer) DeepCopyInto(out *CloudflareLoadBalancer) {
*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 CloudflareLoadBalancer.
func (in *CloudflareLoadBalancer) DeepCopy() *CloudflareLoadBalancer {
if in == nil {
return nil
}
out := new(CloudflareLoadBalancer)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *CloudflareLoadBalancer) 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 *CloudflareLoadBalancerList) DeepCopyInto(out *CloudflareLoadBalancerList) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ListMeta.DeepCopyInto(&out.ListMeta)
if in.Items != nil {
in, out := &in.Items, &out.Items
*out = make([]CloudflareLoadBalancer, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CloudflareLoadBalancerList.
func (in *CloudflareLoadBalancerList) DeepCopy() *CloudflareLoadBalancerList {
if in == nil {
return nil
}
out := new(CloudflareLoadBalancerList)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *CloudflareLoadBalancerList) 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 *CloudflareLoadBalancerSpec) DeepCopyInto(out *CloudflareLoadBalancerSpec) {
*out = *in
out.Monitor = in.Monitor
if in.Origins != nil {
in, out := &in.Origins, &out.Origins
*out = make([]OriginSpec, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CloudflareLoadBalancerSpec.
func (in *CloudflareLoadBalancerSpec) DeepCopy() *CloudflareLoadBalancerSpec {
if in == nil {
return nil
}
out := new(CloudflareLoadBalancerSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *CloudflareLoadBalancerStatus) DeepCopyInto(out *CloudflareLoadBalancerStatus) {
*out = *in
if in.Healthy != nil {
in, out := &in.Healthy, &out.Healthy
*out = new(bool)
**out = **in
}
if in.Origins != nil {
in, out := &in.Origins, &out.Origins
*out = make([]OriginStatus, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
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 CloudflareLoadBalancerStatus.
func (in *CloudflareLoadBalancerStatus) DeepCopy() *CloudflareLoadBalancerStatus {
if in == nil {
return nil
}
out := new(CloudflareLoadBalancerStatus)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *MonitorSpec) DeepCopyInto(out *MonitorSpec) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MonitorSpec.
func (in *MonitorSpec) DeepCopy() *MonitorSpec {
if in == nil {
return nil
}
out := new(MonitorSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *OriginSpec) DeepCopyInto(out *OriginSpec) {
*out = *in
if in.Port != nil {
in, out := &in.Port, &out.Port
*out = new(int32)
**out = **in
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OriginSpec.
func (in *OriginSpec) DeepCopy() *OriginSpec {
if in == nil {
return nil
}
out := new(OriginSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *OriginStatus) DeepCopyInto(out *OriginStatus) {
*out = *in
if in.Healthy != nil {
in, out := &in.Healthy, &out.Healthy
*out = new(bool)
**out = **in
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OriginStatus.
func (in *OriginStatus) DeepCopy() *OriginStatus {
if in == nil {
return nil
}
out := new(OriginStatus)
in.DeepCopyInto(out)
return out
}
@@ -1,28 +0,0 @@
package main
import (
"errors"
"fmt"
)
type config struct {
APIToken string
}
// loadConfig reads the Cloudflare 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
}{
{"CLOUDFLARE_API_TOKEN", &cfg.APIToken},
} {
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...)
}
@@ -1,22 +0,0 @@
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] }
}
cfg, err := loadConfig(env(map[string]string{"CLOUDFLARE_API_TOKEN": "tok"}))
if err != nil || cfg != (config{APIToken: "tok"}) {
t.Fatalf("cfg=%+v err=%v", cfg, err)
}
_, err = loadConfig(env(nil))
if err == nil || !strings.Contains(err.Error(), "CLOUDFLARE_API_TOKEN") {
t.Errorf("missing: err = %v, want it to name the variable", err)
}
}
@@ -1,42 +0,0 @@
// Command cloudflare-lb-sync reconciles CloudflareLoadBalancer resources into
// Cloudflare load balancers.
package main
import (
"fmt"
"os"
"time"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/log/zap"
"gitea.renner.uno/konairius/homelab/cloudflare-lb-sync/internal/app"
"gitea.renner.uno/konairius/homelab/cloudflare-lb-sync/internal/cfclient"
)
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "cloudflare-lb-sync:", err)
os.Exit(1)
}
}
func run() error {
cfg, err := loadConfig(os.Getenv)
if err != nil {
return err
}
ctrl.SetLogger(zap.New())
mgr, err := app.NewManager(app.Options{
Config: ctrl.GetConfigOrDie(),
Cloudflare: cfclient.NewSDK(cfg.APIToken),
MetricsAddr: ":8080",
ProbeAddr: ":8081",
Resync: 2 * time.Minute,
})
if err != nil {
return err
}
return mgr.Start(ctrl.SetupSignalHandler())
}
@@ -1,268 +0,0 @@
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.23.0
name: cloudflareloadbalancers.cloudflare.renner.uno
spec:
group: cloudflare.renner.uno
names:
kind: CloudflareLoadBalancer
listKind: CloudflareLoadBalancerList
plural: cloudflareloadbalancers
shortNames:
- cflb
singular: cloudflareloadbalancer
scope: Namespaced
versions:
- additionalPrinterColumns:
- jsonPath: .spec.hostname
name: Hostname
type: string
- jsonPath: .status.healthy
name: Healthy
type: boolean
- 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:
hostname:
maxLength: 253
pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?\.)+[a-z]{2,}$
type: string
x-kubernetes-validations:
- message: hostname is immutable
rule: self == oldSelf
monitor:
default: {}
description: MonitorSpec is the health monitor attached to the pool.
properties:
expectedCodes:
default: "200"
description: Ignored for type tcp.
minLength: 1
type: string
host:
description: Host header sent with the check.
minLength: 1
type: string
interval:
default: 60
description: Seconds.
format: int32
minimum: 1
type: integer
path:
default: /
description: Ignored for type tcp.
minLength: 1
type: string
port:
default: 443
format: int32
maximum: 65535
minimum: 1
type: integer
retries:
default: 2
format: int32
minimum: 0
type: integer
timeout:
default: 5
description: Seconds.
format: int32
minimum: 1
type: integer
type:
default: https
enum:
- http
- https
- tcp
type: string
type: object
x-kubernetes-validations:
- message: host is not allowed for type tcp
rule: self.type != 'tcp' || !has(self.host)
origins:
items:
description: OriginSpec is one origin of the pool.
properties:
address:
description: IP address or hostname.
minLength: 1
type: string
name:
maxLength: 63
pattern: ^[A-Za-z0-9_-]+$
type: string
port:
description: Unset means the default port of the protocol.
format: int32
maximum: 65535
minimum: 1
type: integer
weight:
default: 100
description: Percent; sent to Cloudflare as weight/100.
format: int32
maximum: 100
minimum: 0
type: integer
required:
- address
- name
type: object
minItems: 1
type: array
x-kubernetes-list-map-keys:
- name
x-kubernetes-list-type: map
steeringPolicy:
default: random
enum:
- "off"
- random
type: string
ttl:
default: 30
description: Seconds.
format: int32
minimum: 1
type: integer
zone:
description: Zone name, e.g. renner.uno.
maxLength: 253
pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?\.)+[a-z]{2,}$
type: string
required:
- hostname
- origins
- zone
type: object
x-kubernetes-validations:
- message: hostname must be the zone or a name below it
rule: self.hostname == self.zone || self.hostname.endsWith('.' + self.zone)
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
healthy:
description: Pool health as reported by Cloudflare; unset until reported.
type: boolean
loadBalancerID:
type: string
monitorID:
type: string
observedGeneration:
format: int64
type: integer
origins:
items:
description: OriginStatus is the health Cloudflare reports for one
origin.
properties:
failureReason:
type: string
healthy:
type: boolean
name:
type: string
required:
- name
type: object
type: array
x-kubernetes-list-map-keys:
- name
x-kubernetes-list-type: map
poolID:
type: string
type: object
required:
- spec
type: object
served: true
storage: true
subresources:
status: {}
@@ -1,37 +0,0 @@
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: cloudflare-lb-sync
rules:
- apiGroups:
- ""
resources:
- events
verbs:
- create
- patch
- apiGroups:
- cloudflare.renner.uno
resources:
- cloudflareloadbalancers
verbs:
- get
- list
- patch
- update
- watch
- apiGroups:
- cloudflare.renner.uno
resources:
- cloudflareloadbalancers/finalizers
verbs:
- update
- apiGroups:
- cloudflare.renner.uno
resources:
- cloudflareloadbalancers/status
verbs:
- get
- patch
- update
-77
View File
@@ -1,77 +0,0 @@
module gitea.renner.uno/konairius/homelab/cloudflare-lb-sync
go 1.27
require (
github.com/cloudflare/cloudflare-go/v7 v7.13.1
k8s.io/apimachinery v0.37.1
k8s.io/client-go v0.37.1
sigs.k8s.io/controller-runtime v0.25.2
sigs.k8s.io/yaml v1.6.0
)
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
github.com/evanphx/json-patch/v5 v5.9.11 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.1 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/zapr 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/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/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // 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/spf13/pflag v1.0.10 // indirect
github.com/tidwall/gjson v1.14.4 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.1 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/net v0.57.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.40.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/api v0.37.1 // indirect
k8s.io/apiextensions-apiserver v0.37.0 // indirect
k8s.io/klog/v2 v2.140.0 // indirect
k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad // indirect
k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 // 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
)
-195
View File
@@ -1,195 +0,0 @@
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
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/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudflare/cloudflare-go/v7 v7.13.1 h1:fRe8JpKifizF3WafGmTQiI5+lNVZht/khSDL2g9KrOs=
github.com/cloudflare/cloudflare-go/v7 v7.13.1/go.mod h1:nHsJfu5TjVpYVJVpmRhB4WJ6Dd91fckUGgw5t9fKe54=
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/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/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
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.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
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-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/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/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.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ=
github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
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/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/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/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/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM=
github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
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.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
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.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
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.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.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
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.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
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/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
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.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
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.0 h1:zRMQ3+/LIE5oZ0tVvXwYHC+dIkSP5cjNWju7AZU1LOI=
k8s.io/apiextensions-apiserver v0.37.0/go.mod h1:HU0PfSBwchHL5iDau6jjt9zU6ryWkDDlaVUiq91NK80=
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=
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=
@@ -1,77 +0,0 @@
// Package app wires the controller-runtime manager.
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"
ctrl "sigs.k8s.io/controller-runtime"
"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/konairius/homelab/cloudflare-lb-sync/api/v1alpha1"
"gitea.renner.uno/konairius/homelab/cloudflare-lb-sync/internal/cfclient"
"gitea.renner.uno/konairius/homelab/cloudflare-lb-sync/internal/controller"
)
// Options configures NewManager.
type Options struct {
Config *rest.Config
Cloudflare cfclient.Client
// MetricsAddr and ProbeAddr are bind addresses; "0" and "" disable them.
MetricsAddr string
ProbeAddr string
// Resync is how often a healthy resource is reconciled again, which is
// what repairs drift made in Cloudflare and refreshes origin health; 0
// means 2 minutes.
Resync time.Duration
}
// NewManager builds the manager with the reconciler.
// There is no leader election: a single replica runs.
func NewManager(o Options) (manager.Manager, error) {
scheme := runtime.NewScheme()
if err := clientgoscheme.AddToScheme(scheme); err != nil {
return nil, err
}
if err := v1.AddToScheme(scheme); err != nil {
return nil, err
}
mgr, err := ctrl.NewManager(o.Config, ctrl.Options{
Scheme: scheme,
Metrics: metricsserver.Options{BindAddress: o.MetricsAddr},
HealthProbeBindAddress: o.ProbeAddr,
LeaderElection: false,
})
if err != nil {
return nil, fmt.Errorf("create manager: %w", err)
}
if err := (&controller.Reconciler{Client: mgr.GetClient(), CF: o.Cloudflare, Resync: o.Resync}).SetupWithManager(mgr); err != nil {
return nil, fmt.Errorf("set up reconciler: %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
}
@@ -1,164 +0,0 @@
// Package cfclient is the boundary to the Cloudflare Load Balancing API.
package cfclient
import (
"context"
"errors"
"maps"
"slices"
"strings"
)
// ErrNotFound means the zone or object does not exist.
var ErrNotFound = errors.New("not found")
// Zone is a Cloudflare zone and the account that holds it.
type Zone struct {
ID string
AccountID string
}
// Monitor is the domain view of a load balancer monitor.
type Monitor struct {
ID string
Type string
Port int64
Path string
Method string
ExpectedCodes string
Header map[string][]string
Interval int64
Retries int64
Timeout int64
Description string
AllowInsecure bool
FollowRedirects bool
}
// Origin is one origin of a pool.
type Origin struct {
Name string
Address string
Weight float64
Port int64
Enabled bool
}
// Pool is the domain view of a load balancer pool.
type Pool struct {
ID string
Name string
Description string
Monitor string
Enabled bool
MinimumOrigins int64
Origins []Origin
// Health is read-only; writes ignore it.
Health PoolHealth
}
// PoolHealth is the health Cloudflare reports with a pool. Nil means not
// reported.
type PoolHealth struct {
Healthy *bool
Origins map[string]OriginHealth
}
// OriginHealth is the health of one origin, keyed by origin name in PoolHealth.
type OriginHealth struct {
Healthy *bool
FailureReason string
}
// LoadBalancer is the domain view of a zone load balancer.
type LoadBalancer struct {
ID string
Name string
Description string
DefaultPools []string
FallbackPool string
Proxied bool
Enabled bool
TTL float64
SteeringPolicy string
}
// Reference is an object that refers to a pool or monitor: a load balancer
// for a pool, a pool for a monitor.
type Reference struct {
ResourceType string
ResourceID string
}
// Client is the subset of the Cloudflare API the controller uses. Every method
// wraps ErrNotFound when the zone or object does not exist.
type Client interface {
// Zone looks a zone up by name.
Zone(ctx context.Context, name string) (Zone, error)
ListLoadBalancers(ctx context.Context, zoneID string) ([]LoadBalancer, error)
CreateLoadBalancer(ctx context.Context, zoneID string, lb LoadBalancer) (string, error)
// UpdateLoadBalancer replaces every managed field of lb.ID.
UpdateLoadBalancer(ctx context.Context, zoneID string, lb LoadBalancer) error
DeleteLoadBalancer(ctx context.Context, zoneID, id string) error
ListPools(ctx context.Context, accountID string) ([]Pool, error)
// GetPool returns the pool with its health.
GetPool(ctx context.Context, accountID, id string) (Pool, error)
CreatePool(ctx context.Context, accountID string, p Pool) (string, error)
// UpdatePool replaces every managed field of p.ID.
UpdatePool(ctx context.Context, accountID string, p Pool) error
DeletePool(ctx context.Context, accountID, id string) error
// PoolReferrers returns the objects that use the pool.
PoolReferrers(ctx context.Context, accountID, id string) ([]Reference, error)
ListMonitors(ctx context.Context, accountID string) ([]Monitor, error)
GetMonitor(ctx context.Context, accountID, id string) (Monitor, error)
CreateMonitor(ctx context.Context, accountID string, m Monitor) (string, error)
// UpdateMonitor replaces every managed field of m.ID.
UpdateMonitor(ctx context.Context, accountID string, m Monitor) error
DeleteMonitor(ctx context.Context, accountID, id string) error
// MonitorReferrers returns the objects that use the monitor.
MonitorReferrers(ctx context.Context, accountID, id string) ([]Reference, error)
}
// IsTCP reports whether the monitor checks a bare TCP connection.
func (m Monitor) IsTCP() bool { return m.Type == "tcp" }
// Managed returns the fields the controller writes, ID cleared, so a desired
// monitor compares equal to an observed one. Type tcp has no path, expected
// codes or header; an empty header is nil.
func (m Monitor) Managed() Monitor {
m.ID = ""
m.Header = maps.Clone(m.Header)
if len(m.Header) == 0 {
m.Header = nil
}
if m.IsTCP() {
m.Path, m.ExpectedCodes, m.Header = "", "", nil
}
return m
}
// Managed returns the fields the controller writes, ID and health cleared and
// origins sorted by name, so a desired pool compares equal to an observed one.
func (p Pool) Managed() Pool {
p.ID = ""
p.Health = PoolHealth{}
p.Origins = slices.Clone(p.Origins)
slices.SortFunc(p.Origins, func(a, b Origin) int { return strings.Compare(a.Name, b.Name) })
if len(p.Origins) == 0 {
p.Origins = nil
}
return p
}
// Managed returns the fields the controller writes, ID cleared.
func (lb LoadBalancer) Managed() LoadBalancer {
lb.ID = ""
lb.DefaultPools = slices.Clone(lb.DefaultPools)
if len(lb.DefaultPools) == 0 {
lb.DefaultPools = nil
}
return lb
}
@@ -1,59 +0,0 @@
package cfclient
import (
"reflect"
"testing"
)
func TestPoolHealth(t *testing.T) {
raw := `{"id":"p","healthy":true,"origins":[
{"name":"proxy-1","address":"proxy-1.renner.uno","healthy":true},
{"name":"proxy-2","address":"proxy-2.renner.uno","healthy":false,"failure_reason":"HTTP timeout occurred"},
{"name":"proxy-3","address":"proxy-3.renner.uno"}]}`
got, err := poolHealth(raw)
if err != nil {
t.Fatal(err)
}
yes, no := true, false
want := PoolHealth{Healthy: &yes, Origins: map[string]OriginHealth{
"proxy-1": {Healthy: &yes},
"proxy-2": {Healthy: &no, FailureReason: "HTTP timeout occurred"},
}}
if !reflect.DeepEqual(got, want) {
t.Errorf("got %+v, want %+v", got, want)
}
got, err = poolHealth(`{"id":"p","origins":[]}`)
if err != nil || got.Healthy != nil || got.Origins != nil {
t.Errorf("unreported health: got %+v err %v", got, err)
}
if got, err := poolHealth(""); err != nil || !reflect.DeepEqual(got, PoolHealth{}) {
t.Errorf("empty: got %+v err %v", got, err)
}
if _, err := poolHealth("{"); err == nil {
t.Error("want decode error")
}
}
func TestMonitorManaged(t *testing.T) {
m := Monitor{ID: "x", Type: "tcp", Path: "/", ExpectedCodes: "200", Header: map[string][]string{"Host": {"h"}}}
if got := m.Managed(); got.ID != "" || got.Path != "" || got.ExpectedCodes != "" || got.Header != nil {
t.Errorf("tcp: %+v", got)
}
m = Monitor{Type: "https", Header: map[string][]string{}}
if got := m.Managed(); got.Header != nil {
t.Errorf("empty header: %+v", got)
}
}
func TestPoolManagedSortsOrigins(t *testing.T) {
yes := true
a := Pool{ID: "1", Origins: []Origin{{Name: "b"}, {Name: "a"}}, Health: PoolHealth{Healthy: &yes}}
b := Pool{Origins: []Origin{{Name: "a"}, {Name: "b"}}}
if !reflect.DeepEqual(a.Managed(), b.Managed()) {
t.Errorf("%+v != %+v", a.Managed(), b.Managed())
}
if a.Origins[0].Name != "b" {
t.Error("Managed changed its receiver's origins")
}
}
@@ -1,347 +0,0 @@
package cfclient
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"github.com/cloudflare/cloudflare-go/v7"
lb "github.com/cloudflare/cloudflare-go/v7/load_balancers"
"github.com/cloudflare/cloudflare-go/v7/option"
"github.com/cloudflare/cloudflare-go/v7/zones"
)
// referral is the reference type of an object the queried one uses itself,
// such as a pool's monitor. Every other type is an object that uses it.
const referral = "referral"
// SDK implements Client with the official Cloudflare SDK.
type SDK struct {
c *cloudflare.Client
}
var _ Client = (*SDK)(nil)
// NewSDK returns a client authenticated with an API token.
func NewSDK(token string, opts ...option.RequestOption) *SDK {
return &SDK{c: cloudflare.NewClient(append([]option.RequestOption{option.WithAPIToken(token)}, opts...)...)}
}
// wrap marks a 404 with ErrNotFound.
func wrap(op string, err error) error {
if err == nil {
return nil
}
var apiErr *cloudflare.Error
if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound {
return fmt.Errorf("%s: %w: %w", op, ErrNotFound, err)
}
return fmt.Errorf("%s: %w", op, err)
}
func (s *SDK) Zone(ctx context.Context, name string) (Zone, error) {
it := s.c.Zones.ListAutoPaging(ctx, zones.ZoneListParams{Name: cloudflare.F(name)})
for it.Next() {
if z := it.Current(); z.Name == name {
return Zone{ID: z.ID, AccountID: z.Account.ID}, nil
}
}
if err := it.Err(); err != nil {
return Zone{}, wrap("list zones", err)
}
return Zone{}, fmt.Errorf("zone %s: %w", name, ErrNotFound)
}
func fromLoadBalancer(l lb.LoadBalancer) LoadBalancer {
return LoadBalancer{
ID: l.ID,
Name: l.Name,
Description: l.Description,
DefaultPools: append([]string(nil), l.DefaultPools...),
FallbackPool: l.FallbackPool,
Proxied: l.Proxied,
Enabled: l.Enabled,
TTL: l.TTL,
SteeringPolicy: string(l.SteeringPolicy),
}
}
func (s *SDK) ListLoadBalancers(ctx context.Context, zoneID string) ([]LoadBalancer, error) {
var out []LoadBalancer
it := s.c.LoadBalancers.ListAutoPaging(ctx, lb.LoadBalancerListParams{ZoneID: cloudflare.F(zoneID)})
for it.Next() {
out = append(out, fromLoadBalancer(it.Current()))
}
return out, wrap("list load balancers", it.Err())
}
func (s *SDK) CreateLoadBalancer(ctx context.Context, zoneID string, l LoadBalancer) (string, error) {
res, err := s.c.LoadBalancers.New(ctx, lb.LoadBalancerNewParams{
ZoneID: cloudflare.F(zoneID),
Name: cloudflare.F(l.Name),
DefaultPools: cloudflare.F(l.DefaultPools),
FallbackPool: cloudflare.F(l.FallbackPool),
Description: cloudflare.F(l.Description),
Enabled: cloudflare.F(l.Enabled),
Proxied: cloudflare.F(l.Proxied),
SteeringPolicy: cloudflare.F(lb.SteeringPolicy(l.SteeringPolicy)),
TTL: cloudflare.F(l.TTL),
})
if err != nil {
return "", wrap("create load balancer", err)
}
return res.ID, nil
}
func (s *SDK) UpdateLoadBalancer(ctx context.Context, zoneID string, l LoadBalancer) error {
_, err := s.c.LoadBalancers.Update(ctx, l.ID, lb.LoadBalancerUpdateParams{
ZoneID: cloudflare.F(zoneID),
Name: cloudflare.F(l.Name),
DefaultPools: cloudflare.F(l.DefaultPools),
FallbackPool: cloudflare.F(l.FallbackPool),
Description: cloudflare.F(l.Description),
Enabled: cloudflare.F(l.Enabled),
Proxied: cloudflare.F(l.Proxied),
SteeringPolicy: cloudflare.F(lb.SteeringPolicy(l.SteeringPolicy)),
TTL: cloudflare.F(l.TTL),
})
return wrap("update load balancer "+l.ID, err)
}
func (s *SDK) DeleteLoadBalancer(ctx context.Context, zoneID, id string) error {
_, err := s.c.LoadBalancers.Delete(ctx, id, lb.LoadBalancerDeleteParams{ZoneID: cloudflare.F(zoneID)})
return wrap("delete load balancer "+id, err)
}
func fromPool(p lb.Pool) Pool {
out := Pool{
ID: p.ID,
Name: p.Name,
Description: p.Description,
Monitor: p.Monitor,
Enabled: p.Enabled,
MinimumOrigins: p.MinimumOrigins,
}
for _, o := range p.Origins {
out.Origins = append(out.Origins, Origin{
Name: o.Name, Address: o.Address, Weight: o.Weight, Port: o.Port, Enabled: o.Enabled,
})
}
return out
}
// poolHealth reads the health fields the SDK's Pool struct does not declare
// from the raw response JSON.
func poolHealth(raw string) (PoolHealth, error) {
var doc struct {
Healthy *bool `json:"healthy"`
Origins []struct {
Name string `json:"name"`
Healthy *bool `json:"healthy"`
FailureReason string `json:"failure_reason"`
} `json:"origins"`
}
if raw == "" {
return PoolHealth{}, nil
}
if err := json.Unmarshal([]byte(raw), &doc); err != nil {
return PoolHealth{}, fmt.Errorf("decode pool health: %w", err)
}
h := PoolHealth{Healthy: doc.Healthy}
for _, o := range doc.Origins {
if o.Healthy == nil && o.FailureReason == "" {
continue
}
if h.Origins == nil {
h.Origins = map[string]OriginHealth{}
}
h.Origins[o.Name] = OriginHealth{Healthy: o.Healthy, FailureReason: o.FailureReason}
}
return h, nil
}
func (s *SDK) ListPools(ctx context.Context, accountID string) ([]Pool, error) {
var out []Pool
it := s.c.LoadBalancers.Pools.ListAutoPaging(ctx, lb.PoolListParams{AccountID: cloudflare.F(accountID)})
for it.Next() {
out = append(out, fromPool(it.Current()))
}
return out, wrap("list pools", it.Err())
}
func (s *SDK) GetPool(ctx context.Context, accountID, id string) (Pool, error) {
res, err := s.c.LoadBalancers.Pools.Get(ctx, id, lb.PoolGetParams{AccountID: cloudflare.F(accountID)})
if err != nil {
return Pool{}, wrap("get pool "+id, err)
}
p := fromPool(*res)
if p.Health, err = poolHealth(res.JSON.RawJSON()); err != nil {
return Pool{}, fmt.Errorf("get pool %s: %w", id, err)
}
return p, nil
}
func originParams(p Pool) []lb.OriginParam {
out := make([]lb.OriginParam, 0, len(p.Origins))
for _, o := range p.Origins {
out = append(out, lb.OriginParam{
Name: cloudflare.F(o.Name),
Address: cloudflare.F(o.Address),
Weight: cloudflare.F(o.Weight),
Port: cloudflare.F(o.Port),
Enabled: cloudflare.F(o.Enabled),
})
}
return out
}
func (s *SDK) CreatePool(ctx context.Context, accountID string, p Pool) (string, error) {
res, err := s.c.LoadBalancers.Pools.New(ctx, lb.PoolNewParams{
AccountID: cloudflare.F(accountID),
Name: cloudflare.F(p.Name),
Origins: cloudflare.F(originParams(p)),
Description: cloudflare.F(p.Description),
Enabled: cloudflare.F(p.Enabled),
MinimumOrigins: cloudflare.F(p.MinimumOrigins),
Monitor: cloudflare.F(p.Monitor),
})
if err != nil {
return "", wrap("create pool", err)
}
return res.ID, nil
}
func (s *SDK) UpdatePool(ctx context.Context, accountID string, p Pool) error {
_, err := s.c.LoadBalancers.Pools.Update(ctx, p.ID, lb.PoolUpdateParams{
AccountID: cloudflare.F(accountID),
Name: cloudflare.F(p.Name),
Origins: cloudflare.F(originParams(p)),
Description: cloudflare.F(p.Description),
Enabled: cloudflare.F(p.Enabled),
MinimumOrigins: cloudflare.F(p.MinimumOrigins),
Monitor: cloudflare.F(p.Monitor),
})
return wrap("update pool "+p.ID, err)
}
func (s *SDK) DeletePool(ctx context.Context, accountID, id string) error {
_, err := s.c.LoadBalancers.Pools.Delete(ctx, id, lb.PoolDeleteParams{AccountID: cloudflare.F(accountID)})
return wrap("delete pool "+id, err)
}
func (s *SDK) PoolReferrers(ctx context.Context, accountID, id string) ([]Reference, error) {
var out []Reference
it := s.c.LoadBalancers.Pools.References.GetAutoPaging(ctx, id, lb.PoolReferenceGetParams{AccountID: cloudflare.F(accountID)})
for it.Next() {
if r := it.Current(); string(r.ReferenceType) != referral {
out = append(out, Reference{ResourceType: r.ResourceType, ResourceID: r.ResourceID})
}
}
return out, wrap("pool references "+id, it.Err())
}
func fromMonitor(m lb.Monitor) Monitor {
return Monitor{
ID: m.ID,
Type: string(m.Type),
Port: m.Port,
Path: m.Path,
Method: m.Method,
ExpectedCodes: m.ExpectedCodes,
Header: m.Header,
Interval: m.Interval,
Retries: m.Retries,
Timeout: m.Timeout,
Description: m.Description,
AllowInsecure: m.AllowInsecure,
FollowRedirects: m.FollowRedirects,
}
}
func (s *SDK) ListMonitors(ctx context.Context, accountID string) ([]Monitor, error) {
var out []Monitor
it := s.c.LoadBalancers.Monitors.ListAutoPaging(ctx, lb.MonitorListParams{AccountID: cloudflare.F(accountID)})
for it.Next() {
out = append(out, fromMonitor(it.Current()))
}
return out, wrap("list monitors", it.Err())
}
func (s *SDK) GetMonitor(ctx context.Context, accountID, id string) (Monitor, error) {
res, err := s.c.LoadBalancers.Monitors.Get(ctx, id, lb.MonitorGetParams{AccountID: cloudflare.F(accountID)})
if err != nil {
return Monitor{}, wrap("get monitor "+id, err)
}
return fromMonitor(*res), nil
}
// The SDK has one params type per call; both carry the same fields.
func (s *SDK) CreateMonitor(ctx context.Context, accountID string, m Monitor) (string, error) {
p := lb.MonitorNewParams{
AccountID: cloudflare.F(accountID),
Type: cloudflare.F(lb.MonitorNewParamsType(m.Type)),
Port: cloudflare.F(m.Port),
Method: cloudflare.F(m.Method),
Interval: cloudflare.F(m.Interval),
Retries: cloudflare.F(m.Retries),
Timeout: cloudflare.F(m.Timeout),
Description: cloudflare.F(m.Description),
AllowInsecure: cloudflare.F(m.AllowInsecure),
FollowRedirects: cloudflare.F(m.FollowRedirects),
}
if !m.IsTCP() {
p.Path = cloudflare.F(m.Path)
p.ExpectedCodes = cloudflare.F(m.ExpectedCodes)
if len(m.Header) > 0 {
p.Header = cloudflare.F(m.Header)
}
}
res, err := s.c.LoadBalancers.Monitors.New(ctx, p)
if err != nil {
return "", wrap("create monitor", err)
}
return res.ID, nil
}
func (s *SDK) UpdateMonitor(ctx context.Context, accountID string, m Monitor) error {
p := lb.MonitorUpdateParams{
AccountID: cloudflare.F(accountID),
Type: cloudflare.F(lb.MonitorUpdateParamsType(m.Type)),
Port: cloudflare.F(m.Port),
Method: cloudflare.F(m.Method),
Interval: cloudflare.F(m.Interval),
Retries: cloudflare.F(m.Retries),
Timeout: cloudflare.F(m.Timeout),
Description: cloudflare.F(m.Description),
AllowInsecure: cloudflare.F(m.AllowInsecure),
FollowRedirects: cloudflare.F(m.FollowRedirects),
}
if !m.IsTCP() {
p.Path = cloudflare.F(m.Path)
p.ExpectedCodes = cloudflare.F(m.ExpectedCodes)
if len(m.Header) > 0 {
p.Header = cloudflare.F(m.Header)
}
}
_, err := s.c.LoadBalancers.Monitors.Update(ctx, m.ID, p)
return wrap("update monitor "+m.ID, err)
}
func (s *SDK) DeleteMonitor(ctx context.Context, accountID, id string) error {
_, err := s.c.LoadBalancers.Monitors.Delete(ctx, id, lb.MonitorDeleteParams{AccountID: cloudflare.F(accountID)})
return wrap("delete monitor "+id, err)
}
func (s *SDK) MonitorReferrers(ctx context.Context, accountID, id string) ([]Reference, error) {
var out []Reference
it := s.c.LoadBalancers.Monitors.References.GetAutoPaging(ctx, id, lb.MonitorReferenceGetParams{AccountID: cloudflare.F(accountID)})
for it.Next() {
if r := it.Current(); string(r.ReferenceType) != referral {
out = append(out, Reference{ResourceType: r.ResourceType, ResourceID: r.ResourceID})
}
}
return out, wrap("monitor references "+id, it.Err())
}
@@ -1,461 +0,0 @@
package cfclient
import (
"context"
"fmt"
"maps"
"slices"
"sync"
)
// Fake is an in-memory Client safe for concurrent use. It holds one account
// with any number of zones. Err, when set, is returned by every method.
type Fake struct {
mu sync.Mutex
zones map[string]Zone // by name
lbs map[string]fakeLB
pools map[string]Pool
monitors map[string]Monitor
nextID int
Err error
// Calls records every write as "<verb> <kind> <id>", in order.
Calls []string
}
type fakeLB struct {
zoneID string
lb LoadBalancer
}
var _ Client = (*Fake)(nil)
func NewFake() *Fake {
return &Fake{
zones: map[string]Zone{}, lbs: map[string]fakeLB{}, pools: map[string]Pool{}, monitors: map[string]Monitor{},
}
}
func (f *Fake) id(kind string) string {
f.nextID++
return fmt.Sprintf("%s-%d", kind, f.nextID)
}
// AddZone adds a zone and returns it.
func (f *Fake) AddZone(name, accountID string) Zone {
f.mu.Lock()
defer f.mu.Unlock()
z := Zone{ID: f.id("zone"), AccountID: accountID}
f.zones[name] = z
return z
}
// PutLoadBalancer stores lb in zoneID, assigning an ID when empty.
func (f *Fake) PutLoadBalancer(zoneID string, lb LoadBalancer) string {
f.mu.Lock()
defer f.mu.Unlock()
if lb.ID == "" {
lb.ID = f.id("lb")
}
f.lbs[lb.ID] = fakeLB{zoneID: zoneID, lb: lb.Managed().withID(lb.ID)}
return lb.ID
}
// PutPool stores p, health included, assigning an ID when empty.
func (f *Fake) PutPool(p Pool) string {
f.mu.Lock()
defer f.mu.Unlock()
id, h := p.ID, p.Health
if id == "" {
id = f.id("pool")
}
p = p.Managed()
p.ID, p.Health = id, h
f.pools[id] = clonePool(p)
return id
}
// PutMonitor stores m, assigning an ID when empty.
func (f *Fake) PutMonitor(m Monitor) string {
f.mu.Lock()
defer f.mu.Unlock()
if m.ID == "" {
m.ID = f.id("monitor")
}
id := m.ID
m = m.Managed()
m.ID = id
f.monitors[id] = m
return id
}
func (lb LoadBalancer) withID(id string) LoadBalancer {
lb.ID = id
return lb
}
// LoadBalancer returns a copy of the load balancer, or nil.
func (f *Fake) LoadBalancer(id string) *LoadBalancer {
f.mu.Lock()
defer f.mu.Unlock()
l, ok := f.lbs[id]
if !ok {
return nil
}
c := l.lb.Managed().withID(id)
return &c
}
// Pool returns a copy of the pool, or nil.
func (f *Fake) Pool(id string) *Pool {
f.mu.Lock()
defer f.mu.Unlock()
p, ok := f.pools[id]
if !ok {
return nil
}
c := clonePool(p)
return &c
}
// Monitor returns a copy of the monitor, or nil.
func (f *Fake) Monitor(id string) *Monitor {
f.mu.Lock()
defer f.mu.Unlock()
m, ok := f.monitors[id]
if !ok {
return nil
}
m.Header = maps.Clone(m.Header)
return &m
}
// Counts returns the number of load balancers, pools and monitors.
func (f *Fake) Counts() (lbs, pools, monitors int) {
f.mu.Lock()
defer f.mu.Unlock()
return len(f.lbs), len(f.pools), len(f.monitors)
}
// SetHealth sets the health GetPool reports for a pool.
func (f *Fake) SetHealth(poolID string, h PoolHealth) {
f.mu.Lock()
defer f.mu.Unlock()
if p, ok := f.pools[poolID]; ok {
p.Health = h
f.pools[poolID] = p
}
}
// SetErr makes every method return err; nil clears it.
func (f *Fake) SetErr(err error) {
f.mu.Lock()
defer f.mu.Unlock()
f.Err = err
}
// Writes returns a copy of Calls and clears it.
func (f *Fake) Writes() []string {
f.mu.Lock()
defer f.mu.Unlock()
c := f.Calls
f.Calls = nil
return c
}
func clonePool(p Pool) Pool {
p.Origins = slices.Clone(p.Origins)
p.Health.Origins = maps.Clone(p.Health.Origins)
return p
}
func (f *Fake) record(verb, kind, id string) { f.Calls = append(f.Calls, verb+" "+kind+" "+id) }
func (f *Fake) Zone(_ context.Context, name string) (Zone, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.Err != nil {
return Zone{}, f.Err
}
z, ok := f.zones[name]
if !ok {
return Zone{}, fmt.Errorf("zone %s: %w", name, ErrNotFound)
}
return z, nil
}
func (f *Fake) ListLoadBalancers(_ context.Context, zoneID string) ([]LoadBalancer, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.Err != nil {
return nil, f.Err
}
var out []LoadBalancer
for id, l := range f.lbs {
if l.zoneID == zoneID {
out = append(out, l.lb.Managed().withID(id))
}
}
slices.SortFunc(out, func(a, b LoadBalancer) int { return compareIDs(a.ID, b.ID) })
return out, nil
}
func compareIDs(a, b string) int {
if len(a) != len(b) {
return len(a) - len(b)
}
if a < b {
return -1
}
if a > b {
return 1
}
return 0
}
func (f *Fake) CreateLoadBalancer(_ context.Context, zoneID string, lb LoadBalancer) (string, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.Err != nil {
return "", f.Err
}
id := f.id("lb")
f.lbs[id] = fakeLB{zoneID: zoneID, lb: lb.Managed().withID(id)}
f.record("create", "lb", id)
return id, nil
}
func (f *Fake) UpdateLoadBalancer(_ context.Context, zoneID string, lb LoadBalancer) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.Err != nil {
return f.Err
}
cur, ok := f.lbs[lb.ID]
if !ok || cur.zoneID != zoneID {
return fmt.Errorf("update load balancer %s: %w", lb.ID, ErrNotFound)
}
f.lbs[lb.ID] = fakeLB{zoneID: zoneID, lb: lb.Managed().withID(lb.ID)}
f.record("update", "lb", lb.ID)
return nil
}
func (f *Fake) DeleteLoadBalancer(_ context.Context, zoneID, id string) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.Err != nil {
return f.Err
}
cur, ok := f.lbs[id]
if !ok || cur.zoneID != zoneID {
return fmt.Errorf("delete load balancer %s: %w", id, ErrNotFound)
}
delete(f.lbs, id)
f.record("delete", "lb", id)
return nil
}
func (f *Fake) ListPools(_ context.Context, _ string) ([]Pool, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.Err != nil {
return nil, f.Err
}
var out []Pool
for _, p := range f.pools {
p = clonePool(p)
p.Health = PoolHealth{}
out = append(out, p)
}
slices.SortFunc(out, func(a, b Pool) int { return compareIDs(a.ID, b.ID) })
return out, nil
}
func (f *Fake) GetPool(_ context.Context, _, id string) (Pool, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.Err != nil {
return Pool{}, f.Err
}
p, ok := f.pools[id]
if !ok {
return Pool{}, fmt.Errorf("get pool %s: %w", id, ErrNotFound)
}
return clonePool(p), nil
}
func (f *Fake) CreatePool(_ context.Context, _ string, p Pool) (string, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.Err != nil {
return "", f.Err
}
if _, ok := f.monitors[p.Monitor]; p.Monitor != "" && !ok {
return "", fmt.Errorf("create pool: monitor %s: %w", p.Monitor, ErrNotFound)
}
id := f.id("pool")
p = p.Managed()
p.ID = id
f.pools[id] = p
f.record("create", "pool", id)
return id, nil
}
func (f *Fake) UpdatePool(_ context.Context, _ string, p Pool) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.Err != nil {
return f.Err
}
cur, ok := f.pools[p.ID]
if !ok {
return fmt.Errorf("update pool %s: %w", p.ID, ErrNotFound)
}
if _, ok := f.monitors[p.Monitor]; p.Monitor != "" && !ok {
return fmt.Errorf("update pool: monitor %s: %w", p.Monitor, ErrNotFound)
}
id := p.ID
p = p.Managed()
p.ID, p.Health = id, cur.Health
f.pools[id] = p
f.record("update", "pool", id)
return nil
}
func (f *Fake) DeletePool(_ context.Context, _, id string) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.Err != nil {
return f.Err
}
if _, ok := f.pools[id]; !ok {
return fmt.Errorf("delete pool %s: %w", id, ErrNotFound)
}
if len(f.poolReferrers(id)) > 0 {
return fmt.Errorf("delete pool %s: still referenced", id)
}
delete(f.pools, id)
f.record("delete", "pool", id)
return nil
}
func (f *Fake) poolReferrers(id string) []Reference {
var out []Reference
for lbID, l := range f.lbs {
if slices.Contains(l.lb.DefaultPools, id) || l.lb.FallbackPool == id {
out = append(out, Reference{ResourceType: "load_balancer", ResourceID: lbID})
}
}
return out
}
func (f *Fake) PoolReferrers(_ context.Context, _, id string) ([]Reference, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.Err != nil {
return nil, f.Err
}
if _, ok := f.pools[id]; !ok {
return nil, fmt.Errorf("pool references %s: %w", id, ErrNotFound)
}
return f.poolReferrers(id), nil
}
func (f *Fake) ListMonitors(_ context.Context, _ string) ([]Monitor, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.Err != nil {
return nil, f.Err
}
var out []Monitor
for _, m := range f.monitors {
m.Header = maps.Clone(m.Header)
out = append(out, m)
}
slices.SortFunc(out, func(a, b Monitor) int { return compareIDs(a.ID, b.ID) })
return out, nil
}
func (f *Fake) GetMonitor(_ context.Context, _, id string) (Monitor, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.Err != nil {
return Monitor{}, f.Err
}
m, ok := f.monitors[id]
if !ok {
return Monitor{}, fmt.Errorf("get monitor %s: %w", id, ErrNotFound)
}
m.Header = maps.Clone(m.Header)
return m, nil
}
func (f *Fake) CreateMonitor(_ context.Context, _ string, m Monitor) (string, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.Err != nil {
return "", f.Err
}
id := f.id("monitor")
m = m.Managed()
m.ID = id
f.monitors[id] = m
f.record("create", "monitor", id)
return id, nil
}
func (f *Fake) UpdateMonitor(_ context.Context, _ string, m Monitor) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.Err != nil {
return f.Err
}
if _, ok := f.monitors[m.ID]; !ok {
return fmt.Errorf("update monitor %s: %w", m.ID, ErrNotFound)
}
id := m.ID
m = m.Managed()
m.ID = id
f.monitors[id] = m
f.record("update", "monitor", id)
return nil
}
func (f *Fake) DeleteMonitor(_ context.Context, _, id string) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.Err != nil {
return f.Err
}
if _, ok := f.monitors[id]; !ok {
return fmt.Errorf("delete monitor %s: %w", id, ErrNotFound)
}
if len(f.monitorReferrers(id)) > 0 {
return fmt.Errorf("delete monitor %s: still referenced", id)
}
delete(f.monitors, id)
f.record("delete", "monitor", id)
return nil
}
func (f *Fake) monitorReferrers(id string) []Reference {
var out []Reference
for poolID, p := range f.pools {
if p.Monitor == id {
out = append(out, Reference{ResourceType: "pool", ResourceID: poolID})
}
}
return out
}
func (f *Fake) MonitorReferrers(_ context.Context, _, id string) ([]Reference, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.Err != nil {
return nil, f.Err
}
if _, ok := f.monitors[id]; !ok {
return nil, fmt.Errorf("monitor references %s: %w", id, ErrNotFound)
}
return f.monitorReferrers(id), nil
}
@@ -1,471 +0,0 @@
// Package controller reconciles CloudflareLoadBalancer resources into
// Cloudflare load balancers, pools and monitors.
package controller
import (
"context"
"errors"
"reflect"
"strings"
"time"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
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/konairius/homelab/cloudflare-lb-sync/api/v1alpha1"
"gitea.renner.uno/konairius/homelab/cloudflare-lb-sync/internal/cfclient"
)
const (
Finalizer = "cloudflare.renner.uno/cloudflare-lb-sync"
ReasonSynced = "Synced"
ReasonOwnerConflict = "OwnerConflict"
ReasonZoneNotFound = "ZoneNotFound"
ReasonCloudflareUnavailable = "CloudflareUnavailable"
resyncPeriod = 2 * time.Minute
)
// +kubebuilder:rbac:groups=cloudflare.renner.uno,resources=cloudflareloadbalancers,verbs=get;list;watch;update;patch
// +kubebuilder:rbac:groups=cloudflare.renner.uno,resources=cloudflareloadbalancers/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=cloudflare.renner.uno,resources=cloudflareloadbalancers/finalizers,verbs=update
// +kubebuilder:rbac:groups="",resources=events,verbs=create;patch
// Reconciler syncs CloudflareLoadBalancer resources to Cloudflare.
type Reconciler struct {
Client client.Client
CF cfclient.Client
// Resync is how often a healthy resource is reconciled again, which repairs
// drift made in Cloudflare and refreshes origin health. 0 means 2 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.CloudflareLoadBalancer{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})).
Named("cloudflareloadbalancer").
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 (
msgSynced = "load balancer in sync"
msgOwnerConflict = "a Cloudflare object carries another resource's owner tag; nothing written"
msgZoneNotFound = "zone not found in Cloudflare"
msgCloudflareUnavailable = "Cloudflare request failed"
)
func unavailable(err error) *failure {
return &failure{ReasonCloudflareUnavailable, msgCloudflareUnavailable, err}
}
// synced is what a successful reconcile records in the status.
type synced struct {
lbID, poolID, monitorID string
health cfclient.PoolHealth
}
func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
cl := &v1.CloudflareLoadBalancer{}
if err := r.Client.Get(ctx, req.NamespacedName, cl); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
if !cl.DeletionTimestamp.IsZero() {
return r.reconcileDelete(ctx, cl)
}
if controllerutil.AddFinalizer(cl, Finalizer) {
if err := r.Client.Update(ctx, cl); err != nil {
return ctrl.Result{}, err
}
}
zone, err := r.CF.Zone(ctx, cl.Spec.Zone)
if errors.Is(err, cfclient.ErrNotFound) {
log.FromContext(ctx).Error(err, "zone not found", "zone", cl.Spec.Zone)
err := r.setReady(ctx, cl, metav1.ConditionFalse, ReasonZoneNotFound, msgZoneNotFound, nil)
return ctrl.Result{RequeueAfter: r.resync()}, err
}
if err != nil {
return r.fail(ctx, cl, unavailable(err))
}
obs, fail := r.observe(ctx, cl, zone)
if fail != nil {
return r.fail(ctx, cl, fail)
}
if obs.conflict != "" {
log.FromContext(ctx).Error(nil, "object carries another resource's owner tag, writing nothing",
"object", obs.conflict, "ownerTag", OwnerTag(cl))
err := r.setReady(ctx, cl, metav1.ConditionFalse, ReasonOwnerConflict, msgOwnerConflict, nil)
return ctrl.Result{RequeueAfter: r.resync()}, err
}
s, err := r.apply(ctx, cl, zone, obs)
if err != nil {
return r.fail(ctx, cl, unavailable(err))
}
if err := r.setReady(ctx, cl, metav1.ConditionTrue, ReasonSynced, msgSynced, &s); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{RequeueAfter: r.resync()}, nil
}
// observed holds the objects this resource will write: owned ones and
// adoptable foreign ones. A nil object is created. conflict names the first
// object carrying another resource's tag.
type observed struct {
lb *cfclient.LoadBalancer
pool *cfclient.Pool
monitor *cfclient.Monitor
conflict string
}
// observe finds the load balancer by hostname, then its first default pool,
// then that pool's monitor. A foreign pool or monitor that another object
// also uses is not adopted. When the chain ends, an object already carrying
// this resource's tag is picked up so an interrupted create is not repeated.
func (r *Reconciler) observe(ctx context.Context, cl *v1.CloudflareLoadBalancer, zone cfclient.Zone) (observed, *failure) {
var obs observed
tag := OwnerTag(cl)
lbs, err := r.CF.ListLoadBalancers(ctx, zone.ID)
if err != nil {
return obs, unavailable(err)
}
for i := range lbs {
if strings.EqualFold(lbs[i].Name, cl.Spec.Hostname) {
obs.lb = &lbs[i]
break
}
}
if obs.lb != nil && classify(obs.lb.Description, tag) == conflict {
obs.conflict = "load balancer " + obs.lb.ID
return obs, nil
}
if obs.lb != nil && len(obs.lb.DefaultPools) > 0 {
p, err := r.CF.GetPool(ctx, zone.AccountID, obs.lb.DefaultPools[0])
switch {
case errors.Is(err, cfclient.ErrNotFound):
case err != nil:
return obs, unavailable(err)
default:
switch classify(p.Description, tag) {
case conflict:
obs.conflict = "pool " + p.ID
return obs, nil
case owned:
obs.pool = &p
case foreign:
refs, err := r.CF.PoolReferrers(ctx, zone.AccountID, p.ID)
if err != nil {
return obs, unavailable(err)
}
if shared(refs, obs.lb.ID) {
log.FromContext(ctx).Info("pool used by another object, not adopting it", "poolID", p.ID)
} else {
obs.pool = &p
}
}
}
}
if obs.pool == nil {
pools, err := r.CF.ListPools(ctx, zone.AccountID)
if err != nil {
return obs, unavailable(err)
}
for _, p := range pools {
if p.Description != tag {
continue
}
got, err := r.CF.GetPool(ctx, zone.AccountID, p.ID)
if err != nil {
return obs, unavailable(err)
}
obs.pool = &got
break
}
}
if obs.pool != nil && obs.pool.Monitor != "" {
m, err := r.CF.GetMonitor(ctx, zone.AccountID, obs.pool.Monitor)
switch {
case errors.Is(err, cfclient.ErrNotFound):
case err != nil:
return obs, unavailable(err)
default:
switch classify(m.Description, tag) {
case conflict:
obs.conflict = "monitor " + m.ID
return obs, nil
case owned:
obs.monitor = &m
case foreign:
refs, err := r.CF.MonitorReferrers(ctx, zone.AccountID, m.ID)
if err != nil {
return obs, unavailable(err)
}
if shared(refs, obs.pool.ID) {
log.FromContext(ctx).Info("monitor used by another object, not adopting it", "monitorID", m.ID)
} else {
obs.monitor = &m
}
}
}
}
if obs.monitor == nil {
monitors, err := r.CF.ListMonitors(ctx, zone.AccountID)
if err != nil {
return obs, unavailable(err)
}
for i := range monitors {
if monitors[i].Description == tag {
obs.monitor = &monitors[i]
break
}
}
}
return obs, nil
}
// shared reports whether anything other than self refers to the object.
func shared(refs []cfclient.Reference, self string) bool {
for _, ref := range refs {
if ref.ResourceID != self {
return true
}
}
return false
}
// apply writes monitor, pool and load balancer in that order, each only when
// it is missing or differs from the desired state.
func (r *Reconciler) apply(ctx context.Context, cl *v1.CloudflareLoadBalancer, zone cfclient.Zone, obs observed) (synced, error) {
var s synced
lg := log.FromContext(ctx)
m := DesiredMonitor(cl)
switch {
case obs.monitor == nil:
id, err := r.CF.CreateMonitor(ctx, zone.AccountID, m)
if err != nil {
return s, err
}
lg.Info("created monitor", "monitorID", id)
s.monitorID = id
case !reflect.DeepEqual(m.Managed(), obs.monitor.Managed()):
m.ID = obs.monitor.ID
if err := r.CF.UpdateMonitor(ctx, zone.AccountID, m); err != nil {
return s, err
}
lg.Info("updated monitor", "monitorID", m.ID)
s.monitorID = m.ID
default:
s.monitorID = obs.monitor.ID
}
p := DesiredPool(cl, s.monitorID)
switch {
case obs.pool == nil:
id, err := r.CF.CreatePool(ctx, zone.AccountID, p)
if err != nil {
return s, err
}
lg.Info("created pool", "poolID", id)
s.poolID = id
case !reflect.DeepEqual(p.Managed(), obs.pool.Managed()):
p.ID = obs.pool.ID
if err := r.CF.UpdatePool(ctx, zone.AccountID, p); err != nil {
return s, err
}
lg.Info("updated pool", "poolID", p.ID)
s.poolID, s.health = p.ID, obs.pool.Health
default:
s.poolID, s.health = obs.pool.ID, obs.pool.Health
}
l := DesiredLoadBalancer(cl, s.poolID)
switch {
case obs.lb == nil:
id, err := r.CF.CreateLoadBalancer(ctx, zone.ID, l)
if err != nil {
return s, err
}
lg.Info("created load balancer", "loadBalancerID", id)
s.lbID = id
case !reflect.DeepEqual(l.Managed(), obs.lb.Managed()):
l.ID = obs.lb.ID
if err := r.CF.UpdateLoadBalancer(ctx, zone.ID, l); err != nil {
return s, err
}
lg.Info("updated load balancer", "loadBalancerID", l.ID)
s.lbID = l.ID
default:
s.lbID = obs.lb.ID
}
return s, nil
}
// reconcileDelete removes the load balancer, then the pools, then the monitors
// carrying this resource's tag. A pool or monitor still used by another object
// stays. NotFound counts as deleted.
func (r *Reconciler) reconcileDelete(ctx context.Context, cl *v1.CloudflareLoadBalancer) (ctrl.Result, error) {
if !controllerutil.ContainsFinalizer(cl, Finalizer) {
return ctrl.Result{}, nil
}
if err := r.deleteOwned(ctx, cl); err != nil {
return r.fail(ctx, cl, unavailable(err))
}
controllerutil.RemoveFinalizer(cl, Finalizer)
return ctrl.Result{}, r.Client.Update(ctx, cl)
}
func (r *Reconciler) deleteOwned(ctx context.Context, cl *v1.CloudflareLoadBalancer) error {
lg := log.FromContext(ctx)
tag := OwnerTag(cl)
zone, err := r.CF.Zone(ctx, cl.Spec.Zone)
if errors.Is(err, cfclient.ErrNotFound) {
lg.Info("zone not found, nothing to delete", "zone", cl.Spec.Zone)
return nil
}
if err != nil {
return err
}
ignoreNotFound := func(err error) error {
if errors.Is(err, cfclient.ErrNotFound) {
return nil
}
return err
}
lbs, err := r.CF.ListLoadBalancers(ctx, zone.ID)
if err != nil {
return err
}
for _, l := range lbs {
if !strings.EqualFold(l.Name, cl.Spec.Hostname) {
continue
}
if l.Description != tag {
lg.Info("load balancer not owned, leaving it", "loadBalancerID", l.ID)
continue
}
if err := ignoreNotFound(r.CF.DeleteLoadBalancer(ctx, zone.ID, l.ID)); err != nil {
return err
}
lg.Info("deleted load balancer", "loadBalancerID", l.ID)
}
pools, err := r.CF.ListPools(ctx, zone.AccountID)
if err != nil {
return err
}
for _, p := range pools {
if p.Description != tag {
continue
}
refs, err := r.CF.PoolReferrers(ctx, zone.AccountID, p.ID)
if errors.Is(err, cfclient.ErrNotFound) {
continue
}
if err != nil {
return err
}
if len(refs) > 0 {
lg.Info("pool still used by another object, leaving it", "poolID", p.ID, "references", refs)
continue
}
if err := ignoreNotFound(r.CF.DeletePool(ctx, zone.AccountID, p.ID)); err != nil {
return err
}
lg.Info("deleted pool", "poolID", p.ID)
}
monitors, err := r.CF.ListMonitors(ctx, zone.AccountID)
if err != nil {
return err
}
for _, m := range monitors {
if m.Description != tag {
continue
}
refs, err := r.CF.MonitorReferrers(ctx, zone.AccountID, m.ID)
if errors.Is(err, cfclient.ErrNotFound) {
continue
}
if err != nil {
return err
}
if len(refs) > 0 {
lg.Info("monitor still used by another object, leaving it", "monitorID", m.ID, "references", refs)
continue
}
if err := ignoreNotFound(r.CF.DeleteMonitor(ctx, zone.AccountID, m.ID)); err != nil {
return err
}
lg.Info("deleted monitor", "monitorID", m.ID)
}
return 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, cl *v1.CloudflareLoadBalancer, f *failure) (ctrl.Result, error) {
log.FromContext(ctx).Info("reconcile failed", "reason", f.reason, "error", f.err.Error())
if serr := r.setReady(ctx, cl, metav1.ConditionFalse, f.reason, f.message, nil); serr != nil {
return ctrl.Result{}, errors.Join(f.err, serr)
}
return ctrl.Result{}, f.err
}
// setReady writes the Ready condition, and the synced IDs and health when
// given, through the status subresource. It writes nothing if the status is
// unchanged.
func (r *Reconciler) setReady(
ctx context.Context, cl *v1.CloudflareLoadBalancer, status metav1.ConditionStatus, reason, msg string, s *synced,
) error {
orig := cl.Status.DeepCopy()
if s != nil {
cl.Status.LoadBalancerID = s.lbID
cl.Status.PoolID = s.poolID
cl.Status.MonitorID = s.monitorID
cl.Status.Healthy = s.health.Healthy
cl.Status.Origins = originStatus(cl, s.health)
cl.Status.ObservedGeneration = cl.Generation
}
meta.SetStatusCondition(&cl.Status.Conditions, metav1.Condition{
Type: v1.ConditionReady,
Status: status,
Reason: reason,
Message: msg,
ObservedGeneration: cl.Generation,
})
if reflect.DeepEqual(*orig, cl.Status) {
return nil
}
return r.Client.Status().Update(ctx, cl)
}
@@ -1,514 +0,0 @@
package controller
import (
"errors"
"reflect"
"slices"
"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/konairius/homelab/cloudflare-lb-sync/api/v1alpha1"
"gitea.renner.uno/konairius/homelab/cloudflare-lb-sync/internal/cfclient"
)
const (
testNS = "cloudflare-lb-sync"
testTag = "cloudflare-lb-sync cloudflare-lb-sync/proxy"
)
type env struct {
r *Reconciler
k8s client.Client
cf *cfclient.Fake
zone cfclient.Zone
}
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.CloudflareLoadBalancer{}).Build()
cf := cfclient.NewFake()
zone := cf.AddZone("renner.uno", "acct")
return &env{r: &Reconciler{Client: k8s, CF: cf}, k8s: k8s, cf: cf, zone: zone}
}
func ptr32(v int32) *int32 { return &v }
func boolp(v bool) *bool { return &v }
// proxyLB is the resource for the live proxy.renner.uno load balancer, with
// the defaults the API server fills in.
func proxyLB() *v1.CloudflareLoadBalancer {
return &v1.CloudflareLoadBalancer{
ObjectMeta: metav1.ObjectMeta{Namespace: testNS, Name: "proxy", Generation: 1},
Spec: v1.CloudflareLoadBalancerSpec{
Zone: "renner.uno", Hostname: "proxy.renner.uno", TTL: 30, SteeringPolicy: "random",
Monitor: v1.MonitorSpec{
Type: "https", Port: 443, Path: "/", ExpectedCodes: "200", Host: "status.renner.uno",
Interval: 60, Retries: 2, Timeout: 5,
},
Origins: []v1.OriginSpec{
{Name: "proxy-1", Address: "proxy-1.renner.uno", Weight: 50, Port: ptr32(443)},
{Name: "proxy-2", Address: "proxy-2.renner.uno", Weight: 50, Port: ptr32(443)},
},
},
}
}
// handMade stores the objects as they were set up in the dashboard and
// returns their IDs.
func (e *env) handMade(t *testing.T) (lbID, poolID, monitorID string) {
t.Helper()
monitorID = e.cf.PutMonitor(cfclient.Monitor{
Type: "https", Port: 443, Path: "/", Method: "GET", ExpectedCodes: "200",
Header: map[string][]string{"Host": {"status.renner.uno"}}, Interval: 60, Retries: 2, Timeout: 5,
})
poolID = e.cf.PutPool(cfclient.Pool{
Name: "proxy", Monitor: monitorID, Enabled: true, MinimumOrigins: 1,
Origins: []cfclient.Origin{
{Name: "proxy-1", Address: "proxy-1.renner.uno", Weight: 0.5, Port: 443, Enabled: true},
{Name: "proxy-2", Address: "proxy-2.renner.uno", Weight: 0.5, Port: 443, Enabled: true},
},
})
lbID = e.cf.PutLoadBalancer(e.zone.ID, cfclient.LoadBalancer{
Name: "proxy.renner.uno", DefaultPools: []string{poolID}, FallbackPool: poolID,
Enabled: true, TTL: 30, SteeringPolicy: "random",
})
return lbID, poolID, monitorID
}
func (e *env) reconcile(t *testing.T) (ctrl.Result, error) {
t.Helper()
return e.r.Reconcile(t.Context(), ctrl.Request{NamespacedName: types.NamespacedName{Namespace: testNS, Name: "proxy"}})
}
func (e *env) mustReconcile(t *testing.T) {
t.Helper()
res, err := e.reconcile(t)
if err != nil {
t.Fatal(err)
}
if res.RequeueAfter != resyncPeriod {
t.Errorf("RequeueAfter = %v, want %v", res.RequeueAfter, resyncPeriod)
}
}
func (e *env) get(t *testing.T) *v1.CloudflareLoadBalancer {
t.Helper()
cl := &v1.CloudflareLoadBalancer{}
if err := e.k8s.Get(t.Context(), types.NamespacedName{Namespace: testNS, Name: "proxy"}, cl); err != nil {
t.Fatal(err)
}
return cl
}
func ready(t *testing.T, cl *v1.CloudflareLoadBalancer) *metav1.Condition {
t.Helper()
c := meta.FindStatusCondition(cl.Status.Conditions, v1.ConditionReady)
if c == nil {
t.Fatal("no Ready condition")
}
return c
}
func expectReady(t *testing.T, cl *v1.CloudflareLoadBalancer, status metav1.ConditionStatus, reason string) {
t.Helper()
if c := ready(t, cl); c.Status != status || c.Reason != reason {
t.Errorf("Ready = %s/%s (%q), want %s/%s", c.Status, c.Reason, c.Message, status, reason)
}
}
func TestReconcileCreates(t *testing.T) {
e := newEnv(t, proxyLB())
e.mustReconcile(t)
if got := e.cf.Writes(); !slices.Equal(got, []string{"create monitor monitor-2", "create pool pool-3", "create lb lb-4"}) {
t.Errorf("writes = %v", got)
}
cl := e.get(t)
expectReady(t, cl, metav1.ConditionTrue, ReasonSynced)
if cl.Status.LoadBalancerID != "lb-4" || cl.Status.PoolID != "pool-3" || cl.Status.MonitorID != "monitor-2" {
t.Errorf("status IDs = %+v", cl.Status)
}
if cl.Status.ObservedGeneration != cl.Generation {
t.Errorf("observedGeneration = %d", cl.Status.ObservedGeneration)
}
if !slices.Contains(cl.Finalizers, Finalizer) {
t.Errorf("finalizers = %v", cl.Finalizers)
}
lb := e.cf.LoadBalancer("lb-4")
if lb == nil || lb.Description != testTag || lb.FallbackPool != "pool-3" || !slices.Equal(lb.DefaultPools, []string{"pool-3"}) {
t.Errorf("lb = %+v", lb)
}
if p := e.cf.Pool("pool-3"); p == nil || p.Name != "proxy-renner-uno" || p.Monitor != "monitor-2" {
t.Errorf("pool = %+v", p)
}
// Second run: everything equal, nothing written.
e.mustReconcile(t)
if got := e.cf.Writes(); len(got) != 0 {
t.Errorf("second run writes = %v", got)
}
}
func TestReconcileAdoptsHandMade(t *testing.T) {
e := newEnv(t, proxyLB())
lbID, poolID, monitorID := e.handMade(t)
e.mustReconcile(t)
want := []string{"update monitor " + monitorID, "update pool " + poolID, "update lb " + lbID}
if got := e.cf.Writes(); !slices.Equal(got, want) {
t.Errorf("writes = %v, want %v", got, want)
}
if lbs, pools, monitors := e.cf.Counts(); lbs != 1 || pools != 1 || monitors != 1 {
t.Errorf("counts = %d/%d/%d, want no duplicates", lbs, pools, monitors)
}
// Adoption changes only descriptions and the pool name.
m := e.cf.Monitor(monitorID)
wantM := cfclient.Monitor{
ID: monitorID, Type: "https", Port: 443, Path: "/", Method: "GET", ExpectedCodes: "200",
Header: map[string][]string{"Host": {"status.renner.uno"}}, Interval: 60, Retries: 2, Timeout: 5,
Description: testTag,
}
if m == nil || !reflect.DeepEqual(*m, wantM) {
t.Errorf("monitor = %+v", m)
}
p := e.cf.Pool(poolID)
if p.Name != "proxy-renner-uno" || p.Description != testTag || p.Monitor != monitorID || len(p.Origins) != 2 ||
p.Origins[0] != (cfclient.Origin{Name: "proxy-1", Address: "proxy-1.renner.uno", Weight: 0.5, Port: 443, Enabled: true}) {
t.Errorf("pool = %+v", p)
}
lb := e.cf.LoadBalancer(lbID)
if lb.Description != testTag || lb.TTL != 30 || lb.SteeringPolicy != "random" || lb.Proxied || !lb.Enabled {
t.Errorf("lb = %+v", lb)
}
cl := e.get(t)
expectReady(t, cl, metav1.ConditionTrue, ReasonSynced)
if cl.Status.LoadBalancerID != lbID || cl.Status.PoolID != poolID || cl.Status.MonitorID != monitorID {
t.Errorf("status = %+v", cl.Status)
}
e.mustReconcile(t)
if got := e.cf.Writes(); len(got) != 0 {
t.Errorf("second run writes = %v", got)
}
}
func TestReconcileRepairsDrift(t *testing.T) {
e := newEnv(t, proxyLB())
e.mustReconcile(t)
e.cf.Writes()
// Dashboard edits: origin weight, monitor interval, load balancer TTL.
p := e.cf.Pool("pool-3")
p.Origins[0].Weight = 1
e.cf.PutPool(*p)
m := e.cf.Monitor("monitor-2")
m.Interval = 120
e.cf.PutMonitor(*m)
lb := e.cf.LoadBalancer("lb-4")
lb.TTL = 300
e.cf.PutLoadBalancer(e.zone.ID, *lb)
e.mustReconcile(t)
want := []string{"update monitor monitor-2", "update pool pool-3", "update lb lb-4"}
if got := e.cf.Writes(); !slices.Equal(got, want) {
t.Errorf("writes = %v, want %v", got, want)
}
if e.cf.Pool("pool-3").Origins[0].Weight != 0.5 || e.cf.Monitor("monitor-2").Interval != 60 || e.cf.LoadBalancer("lb-4").TTL != 30 {
t.Error("drift not repaired")
}
}
func TestReconcileSpecChangeUpdatesOnlyPool(t *testing.T) {
e := newEnv(t, proxyLB())
e.mustReconcile(t)
e.cf.Writes()
cl := e.get(t)
cl.Spec.Origins[1].Weight = 25
if err := e.k8s.Update(t.Context(), cl); err != nil {
t.Fatal(err)
}
e.mustReconcile(t)
if got := e.cf.Writes(); !slices.Equal(got, []string{"update pool pool-3"}) {
t.Errorf("writes = %v", got)
}
if w := e.cf.Pool("pool-3").Origins[1].Weight; w != 0.25 {
t.Errorf("weight = %v", w)
}
}
func TestReconcileOwnerConflictWritesNothing(t *testing.T) {
for _, kind := range []string{"lb", "pool", "monitor"} {
t.Run(kind, func(t *testing.T) {
e := newEnv(t, proxyLB())
lbID, poolID, monitorID := e.handMade(t)
other := "cloudflare-lb-sync other/proxy"
switch kind {
case "lb":
lb := e.cf.LoadBalancer(lbID)
lb.Description = other
e.cf.PutLoadBalancer(e.zone.ID, *lb)
case "pool":
p := e.cf.Pool(poolID)
p.Description = other
e.cf.PutPool(*p)
case "monitor":
m := e.cf.Monitor(monitorID)
m.Description = other
e.cf.PutMonitor(*m)
}
e.mustReconcile(t)
if got := e.cf.Writes(); len(got) != 0 {
t.Errorf("writes = %v, want none", got)
}
cl := e.get(t)
expectReady(t, cl, metav1.ConditionFalse, ReasonOwnerConflict)
if c := ready(t, cl); c.Message != msgOwnerConflict {
t.Errorf("message = %q", c.Message)
}
})
}
}
func TestReconcileSharedPoolNotAdopted(t *testing.T) {
e := newEnv(t, proxyLB())
lbID, poolID, monitorID := e.handMade(t)
// A second, unrelated load balancer uses the same pool.
otherLB := e.cf.PutLoadBalancer(e.zone.ID, cfclient.LoadBalancer{
Name: "other.renner.uno", DefaultPools: []string{poolID}, FallbackPool: poolID, Enabled: true, TTL: 30,
})
e.mustReconcile(t)
// The shared pool keeps its monitor, so the monitor is shared too: both
// are created new, and the load balancer switches to them.
want := []string{"create monitor monitor-6", "create pool pool-7", "update lb " + lbID}
if got := e.cf.Writes(); !slices.Equal(got, want) {
t.Errorf("writes = %v, want %v", got, want)
}
if p := e.cf.Pool(poolID); p.Description != "" || p.Name != "proxy" || p.Monitor != monitorID {
t.Errorf("shared pool changed: %+v", p)
}
if m := e.cf.Monitor(monitorID); m.Description != "" {
t.Errorf("shared monitor changed: %+v", m)
}
if lb := e.cf.LoadBalancer(otherLB); !slices.Equal(lb.DefaultPools, []string{poolID}) {
t.Errorf("other lb changed: %+v", lb)
}
if lb := e.cf.LoadBalancer(lbID); lb.FallbackPool != "pool-7" {
t.Errorf("lb = %+v", lb)
}
}
func TestReconcileSharedMonitorNotAdopted(t *testing.T) {
e := newEnv(t, proxyLB())
_, poolID, monitorID := e.handMade(t)
// Another pool uses the hand-made monitor.
e.cf.PutPool(cfclient.Pool{Name: "other", Monitor: monitorID, Enabled: true, MinimumOrigins: 1,
Origins: []cfclient.Origin{{Name: "x", Address: "x.example", Weight: 1, Enabled: true}}})
e.mustReconcile(t)
p := e.cf.Pool(poolID)
if p.Description != testTag || p.Monitor == monitorID {
t.Errorf("pool = %+v, want adopted with a new monitor", p)
}
if m := e.cf.Monitor(monitorID); m.Description != "" {
t.Errorf("shared monitor changed: %+v", m)
}
if m := e.cf.Monitor(p.Monitor); m == nil || m.Description != testTag {
t.Errorf("new monitor = %+v", m)
}
}
func TestReconcileResumesInterruptedCreate(t *testing.T) {
e := newEnv(t, proxyLB())
// A previous run created monitor and pool, then failed before the load
// balancer.
m := DesiredMonitor(proxyLB())
monitorID := e.cf.PutMonitor(m)
poolID := e.cf.PutPool(DesiredPool(proxyLB(), monitorID))
e.mustReconcile(t)
if got := e.cf.Writes(); !slices.Equal(got, []string{"create lb lb-4"}) {
t.Errorf("writes = %v", got)
}
if lb := e.cf.LoadBalancer("lb-4"); lb.FallbackPool != poolID {
t.Errorf("lb = %+v", lb)
}
}
func TestReconcileReportsHealth(t *testing.T) {
e := newEnv(t, proxyLB())
_, poolID, _ := e.handMade(t)
e.cf.SetHealth(poolID, cfclient.PoolHealth{
Healthy: boolp(true),
Origins: map[string]cfclient.OriginHealth{
"proxy-1": {Healthy: boolp(true)},
"proxy-2": {Healthy: boolp(false), FailureReason: "TCP connection failed"},
},
})
e.mustReconcile(t)
cl := e.get(t)
if cl.Status.Healthy == nil || !*cl.Status.Healthy {
t.Errorf("healthy = %v", cl.Status.Healthy)
}
want := []v1.OriginStatus{
{Name: "proxy-1", Healthy: boolp(true)},
{Name: "proxy-2", Healthy: boolp(false), FailureReason: "TCP connection failed"},
}
if len(cl.Status.Origins) != 2 {
t.Fatalf("origins = %+v", cl.Status.Origins)
}
for i, o := range cl.Status.Origins {
if o.Name != want[i].Name || *o.Healthy != *want[i].Healthy || o.FailureReason != want[i].FailureReason {
t.Errorf("origin %d = %+v, want %+v", i, o, want[i])
}
}
}
func TestReconcileZoneNotFound(t *testing.T) {
cl := proxyLB()
cl.Spec.Zone, cl.Spec.Hostname = "example.org", "proxy.example.org"
e := newEnv(t, cl)
e.mustReconcile(t)
expectReady(t, e.get(t), metav1.ConditionFalse, ReasonZoneNotFound)
if got := e.cf.Writes(); len(got) != 0 {
t.Errorf("writes = %v", got)
}
}
func TestReconcileCloudflareError(t *testing.T) {
e := newEnv(t, proxyLB())
boom := errors.New("boom")
e.cf.SetErr(boom)
if _, err := e.reconcile(t); !errors.Is(err, boom) {
t.Fatalf("err = %v, want boom", err)
}
cl := e.get(t)
expectReady(t, cl, metav1.ConditionFalse, ReasonCloudflareUnavailable)
if c := ready(t, cl); c.Message != msgCloudflareUnavailable {
t.Errorf("message = %q, want the fixed message without the cause", c.Message)
}
}
func TestSetReadySkipsUnchangedStatus(t *testing.T) {
e := newEnv(t, proxyLB())
e.mustReconcile(t)
before := e.get(t).ResourceVersion
e.mustReconcile(t)
if after := e.get(t).ResourceVersion; after != before {
t.Errorf("resourceVersion %s -> %s, want no status write", before, after)
}
}
func (e *env) delete(t *testing.T) {
t.Helper()
cl := e.get(t)
if err := e.k8s.Delete(t.Context(), cl); err != nil {
t.Fatal(err)
}
res, err := e.reconcile(t)
if err != nil {
t.Fatal(err)
}
if res.RequeueAfter != 0 {
t.Errorf("RequeueAfter = %v", res.RequeueAfter)
}
got := &v1.CloudflareLoadBalancer{}
err = e.k8s.Get(t.Context(), types.NamespacedName{Namespace: testNS, Name: "proxy"}, got)
if !apierrors.IsNotFound(err) {
t.Errorf("resource still present: %v %+v", err, got.Finalizers)
}
}
func TestDeleteOrder(t *testing.T) {
e := newEnv(t, proxyLB())
lbID, poolID, monitorID := e.handMade(t)
e.mustReconcile(t)
e.cf.Writes()
e.delete(t)
want := []string{"delete lb " + lbID, "delete pool " + poolID, "delete monitor " + monitorID}
if got := e.cf.Writes(); !slices.Equal(got, want) {
t.Errorf("writes = %v, want %v", got, want)
}
}
func TestDeleteLeavesForeignAndShared(t *testing.T) {
e := newEnv(t, proxyLB())
lbID, poolID, monitorID := e.handMade(t)
e.mustReconcile(t)
e.cf.Writes()
// The load balancer was handed to someone else; its pool and monitor
// stay ours but are still used by it.
lb := e.cf.LoadBalancer(lbID)
lb.Description = "hand-made again"
e.cf.PutLoadBalancer(e.zone.ID, *lb)
e.delete(t)
if got := e.cf.Writes(); len(got) != 0 {
t.Errorf("writes = %v, want none", got)
}
if e.cf.Pool(poolID) == nil || e.cf.Monitor(monitorID) == nil {
t.Error("referenced pool or monitor deleted")
}
}
func TestDeleteToleratesNotFound(t *testing.T) {
e := newEnv(t, proxyLB())
e.mustReconcile(t)
if err := e.cf.DeleteLoadBalancer(t.Context(), e.zone.ID, "lb-4"); err != nil {
t.Fatal(err)
}
if err := e.cf.DeletePool(t.Context(), e.zone.AccountID, "pool-3"); err != nil {
t.Fatal(err)
}
e.cf.Writes()
e.delete(t)
if got := e.cf.Writes(); !slices.Equal(got, []string{"delete monitor monitor-2"}) {
t.Errorf("writes = %v", got)
}
}
func TestDeleteFailureKeepsFinalizer(t *testing.T) {
e := newEnv(t, proxyLB())
e.mustReconcile(t)
if err := e.k8s.Delete(t.Context(), e.get(t)); err != nil {
t.Fatal(err)
}
e.cf.SetErr(errors.New("boom"))
if _, err := e.reconcile(t); err == nil {
t.Fatal("want error")
}
cl := e.get(t)
if !slices.Contains(cl.Finalizers, Finalizer) {
t.Errorf("finalizers = %v", cl.Finalizers)
}
expectReady(t, cl, metav1.ConditionFalse, ReasonCloudflareUnavailable)
}
func TestResyncOverride(t *testing.T) {
e := newEnv(t, proxyLB())
e.r.Resync = time.Minute
res, err := e.reconcile(t)
if err != nil || res.RequeueAfter != time.Minute {
t.Errorf("res = %+v err = %v", res, err)
}
}
@@ -1,119 +0,0 @@
package controller
import (
"strings"
v1 "gitea.renner.uno/konairius/homelab/cloudflare-lb-sync/api/v1alpha1"
"gitea.renner.uno/konairius/homelab/cloudflare-lb-sync/internal/cfclient"
)
// ownerPrefix starts the description of every object this controller owns.
const ownerPrefix = "cloudflare-lb-sync "
// tcpMethod is the method Cloudflare uses for tcp monitors.
const tcpMethod = "connection_established"
// OwnerTag is the description of the objects owned by a resource.
func OwnerTag(cl *v1.CloudflareLoadBalancer) string {
return ownerPrefix + cl.Namespace + "/" + cl.Name
}
type ownership int
const (
// owned: the description is this resource's tag.
owned ownership = iota
// foreign: no tag at all, such as an object made by hand; adoptable.
foreign
// conflict: another resource's tag.
conflict
)
func classify(description, tag string) ownership {
switch {
case description == tag:
return owned
case strings.HasPrefix(description, ownerPrefix):
return conflict
default:
return foreign
}
}
// PoolName is the pool name for a hostname: Cloudflare allows only
// alphanumerics, hyphens and underscores.
func PoolName(hostname string) string {
return strings.ReplaceAll(hostname, ".", "-")
}
// DesiredMonitor maps the monitor spec.
func DesiredMonitor(cl *v1.CloudflareLoadBalancer) cfclient.Monitor {
s := cl.Spec.Monitor
m := cfclient.Monitor{
Type: s.Type,
Port: int64(s.Port),
Interval: int64(s.Interval),
Retries: int64(s.Retries),
Timeout: int64(s.Timeout),
Description: OwnerTag(cl),
}
if m.IsTCP() {
m.Method = tcpMethod
return m
}
m.Method = "GET"
m.Path = s.Path
m.ExpectedCodes = s.ExpectedCodes
if s.Host != "" {
m.Header = map[string][]string{"Host": {s.Host}}
}
return m
}
// DesiredPool maps the origins; monitorID is resolved by the reconciler.
func DesiredPool(cl *v1.CloudflareLoadBalancer, monitorID string) cfclient.Pool {
p := cfclient.Pool{
Name: PoolName(cl.Spec.Hostname),
Description: OwnerTag(cl),
Monitor: monitorID,
Enabled: true,
MinimumOrigins: 1,
}
for _, o := range cl.Spec.Origins {
origin := cfclient.Origin{
Name: o.Name,
Address: o.Address,
Weight: float64(o.Weight) / 100,
Enabled: true,
}
if o.Port != nil {
origin.Port = int64(*o.Port)
}
p.Origins = append(p.Origins, origin)
}
return p
}
// DesiredLoadBalancer maps the spec; poolID is resolved by the reconciler.
func DesiredLoadBalancer(cl *v1.CloudflareLoadBalancer, poolID string) cfclient.LoadBalancer {
return cfclient.LoadBalancer{
Name: cl.Spec.Hostname,
Description: OwnerTag(cl),
DefaultPools: []string{poolID},
FallbackPool: poolID,
Proxied: false,
Enabled: true,
TTL: float64(cl.Spec.TTL),
SteeringPolicy: cl.Spec.SteeringPolicy,
}
}
// originStatus lists the health of every origin in spec order.
func originStatus(cl *v1.CloudflareLoadBalancer, h cfclient.PoolHealth) []v1.OriginStatus {
out := make([]v1.OriginStatus, 0, len(cl.Spec.Origins))
for _, o := range cl.Spec.Origins {
oh := h.Origins[o.Name]
out = append(out, v1.OriginStatus{Name: o.Name, Healthy: oh.Healthy, FailureReason: oh.FailureReason})
}
return out
}
@@ -1,106 +0,0 @@
package controller
import (
"reflect"
"testing"
"gitea.renner.uno/konairius/homelab/cloudflare-lb-sync/internal/cfclient"
)
func TestOwnerTagAndClassify(t *testing.T) {
tag := OwnerTag(proxyLB())
if tag != testTag {
t.Fatalf("tag = %q", tag)
}
for desc, want := range map[string]ownership{
tag: owned,
"": foreign,
"made by hand": foreign,
"cloudflare-lb-sync": foreign,
"cloudflare-lb-sync other/proxy": conflict,
"cloudflare-lb-sync " + testNS + "/x": conflict,
} {
if got := classify(desc, tag); got != want {
t.Errorf("classify(%q) = %d, want %d", desc, got, want)
}
}
}
func TestPoolName(t *testing.T) {
if got := PoolName("proxy.renner.uno"); got != "proxy-renner-uno" {
t.Errorf("PoolName = %q", got)
}
}
func TestDesiredMonitorHTTPS(t *testing.T) {
got := DesiredMonitor(proxyLB())
want := cfclient.Monitor{
Type: "https", Port: 443, Path: "/", Method: "GET", ExpectedCodes: "200",
Header: map[string][]string{"Host": {"status.renner.uno"}}, Interval: 60, Retries: 2, Timeout: 5,
Description: testTag,
}
if !reflect.DeepEqual(got, want) {
t.Errorf("got %+v\nwant %+v", got, want)
}
}
func TestDesiredMonitorNoHost(t *testing.T) {
cl := proxyLB()
cl.Spec.Monitor.Host = ""
if got := DesiredMonitor(cl); got.Header != nil {
t.Errorf("header = %v", got.Header)
}
}
func TestDesiredMonitorTCP(t *testing.T) {
cl := proxyLB()
cl.Spec.Monitor.Type, cl.Spec.Monitor.Host = "tcp", ""
got := DesiredMonitor(cl)
want := cfclient.Monitor{
Type: "tcp", Port: 443, Method: "connection_established", Interval: 60, Retries: 2, Timeout: 5,
Description: testTag,
}
if !reflect.DeepEqual(got, want) {
t.Errorf("got %+v\nwant %+v", got, want)
}
}
func TestDesiredPool(t *testing.T) {
cl := proxyLB()
cl.Spec.Origins[1].Port = nil
cl.Spec.Origins[1].Weight = 100
got := DesiredPool(cl, "mon")
want := cfclient.Pool{
Name: "proxy-renner-uno", Description: testTag, Monitor: "mon", Enabled: true, MinimumOrigins: 1,
Origins: []cfclient.Origin{
{Name: "proxy-1", Address: "proxy-1.renner.uno", Weight: 0.5, Port: 443, Enabled: true},
{Name: "proxy-2", Address: "proxy-2.renner.uno", Weight: 1, Port: 0, Enabled: true},
},
}
if !reflect.DeepEqual(got, want) {
t.Errorf("got %+v\nwant %+v", got, want)
}
}
func TestDesiredLoadBalancer(t *testing.T) {
got := DesiredLoadBalancer(proxyLB(), "pool")
want := cfclient.LoadBalancer{
Name: "proxy.renner.uno", Description: testTag, DefaultPools: []string{"pool"}, FallbackPool: "pool",
Proxied: false, Enabled: true, TTL: 30, SteeringPolicy: "random",
}
if !reflect.DeepEqual(got, want) {
t.Errorf("got %+v\nwant %+v", got, want)
}
}
func TestOriginStatusFollowsSpec(t *testing.T) {
h := cfclient.PoolHealth{Origins: map[string]cfclient.OriginHealth{
"proxy-2": {Healthy: boolp(false), FailureReason: "timeout"},
"gone": {Healthy: boolp(true)},
}}
got := originStatus(proxyLB(), h)
if len(got) != 2 || got[0].Name != "proxy-1" || got[0].Healthy != nil ||
got[1].Name != "proxy-2" || *got[1].Healthy || got[1].FailureReason != "timeout" {
t.Errorf("got %+v", got)
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
ARG TALOS_VERSION=v1.13.12
ARG TALOS_VERSION=v1.13.10
ARG VELERO_VERSION=v1.16.0
ARG KUBECTL_VERSION=v1.36.3
ARG FLUX_VERSION=2.9.5
@@ -18,7 +18,7 @@ RUN apk add --no-cache \
RUN curl -sfL -o /usr/local/bin/talosctl \
"https://github.com/siderolabs/talos/releases/download/${TALOS_VERSION}/talosctl-linux-amd64" \
&& echo "4ce4218764c24d6a6bd561097199b60ed8619db073792fce5b7cfce7fa8d12fc /usr/local/bin/talosctl" | sha256sum -c - \
&& echo "28e9ccd9aebd690fa03d8cf2582221d87ea38b2a1704f411d75a03589c58f2aa /usr/local/bin/talosctl" | sha256sum -c - \
&& chmod +x /usr/local/bin/talosctl
RUN curl -sfL -o /usr/local/bin/kubectl \
+5 -4
View File
@@ -1,9 +1,10 @@
#!/usr/bin/env python3
"""netbird-service-sync: reconcile NetBird reverse-proxy services from Ingresses.
Source of truth: every Ingress labeled netbird.renner.uno/public=true. DNS is
independent of it: dns.renner.uno/internal (UDM) and dns.renner.uno/public
(Cloudflare) are read by the two external-dns instances.
Source of truth: every Ingress carrying one of the public labels.
dns.renner.uno/public also takes the host out of the LAN DNS (external-dns-unifi
skips it); netbird.renner.uno/public publishes on the proxy only and leaves the
LAN record in place.
For each host on such an Ingress the NetBird management gets one reverse-proxy
service in TLS-passthrough mode on :443, targeting Traefik's ClusterIP through a
subnet resource that covers the cluster's service CIDR. SNI and Host reach
@@ -44,7 +45,7 @@ NETWORK_NAME = os.environ.get("NETWORK_NAME", "k8s public ingress")
ROUTER_GROUP = os.environ.get("ROUTER_GROUP_NAME", "k8s-ingress-router")
SOURCE_GROUP = os.environ.get("SOURCE_GROUP_NAME", "All")
TRAEFIK_NS, TRAEFIK_SVC = os.environ.get("TRAEFIK_SERVICE", "ingress/traefik").split("/", 1)
PUBLIC_LABELS = os.environ.get("PUBLIC_LABELS", "netbird.renner.uno/public").split(",")
PUBLIC_LABELS = os.environ.get("PUBLIC_LABELS", "dns.renner.uno/public,netbird.renner.uno/public").split(",")
LISTEN_PORT = int(os.environ.get("LISTEN_PORT", "443"))
INTERVAL = int(os.environ.get("INTERVAL_SECONDS", "60"))
HTTP_TIMEOUT = 20
+2 -2
View File
@@ -14,7 +14,7 @@ FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cab
# kubernetes/talos/patches/cluster.yaml. The phase reads the version from that
# file at run time and passes it to the provisioner; this pin only decides which
# client talks to it.
ARG TALOS_VERSION=v1.13.12
ARG TALOS_VERSION=v1.13.10
ARG KUBECTL_VERSION=v1.36.2
ARG FLUX_VERSION=2.9.2
ARG KOPIA_VERSION=0.23.1
@@ -49,7 +49,7 @@ RUN apk add --no-cache \
RUN curl -sfL -o /usr/local/bin/talosctl \
"https://github.com/siderolabs/talos/releases/download/${TALOS_VERSION}/talosctl-linux-amd64" \
&& echo "4ce4218764c24d6a6bd561097199b60ed8619db073792fce5b7cfce7fa8d12fc /usr/local/bin/talosctl" | sha256sum -c - \
&& echo "28e9ccd9aebd690fa03d8cf2582221d87ea38b2a1704f411d75a03589c58f2aa /usr/local/bin/talosctl" | sha256sum -c - \
&& chmod +x /usr/local/bin/talosctl
RUN curl -sfL -o /usr/local/bin/kubectl \
@@ -1,56 +0,0 @@
# cloudflare-lb-sync — in-house Cloudflare Load Balancer controller (design)
## Why
`proxy.renner.uno` had two AAAA records and one A record with no health check, so every proxy reboot sent part of the public traffic into a dead address (OS-update run 2026-10-22). The owner chose a DNS-only Cloudflare Load Balancer with a health monitor and set one up by hand to show the shape. The owner decided that the load balancer belongs in the repo and that the cluster manages it, with the API token in OpenBao at `homelab/infrastructure/k8s-cloudflare-lb` (not in Woodpecker).
## Goals
- A Cloudflare DNS-only load balancer, its pool and its health monitor are declared in git as one typed Kubernetes resource.
- The controller adopts the hand-made `proxy.renner.uno` load balancer, pool and monitor instead of creating duplicates. The account allows only 2 origins in total, so a second pool next to the hand-made one would be rejected.
- Drift made in the dashboard is set back on the next resync.
- The resource status shows each origin's health, so `kubectl get` answers "is failover working".
## Non-goals
- Proxied (orange-cloud) load balancers, geo steering, session affinity, rules, multiple pools per load balancer.
- DNS records. The `*.renner.uno` wildcard and per-name records stay with external-dns.
- Garbage collection of load balancers whose resource disappeared while the controller was down. There is one load balancer; the finalizer covers normal deletion.
## Anti-goals
- Never modify or delete a load balancer, pool or monitor that another controller instance owns, or a pool or monitor that a load balancer or pool not owned by this resource still uses.
- No local state. Restarting with nothing but the cluster and Cloudflare gives the same result.
## Decisions (owner)
- Managed from the cluster, declared in the repo.
- Token in OpenBao `homelab/infrastructure/k8s-cloudflare-lb`, property `api-key`.
- Origins are hostnames (`proxy-1.renner.uno`, `proxy-2.renner.uno`), one per proxy, covering A and AAAA.
- HTTPS monitor with `Host: status.renner.uno`; health checks from all regions (no `check_regions`).
## Decisions (my calls, open to review)
- **Name** `cloudflare-lb-sync`, source `ci/images/cloudflare-lb-sync/`, image `gitea.renner.uno/gitea_admin/cloudflare-lb-sync`, same house pattern as `kuma-sync` (kaniko step in `build-ci-images.yml`, unit tests as a Containerfile stage, Flux image automation).
- **Client**: `github.com/cloudflare/cloudflare-go/v7` (official SDK, v7.13.1), wrapped behind a small internal interface so the reconciler is tested against a fake.
- **CRD** `CloudflareLoadBalancer`, group `cloudflare.renner.uno`, version `v1alpha1`, namespaced, short name `cflb`. Spec:
- `zone` (zone name), `hostname` (must be the zone or below it; immutable).
- `ttl` (default 30), `steeringPolicy` (`off` | `random`, default `random`).
- `monitor`: `type` (`http` | `https` | `tcp`, default `https`), `port` (default 443), `path` (default `/`), `expectedCodes` (default `200`), `host` (optional Host header), `interval` (default 60), `retries` (default 2), `timeout` (default 5).
- `origins` (1..n, list map keyed by `name`): `name`, `address` (IP or hostname), `weight` (0–100, sent as weight/100), `port` (optional).
- **Identity and ownership**: the load balancer is identified by its hostname in the zone. Pool and monitor are found by following the load balancer's references (`default_pools[0]`, then the pool's `monitor`). Each managed object's `description` is set to `cloudflare-lb-sync <namespace>/<name>`.
- Description equal to that tag: owned.
- Description empty or anything without the `cloudflare-lb-sync ` prefix: adopted (the hand-made objects), unless the pool or monitor is also referenced by another load balancer or pool — then a new one is created for this resource.
- Description with the prefix but another `<namespace>/<name>`: `OwnerConflict`, nothing is written.
- **Reconcile**: zone ID and account ID from the zone lookup. Ensure monitor → ensure pool (references monitor) → ensure load balancer (`default_pools` and `fallback_pool` = the pool). Compare desired and actual fields and send a full update (PUT) only when they differ. Pool name is the hostname with dots replaced by hyphens.
- **Status**: `loadBalancerID`, `poolID`, `monitorID`, `healthy` (pool), per-origin `{name, healthy, failureReason}`, `observedGeneration`, a `Ready` condition with fixed per-reason messages (house pattern).
- **Deletion**: finalizer `cloudflare.renner.uno/cloudflare-lb-sync` deletes the load balancer, then the pool, then the monitor, each only if owned and no longer referenced.
- **Resync** every 2 minutes, so the origin health in the status stays current. Status-only changes do not trigger a reconcile.
- **Scope**: watches `CloudflareLoadBalancer` cluster-wide. ClusterRole limited to the resource, its status and finalizers, and events.
- **Network**: egress only to DNS, kube-apiserver and `api.cloudflare.com:443`.
- **Single replica, Recreate, no leader election.**
- **Tests**: unit tests of the reconciler against a fake client (create, adopt hand-made objects, no-op when equal, update on drift, owner conflict, shared pool not adopted, deletion order), mapping tests, CRD validation tests.
## Left to the builder
Internal package layout, naming inside the code, log format.
+1 -1
View File
@@ -107,7 +107,7 @@ config, and the nodes are addressed by name.
## Step 1b — boot all three machines from install media
```
https://factory.talos.dev/image/0f6fb8bd8bb53013e9743b7c7b099d96fb9c32fd2464ced32693c2dc0ea5feb9/v1.13.12/metal-amd64.iso
https://factory.talos.dev/image/0f6fb8bd8bb53013e9743b7c7b099d96fb9c32fd2464ced32693c2dc0ea5feb9/v1.13.10/metal-amd64.iso
```
Write it to a USB stick and boot each MS-01 from it. Each comes up in
@@ -7,7 +7,6 @@ metadata:
# Registered publicly (Cloudflare wildcard -> NetBird proxy); the UDM
# external-dns instance skips it, LAN reaches it via the hairpin NAT.
dns.renner.uno/public: "true"
netbird.renner.uno/public: "true"
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
spec:
+1 -1
View File
@@ -114,7 +114,7 @@ spec:
type: RuntimeDefault
containers:
- name: controller
image: gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-10-22-1858 # {"$imagepolicy": "flux-system:authentik-gate-sync"}
image: gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-10-21-0755 # {"$imagepolicy": "flux-system:authentik-gate-sync"}
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
@@ -164,7 +164,6 @@ spec:
# Registered publicly (Cloudflare wildcard -> NetBird proxy); the
# UDM external-dns instance skips it, LAN uses the hairpin NAT.
dns.renner.uno/public: "true"
netbird.renner.uno/public: "true"
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
hosts:
@@ -27,7 +27,6 @@ metadata:
namespace: authentik
labels:
lb-pool: bgp
dns.renner.uno/internal: "true"
annotations:
# Pinned to .88 / fd99::88 (next free in the .80-.89 infra range
# alongside traefik .86, ha .87, syslog .84, syncthing .85). Pinning
@@ -1,64 +0,0 @@
# cloudflare-lb-sync: CloudflareLoadBalancer -> Cloudflare load balancers, pools and monitors (ci/images/cloudflare-lb-sync).
apiVersion: apps/v1
kind: Deployment
metadata:
name: cloudflare-lb-sync
namespace: cloudflare-lb-sync
labels:
app.kubernetes.io/name: cloudflare-lb-sync
annotations:
secret.reloader.stakater.com/reload: cloudflare-lb-sync-cloudflare
spec:
replicas: 1
# One writer against Cloudflare at a time; the controller runs without leader election.
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: cloudflare-lb-sync
template:
metadata:
labels:
app.kubernetes.io/name: cloudflare-lb-sync
spec:
serviceAccountName: cloudflare-lb-sync
securityContext:
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
seccompProfile:
type: RuntimeDefault
containers:
- name: controller
image: gitea.renner.uno/gitea_admin/cloudflare-lb-sync:2026-10-22-1904 # {"$imagepolicy": "flux-system:cloudflare-lb-sync"}
env:
- name: CLOUDFLARE_API_TOKEN
valueFrom:
secretKeyRef:
name: cloudflare-lb-sync-cloudflare
key: api-token
ports:
- name: metrics
containerPort: 8080
- name: probes
containerPort: 8081
livenessProbe:
httpGet:
path: /healthz
port: probes
readinessProbe:
httpGet:
path: /readyz
port: probes
resources:
requests:
cpu: 10m
memory: 64Mi
limits:
memory: 256Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
terminationGracePeriodSeconds: 30
@@ -1,19 +0,0 @@
---
# Cloudflare API token for cloudflare-lb-sync; OpenBao homelab/infrastructure/k8s-cloudflare-lb.
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: cloudflare-lb-sync-cloudflare
namespace: cloudflare-lb-sync
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: openbao
target:
name: cloudflare-lb-sync-cloudflare
data:
- secretKey: api-token
remoteRef:
key: infrastructure/k8s-cloudflare-lb
property: api-key
@@ -1,7 +0,0 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- externalsecret.yaml
- rbac.yaml
- deployment.yaml
- proxy-renner-uno.yaml
@@ -1,29 +0,0 @@
# DNS-only Cloudflare load balancer for proxy.renner.uno across both proxies.
apiVersion: cloudflare.renner.uno/v1alpha1
kind: CloudflareLoadBalancer
metadata:
name: proxy-renner-uno
namespace: cloudflare-lb-sync
spec:
zone: renner.uno
hostname: proxy.renner.uno
ttl: 30
steeringPolicy: random
monitor:
type: https
port: 443
path: /
expectedCodes: "200"
host: status.renner.uno
interval: 60
retries: 2
timeout: 5
origins:
- name: proxy-1
address: proxy-1.renner.uno
weight: 50
port: 443
- name: proxy-2
address: proxy-2.renner.uno
weight: 50
port: 443
@@ -1,40 +0,0 @@
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: cloudflare-lb-sync
namespace: cloudflare-lb-sync
labels:
app.kubernetes.io/name: cloudflare-lb-sync
---
# Cluster-wide: CloudflareLoadBalancer objects live in any namespace.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: cloudflare-lb-sync
rules:
- apiGroups: ["cloudflare.renner.uno"]
resources: ["cloudflareloadbalancers"]
verbs: ["get", "list", "watch", "update", "patch"]
- apiGroups: ["cloudflare.renner.uno"]
resources: ["cloudflareloadbalancers/status"]
verbs: ["get", "update", "patch"]
- apiGroups: ["cloudflare.renner.uno"]
resources: ["cloudflareloadbalancers/finalizers"]
verbs: ["update"]
- apiGroups: [""]
resources: ["events"]
verbs: ["create", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: cloudflare-lb-sync
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: cloudflare-lb-sync
subjects:
- kind: ServiceAccount
name: cloudflare-lb-sync
namespace: cloudflare-lb-sync
-2
View File
@@ -3,8 +3,6 @@ kind: Ingress
metadata:
name: evcc
namespace: evcc
labels:
dns.renner.uno/internal: "true"
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
spec:
@@ -43,16 +43,16 @@ spec:
name: cloudflare-api-token
key: api-token
extraArgs:
- --cloudflare-proxied
- --cloudflare-dns-records-per-page=5000
# Every public host is a DNS-only CNAME to the proxy load balancer (kubernetes/apps/cloudflare-lb-sync).
- --default-targets=proxy.renner.uno
- --default-targets=d2386ccd-a665-41e7-8631-603fdafcb006.cfargotunnel.com
- --force-default-targets
domainFilters:
- renner.uno
sources:
- ingress
txtOwnerId: k8s-homelab
policy: sync
policy: upsert-only
# Filter by label, not annotation. external-dns translates annotations
# in its prefix namespace into ProviderSpecific properties on desired
# endpoints; the UniFi webhook does not implement AdjustEndpoints, so
@@ -60,4 +60,4 @@ spec:
# records and cause every reconcile to delete-and-recreate the same
# values. Labels are not translated, so they are safe to use as a
# selector without leaking provider-specific state to the UniFi side.
labelFilter: dns.renner.uno/public=true
labelFilter: external-dns/cloudflare=true
-1
View File
@@ -7,7 +7,6 @@ metadata:
# Registered publicly (Cloudflare wildcard -> NetBird proxy); the UDM
# external-dns instance skips it, LAN reaches it via the hairpin NAT.
dns.renner.uno/public: "true"
netbird.renner.uno/public: "true"
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
spec:
-2
View File
@@ -7,8 +7,6 @@ metadata:
app.kubernetes.io/name: gitea-mcp
# Published on the NetBird proxy; LAN DNS stays on the Traefik VIP.
netbird.renner.uno/public: "true"
dns.renner.uno/internal: "true"
dns.renner.uno/public: "true"
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
spec:
-2
View File
@@ -8,8 +8,6 @@ metadata:
# Published on the NetBird proxy; LAN DNS stays on the Traefik VIP so
# SSH (gitea-ssh) and node image pulls stay local.
netbird.renner.uno/public: "true"
dns.renner.uno/internal: "true"
dns.renner.uno/public: "true"
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
# Long-running git fetch/push and container-registry blob uploads:
-4
View File
@@ -11,8 +11,6 @@ kind: Ingress
metadata:
name: hermes
namespace: hermes
labels:
dns.renner.uno/internal: "true"
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
spec:
@@ -40,8 +38,6 @@ kind: Ingress
metadata:
name: hermes-api
namespace: hermes
labels:
dns.renner.uno/internal: "true"
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
spec:
@@ -19,8 +19,11 @@ expert's PR goes back to the card that opened it, without a manual step.
labels, PR reviews, PR comments, PR open/close) posts to
`http://hermes.hermes.svc.cluster.local:8644/webhooks/gitea-issues`.
The CronJob `gitea-webhook-sync` (namespace hermes, every 15 min) keeps that hook in
place, creates the domain labels and backfills, all only in repos where hermes-bot
has issue write (org labels for their orgs, repo labels for user-owned ones): open
place, creates the domain labels and backfills, all in every non-archived repo with
issues of the orgs hermes-bot belongs to, where an org team grants it issue write
(today: all repos of `konairius` through team `Bots`). Labels are org labels only.
User-owned repos and repos where hermes-bot is only a direct collaborator get neither
labels nor backfill; their new issues still become cards through the system hook. Backfill: open
issues without a card comment are replayed to the route as signed `opened`
deliveries, at most 10 per run, and the reviews of open hermes-bot PRs are replayed
(step 8). Its site-admin PAT lives at OpenBao `apps/gitea-webhook-sync/admin`,
@@ -7,10 +7,10 @@
active system hook carries the desired name, a new one is created first and every
other hook with the prefix is deleted afterwards.
2. Domain labels. hestia/atlas/apollo/clio/daedalus are created only where Hermes
works issues: repos with an issue tracker in which BOT_LOGIN (hermes-bot) has
issue write. They become org labels in each org owning such a repo and repo labels
in such user-owned repos (org labels do not reach those). Existing labels are left
as they are; only missing ones are created.
works issues: the non-archived org repos with an issue tracker in which BOT_LOGIN
(hermes-bot) may write issues through an org team (it labels and comments). They
are org labels of those orgs, never repo labels; user-owned repos are out of scope.
Existing labels are left as they are; only missing ones are created.
3. Backfill. Open issues of the same repos without the bridge's card comment are
replayed to the Hermes webhook route as signed `opened` deliveries, at most
BACKFILL_LIMIT per run and none started after RUN_BUDGET seconds, in random order
@@ -60,9 +60,10 @@ BACKFILL_SPACING = float(os.environ.get("BACKFILL_SPACING_SECONDS", "3"))
RUN_BUDGET = float(os.environ.get("RUN_BUDGET_SECONDS", "900"))
DELIVER_TIMEOUT = 60
CARD_MARKER = "<!-- hermes-kanban-card:"
# Labels and backfill only where this account may write issues (it labels and comments).
# Labels and backfill only in org repos this account may write issues in (it labels and comments).
BOT_LOGIN = os.environ.get("BOT_LOGIN", "hermes-bot")
BOT_WRITE = ("write", "admin", "owner")
ISSUES_UNIT = "repo.issues"
STARTED = time.monotonic()
# Review replay (step 4): reviews by these accounts on open BOT_LOGIN PRs.
REVIEW_BOT_LOGIN = os.environ.get("REVIEW_BOT_LOGIN", "review-bot")
@@ -110,13 +111,11 @@ def api(method: str, path: str, token: str, body: dict | None = None):
def paged(path: str, token: str) -> list[dict]:
"""All pages of a list endpoint; /repos/search wraps its list in {"data": [...]}."""
"""All pages of a list endpoint."""
out, page = [], 1
sep = "&" if "?" in path else "?"
while True:
batch = api("GET", f"{path}{sep}page={page}&limit=50", token) or []
if isinstance(batch, dict):
batch = batch.get("data") or []
out.extend(batch)
if len(batch) < 50:
return out
@@ -160,25 +159,56 @@ def reconcile(token: str, secret: str) -> str:
# --- 2. domain labels --------------------------------------------------------
def team_writes_issues(team: dict) -> bool:
"""Issue write from the team's unit map (Gitea >= 1.20), else its team-wide mode."""
if team.get("permission") in ("admin", "owner"):
return True
units = team.get("units_map") or {}
return (units.get(ISSUES_UNIT) if units else team.get("permission")) in BOT_WRITE
def issue_repos(token: str, errors: list[str] | None = None) -> list[dict]:
"""Repos whose issues Hermes works: issue tracker on, not archived, and hermes-bot may
write issues there (it sets the labels and comments). A site admin sees every repo.
A repo whose permission lookup fails is skipped and named in `errors`."""
out = []
for r in paged("/repos/search", token):
if not r.get("has_issues", True) or r.get("archived"):
"""Repos whose issues Hermes works: in each org BOT_LOGIN belongs to, the repos of its
teams that grant issue write (every org repo for an includes_all_repositories team),
issue tracker on, not archived. Not /collaborators/{user}/permission: that reports
the repo-wide access mode, which stays "none" when the write comes from a unit-based
team. The admin route lists private orgs too and needs only the PAT's admin scope
(/users/{u}/orgs also requires read:user). An org or team whose lookup fails is
skipped and named in `errors`."""
def failed(what: str, exc: RuntimeError) -> None:
if errors is None:
raise exc
errors.append(f"{what}: {exc}")
found: dict[str, dict] = {}
for org in paged(f"/admin/users/{q(BOT_LOGIN)}/orgs", token):
name = org.get("username") or org.get("name") or ""
if not name:
continue
owner, _, name = r["full_name"].partition("/")
try:
perm = api("GET", f"/repos/{q(owner)}/{q(name)}/collaborators/{q(BOT_LOGIN)}/permission", token)
teams = paged(f"/orgs/{q(name)}/teams", token)
except RuntimeError as exc:
if errors is None:
raise
errors.append(f"{r['full_name']}: {exc}")
failed(name, exc)
continue
if (perm or {}).get("permission") in BOT_WRITE:
out.append(r)
return out
for team in teams:
if not team_writes_issues(team):
continue
try:
try:
api("GET", f"/teams/{team['id']}/members/{q(BOT_LOGIN)}", token)
except RuntimeError as exc:
if str(exc).endswith("HTTP 404"):
continue # not a member of this team
raise
repos = paged(f"/orgs/{q(name)}/repos" if team.get("includes_all_repositories")
else f"/teams/{team['id']}/repos", token)
except RuntimeError as exc:
failed(f"{name} team {team.get('name')}", exc)
continue
for r in repos:
if r.get("has_issues", True) and not r.get("archived"):
found.setdefault(r["full_name"], r)
return [found[k] for k in sorted(found)]
def ensure_labels_at(path: str, token: str) -> list[str]:
@@ -192,18 +222,14 @@ def ensure_labels_at(path: str, token: str) -> list[str]:
def ensure_labels(token: str, repos: list[dict]) -> str:
"""Org labels in each org that owns one of `repos`, repo labels in the user-owned ones."""
orgs = {n for o in paged("/admin/orgs", token) if (n := o.get("username") or o.get("name"))}
owners = {(r.get("owner") or {}).get("login") for r in repos}
targets = [f"/orgs/{q(org)}/labels" for org in sorted(orgs & owners)]
targets += [f"/repos/{q(r['owner']['login'])}/{q(r['name'])}/labels" for r in repos
if (r.get("owner") or {}).get("login") not in orgs]
"""Org labels in each org that owns one of `repos` (all org repos); never repo labels."""
orgs = sorted({o for r in repos if (o := (r.get("owner") or {}).get("login"))})
actions = []
for path in targets:
created = ensure_labels_at(path, token)
for org in orgs:
created = ensure_labels_at(f"/orgs/{q(org)}/labels", token)
if created:
actions.append(f"{path.rsplit('/labels', 1)[0]}: created {','.join(created)}")
return "; ".join(actions) or f"labels present in {len(targets)} orgs/repos"
actions.append(f"/orgs/{org}: created {','.join(created)}")
return "; ".join(actions) or f"labels present in {len(orgs)} orgs"
# --- 3. backfill -------------------------------------------------------------
@@ -372,9 +398,9 @@ def main() -> int:
def load_repos() -> str:
errors: list[str] = []
repos.extend(issue_repos(token, errors))
msg = f"{len(repos)} with issues writable by {BOT_LOGIN}"
msg = f"{len(repos)} org repos with issues writable by {BOT_LOGIN}"
if errors: # the other repos still get labels and backfill; the Job fails
raise RuntimeError(f"{msg}; permission lookup failed, skipped: {'; '.join(errors)}")
raise RuntimeError(f"{msg}; team lookup failed, skipped: {'; '.join(errors)}")
return msg
failures = 0
@@ -701,62 +701,120 @@ def repo(full, has_issues=True, archived=False):
"has_issues": has_issues, "archived": archived}
def team(tid, name, issues="write", all_repos=True, permission="write"):
return {"id": tid, "name": name, "includes_all_repositories": all_repos,
"permission": permission, "units_map": {"repo.issues": issues, "repo.code": "write"}}
def fake_gitea(orgs, teams, members, repos, fail=()):
"""api() stand-in: orgs of the bot, teams per org, bot's team ids, repos per org or team."""
seen = []
def fake(method, path, token, body=None):
p = path.split("?")[0]
seen.append(p)
if p in fail:
raise RuntimeError(f"GET {p}: HTTP 500")
if p == "/admin/users/hermes-bot/orgs":
return [{"username": o} for o in orgs]
if p.startswith("/orgs/") and p.endswith("/teams"):
return teams.get(p.split("/")[2], [])
if p.startswith("/teams/") and "/members/" in p:
if int(p.split("/")[2]) in members:
return {"login": "hermes-bot"}
raise RuntimeError(f"GET {p}: HTTP 404")
if p.endswith("/repos"):
return repos.get(p, [])
raise AssertionError(f"unexpected {method} {p}")
return fake, seen
class DomainLabels(unittest.TestCase):
def test_labels_only_for_orgs_and_user_repos_hermes_works(self):
def test_org_labels_only_never_repo_labels(self):
calls = []
def fake(method, path, token, body=None):
calls.append((method, path, body))
if path.startswith("/admin/orgs"):
return [{"username": "konairius"}, {"username": "unrelated-org"}]
if path.startswith("/orgs/konairius/labels"):
return [{"name": "Atlas"}, {"name": "bug"}]
return []
repos = [repo("konairius/homelab"), repo("k.renner/turm1388")]
repos = [repo("konairius/homelab"), repo("konairius/shelly-manager")]
with mock.patch.object(sync, "api", side_effect=fake):
msg = sync.ensure_labels("tok", repos)
posts = [(p, b["name"]) for m, p, b in calls if m == "POST"]
self.assertEqual(sorted(n for p, n in posts if p == "/orgs/konairius/labels"),
["apollo", "clio", "daedalus", "hestia"]) # atlas exists (any case)
self.assertEqual(len([p for p, _ in posts if p == "/repos/k.renner/turm1388/labels"]), 5)
self.assertFalse([p for p, _ in posts if p.startswith("/repos/konairius/")]) # org labels reach it
self.assertFalse([c for c in calls if "unrelated-org" in c[1]]) # no repo of it is worked
self.assertFalse([c for c in calls if c[1].startswith("/repos/")]) # org labels reach them
self.assertFalse([c for c in calls if c[1].startswith("/admin/")])
self.assertIn("created", msg)
def test_repos_without_issues_archived_or_bot_write_are_skipped(self):
perms = {"a/x": "write", "a/w": "admin", "gitea_admin/_cargo-index": "read", "a/n": "none"}
seen = []
def fake(method, path, token, body=None):
if path.startswith("/repos/search"):
return {"ok": True, "data": [
repo("a/x"), repo("a/w"), repo("gitea_admin/_cargo-index"), repo("a/n"),
repo("a/y", has_issues=False), repo("a/z", archived=True)]}
seen.append(path)
full = path.split("/repos/")[1].split("/collaborators/")[0]
return {"permission": perms[full]}
def test_team_write_covers_every_org_repo_without_collaborator(self):
# Live 2026-10-21: hermes-bot is only in team Bots (all repos, issues write);
# /collaborators/hermes-bot/permission says "none" for 9 of the 10 repos.
org_repos = [repo("konairius/homelab"), repo("konairius/shelly-manager"),
repo("konairius/ical-proxy"), repo("konairius/off", has_issues=False),
repo("konairius/old", archived=True)]
fake, seen = fake_gitea(
orgs=["konairius"],
teams={"konairius": [team(1, "Owners", "owner", permission="owner"), team(2, "Bots"),
team(5, "Reviewers", "read", permission="none")]},
members={2},
repos={"/orgs/konairius/repos": org_repos})
with mock.patch.object(sync, "api", side_effect=fake):
got = [r["full_name"] for r in sync.issue_repos("tok")]
self.assertEqual(got, ["a/x", "a/w"])
self.assertIn("/repos/a/x/collaborators/hermes-bot/permission", seen)
self.assertFalse([p for p in seen if p.startswith(("/repos/a/y/", "/repos/a/z/"))])
self.assertEqual(got, ["konairius/homelab", "konairius/ical-proxy", "konairius/shelly-manager"])
self.assertFalse([p for p in seen if "/collaborators/" in p or p.startswith("/repos/")])
self.assertNotIn("/teams/5/members/hermes-bot", seen) # read-only team is not even checked
def test_failed_permission_lookup_skips_only_that_repo(self):
def fake(method, path, token, body=None):
if path.startswith("/repos/search"):
return {"data": [repo("a/x"), repo("a/broken"), repo("a/w")]}
if path.startswith("/repos/a/broken/"):
raise RuntimeError("GET /repos/a/broken/...: HTTP 500")
return {"permission": "write"}
def test_team_with_selected_repos_and_no_issue_write(self):
fake, seen = fake_gitea(
orgs=["a", "b"],
teams={"a": [team(3, "Some", all_repos=False)],
"b": [team(4, "CodeOnly", issues="read")]},
members={3, 4},
repos={"/teams/3/repos": [repo("a/x"), repo("a/y")],
"/orgs/a/repos": [repo("a/x"), repo("a/y"), repo("a/not-in-team")],
"/orgs/b/repos": [repo("b/z")]})
with mock.patch.object(sync, "api", side_effect=fake):
got = [r["full_name"] for r in sync.issue_repos("tok")]
self.assertEqual(got, ["a/x", "a/y"])
self.assertNotIn("/orgs/b/repos", seen)
def test_team_issue_write_from_units_map_or_team_mode(self):
self.assertTrue(sync.team_writes_issues(team(2, "Bots")))
self.assertFalse(sync.team_writes_issues(team(5, "Reviewers", "read", permission="none")))
self.assertTrue(sync.team_writes_issues(team(1, "Owners", "read", permission="owner")))
self.assertTrue(sync.team_writes_issues({"permission": "write"})) # no units_map
self.assertFalse(sync.team_writes_issues({"permission": "read", "units_map": {}}))
def test_user_repos_are_out_of_scope(self):
fake, seen = fake_gitea(orgs=[], teams={}, members=set(), repos={})
with mock.patch.object(sync, "api", side_effect=fake):
self.assertEqual(sync.issue_repos("tok"), [])
self.assertEqual(seen, ["/admin/users/hermes-bot/orgs"]) # no /repos/search over user repos
def test_failed_team_lookup_skips_only_that_team(self):
fake, _ = fake_gitea(
orgs=["a", "broken"],
teams={"a": [team(3, "Bots", all_repos=False), team(6, "More", all_repos=False)]},
members={3, 6},
repos={"/teams/3/repos": [repo("a/x")], "/teams/6/repos": [repo("a/w"), repo("a/x")]},
fail={"/orgs/broken/teams", "/teams/6/members/hermes-bot"})
errors = []
with mock.patch.object(sync, "api", side_effect=fake):
got = [r["full_name"] for r in sync.issue_repos("tok", errors)]
self.assertEqual(got, ["a/x", "a/w"])
self.assertEqual(len(errors), 1)
self.assertIn("a/broken", errors[0])
self.assertEqual(got, ["a/x"])
self.assertEqual(len(errors), 2)
self.assertIn("a team More", errors[0])
self.assertTrue(errors[1].startswith("broken:"))
def test_failed_lookup_raises_without_error_list(self):
fake, _ = fake_gitea(orgs=["broken"], teams={}, members=set(), repos={},
fail={"/orgs/broken/teams"})
with mock.patch.object(sync, "api", side_effect=fake), self.assertRaises(RuntimeError):
sync.issue_repos("tok")
class Backfill(unittest.TestCase):
@@ -12,8 +12,6 @@ kind: Ingress
metadata:
name: shelly-manager
namespace: home-assistant
labels:
dns.renner.uno/internal: "true"
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
auth.renner.uno/group: Shelly Managers
@@ -16,7 +16,6 @@ metadata:
labels:
app.kubernetes.io/name: home-assistant
lb-pool: bgp
dns.renner.uno/internal: "true"
annotations:
lbipam.cilium.io/ips: "10.2.99.87,fd99::87"
external-dns.kubernetes.io/hostname: ha.renner.uno
@@ -3,8 +3,6 @@ kind: Ingress
metadata:
name: esphome
namespace: home-assistant
labels:
dns.renner.uno/internal: "true"
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
auth.renner.uno/group: ESPHome Managers
@@ -7,7 +7,6 @@ metadata:
# Registered publicly (Cloudflare wildcard -> NetBird proxy); the UDM
# external-dns instance skips it, LAN reaches it via the hairpin NAT.
dns.renner.uno/public: "true"
netbird.renner.uno/public: "true"
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
spec:
@@ -13,7 +13,6 @@ metadata:
labels:
app.kubernetes.io/name: mosquitto
lb-pool: bgp
dns.renner.uno/internal: "true"
annotations:
lbipam.cilium.io/ips: "10.2.99.81,fd99::81"
# Publish an internal A record to UniFi DNS so workstations on the
@@ -10,8 +10,6 @@ kind: Ingress
metadata:
name: home-assistant-vscode
namespace: home-assistant
labels:
dns.renner.uno/internal: "true"
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
auth.renner.uno/group: Home Assistant Config Editors
@@ -3,8 +3,6 @@ kind: Ingress
metadata:
name: zigbee2mqtt
namespace: home-assistant
labels:
dns.renner.uno/internal: "true"
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
auth.renner.uno/group: Zigbee2MQTT Managers
@@ -104,8 +104,6 @@ kind: Ingress
metadata:
name: ical-proxy
namespace: apps
labels:
dns.renner.uno/internal: "true"
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
spec:
+3 -5
View File
@@ -7,7 +7,6 @@ metadata:
# Registered publicly (Cloudflare wildcard -> NetBird proxy); the UDM
# external-dns instance skips it, LAN reaches it via the hairpin NAT.
dns.renner.uno/public: "true"
netbird.renner.uno/public: "true"
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
spec:
@@ -28,8 +27,9 @@ spec:
hosts:
- immich.renner.uno
---
# photos.renner.uno is LAN-only (UDM record, no proxy), so it lives on its
# own Ingress: DNS label filters are per-resource. It was the LXC alias
# photos.renner.uno is LAN-registered (no proxy tunnel), so it lives on its
# own unlabeled Ingress: the dns.renner.uno/public label-filter is
# per-resource, and this name must keep its UDM record. It was the LXC alias
# served by Caddy; preserved so external-dns-unifi can take over once the
# tofu CNAME on lxc_225 is removed.
apiVersion: networking.k8s.io/v1
@@ -37,8 +37,6 @@ kind: Ingress
metadata:
name: immich-photos
namespace: immich
labels:
dns.renner.uno/internal: "true"
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
spec:
+1 -1
View File
@@ -30,7 +30,7 @@ spec:
type: RuntimeDefault
containers:
- name: controller
image: gitea.renner.uno/gitea_admin/kuma-sync:2026-10-22-1858 # {"$imagepolicy": "flux-system:kuma-sync"}
image: gitea.renner.uno/gitea_admin/kuma-sync:2026-10-21-0756 # {"$imagepolicy": "flux-system:kuma-sync"}
env:
- name: KUMA_URL
value: https://kuma.renner.uno
@@ -19,7 +19,6 @@ metadata:
# external-dns instance skips anything carrying this, and LAN clients reach
# the same hostname through the hairpin NAT.
dns.renner.uno/public: "true"
netbird.renner.uno/public: "true"
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
# Manifest and service worker are fetched without credentials; neither
@@ -35,8 +35,6 @@ kind: Ingress
metadata:
name: wiki-embed
namespace: ai
labels:
dns.renner.uno/internal: "true"
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
traefik.ingress.kubernetes.io/router.middlewares: ai-wiki-embed-basic-auth@kubernetescrd
-3
View File
@@ -16,7 +16,6 @@ metadata:
# Registered publicly (Cloudflare wildcard -> NetBird proxy); the UDM
# external-dns instance skips it, LAN reaches it via the hairpin NAT.
dns.renner.uno/public: "true"
netbird.renner.uno/public: "true"
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
spec:
@@ -126,7 +125,6 @@ metadata:
# Registered publicly (Cloudflare wildcard -> NetBird proxy); the UDM
# external-dns instance skips it, LAN reaches it via the hairpin NAT.
dns.renner.uno/public: "true"
netbird.renner.uno/public: "true"
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
spec:
@@ -157,7 +155,6 @@ metadata:
# Registered publicly (Cloudflare wildcard -> NetBird proxy); the UDM
# external-dns instance skips it, LAN reaches it via the hairpin NAT.
dns.renner.uno/public: "true"
netbird.renner.uno/public: "true"
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
spec:
-2
View File
@@ -317,7 +317,6 @@ metadata:
namespace: matrix
labels:
dns.renner.uno/public: "true"
netbird.renner.uno/public: "true"
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
traefik.ingress.kubernetes.io/router.middlewares: matrix-lk-jwt-strip@kubernetescrd
@@ -370,7 +369,6 @@ metadata:
namespace: matrix
labels:
dns.renner.uno/public: "true"
netbird.renner.uno/public: "true"
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: web
spec:
@@ -9,8 +9,6 @@ kind: Ingress
metadata:
name: alertmanager
namespace: monitoring
labels:
dns.renner.uno/internal: "true"
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
auth.renner.uno/group: Monitoring Access
@@ -229,7 +229,6 @@ metadata:
namespace: monitoring
labels:
lb-pool: bgp
dns.renner.uno/internal: "true"
annotations:
io.cilium/lb-ipam-ips: "10.2.99.84,fd99::84"
external-dns.kubernetes.io/hostname: syslog.renner.uno
@@ -132,8 +132,6 @@ spec:
ingress:
enabled: true
ingressClassName: traefik
labels:
dns.renner.uno/internal: "true"
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
hosts:
@@ -3,8 +3,6 @@ kind: Ingress
metadata:
name: loki
namespace: monitoring
labels:
dns.renner.uno/internal: "true"
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
# nginx's auth-type/auth-secret/auth-realm annotation triple becomes
@@ -9,8 +9,6 @@ kind: Ingress
metadata:
name: prometheus
namespace: monitoring
labels:
dns.renner.uno/internal: "true"
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
auth.renner.uno/group: Monitoring Access
@@ -7,7 +7,6 @@ metadata:
# Registered publicly (Cloudflare wildcard -> NetBird proxy); the UDM
# external-dns instance skips it, LAN reaches it via the hairpin NAT.
dns.renner.uno/public: "true"
netbird.renner.uno/public: "true"
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
spec:
-2
View File
@@ -6,7 +6,6 @@ metadata:
namespace: navidrome
labels:
dns.renner.uno/public: "true"
netbird.renner.uno/public: "true"
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
spec:
@@ -46,7 +45,6 @@ metadata:
namespace: navidrome
labels:
dns.renner.uno/public: "true"
netbird.renner.uno/public: "true"
annotations:
traefik.ingress.kubernetes.io/router.middlewares: navidrome-navidrome-strip-identity@kubernetescrd
spec:
-1
View File
@@ -194,7 +194,6 @@ metadata:
namespace: navidrome
labels:
dns.renner.uno/public: "true"
netbird.renner.uno/public: "true"
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
auth.renner.uno/group: Music Librarians
@@ -27,7 +27,6 @@ metadata:
# Registered publicly (Cloudflare wildcard -> NetBird proxy); the UDM
# external-dns instance skips it, LAN reaches it via the hairpin NAT.
dns.renner.uno/public: "true"
netbird.renner.uno/public: "true"
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
traefik.ingress.kubernetes.io/router.middlewares: paperless-docs-to-paperless-redirect@kubernetescrd
@@ -113,7 +113,6 @@ metadata:
namespace: paperless
labels:
lb-pool: bgp
dns.renner.uno/internal: "true"
annotations:
io.cilium/lb-ipam-ips: "10.2.99.82,fd99::82"
# LAN-only DNS via the UniFi external-dns instance. No cloudflare
-1
View File
@@ -357,7 +357,6 @@ metadata:
# Registered publicly (Cloudflare wildcard -> NetBird proxy); the UDM
# external-dns instance skips it, LAN reaches it via the hairpin NAT.
dns.renner.uno/public: "true"
netbird.renner.uno/public: "true"
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
auth.renner.uno/group: Paperless Users
-1
View File
@@ -143,7 +143,6 @@ metadata:
namespace: paperless
labels:
lb-pool: bgp
dns.renner.uno/internal: "true"
annotations:
io.cilium/lb-ipam-ips: "10.2.99.85,fd99::85"
external-dns.kubernetes.io/hostname: syncthing.paperless.renner.uno
+1 -1
View File
@@ -32,7 +32,7 @@ spec:
type: RuntimeDefault
containers:
- name: review-bot
image: gitea.renner.uno/gitea_admin/review-bot:2026-10-22-1854 # {"$imagepolicy": "flux-system:review-bot"}
image: gitea.renner.uno/gitea_admin/review-bot:2026-10-21-0752 # {"$imagepolicy": "flux-system:review-bot"}
env:
- name: REPOS
value: konairius/homelab,konairius/homeassistant,konairius/authentik-samba-bridge,konairius/shelly-manager
-1
View File
@@ -6,7 +6,6 @@ metadata:
namespace: review-bot
labels:
app.kubernetes.io/name: review-bot
dns.renner.uno/internal: "true"
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
spec:
@@ -28,8 +28,6 @@ kind: Ingress
metadata:
name: terminus-device
namespace: trmnl
labels:
dns.renner.uno/internal: "true"
# No external-dns hostname annotation: external-dns-unifi runs with
# --source=ingress, so it registers the hosts in spec.rules on its own.
# Same as the terminus Ingress next door.
-2
View File
@@ -7,8 +7,6 @@ kind: Ingress
metadata:
name: terminus
namespace: trmnl
labels:
dns.renner.uno/internal: "true"
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
spec:
-4
View File
@@ -6,8 +6,6 @@ kind: Ingress
metadata:
name: wiki
namespace: ai
labels:
dns.renner.uno/internal: "true"
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
spec:
@@ -45,8 +43,6 @@ kind: Ingress
metadata:
name: wiki-api
namespace: ai
labels:
dns.renner.uno/internal: "true"
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
traefik.ingress.kubernetes.io/router.middlewares: ai-wiki-api-basic-auth@kubernetescrd
-1
View File
@@ -5,7 +5,6 @@ metadata:
namespace: woodpecker
labels:
app.kubernetes.io/name: woodpecker
dns.renner.uno/internal: "true"
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
# Live log streams (SSE/WebSocket) hold the connection open for the
+1 -1
View File
@@ -25,7 +25,7 @@ spec:
fsGroup: 33
containers:
- name: wordpress
image: wordpress:7.1.3-php8.5-apache@sha256:ce31904fa1e51b73543fa2e35cc8f1747589b2abf48dc747b422749b88b77e9e
image: wordpress:7.1.3-php8.3-apache@sha256:8ecd8d8e374167ea22235acf30f80d1165b82f07ffa5bcef90c1cad8f7be251f
ports:
- name: http
containerPort: 80
+3 -2
View File
@@ -16,7 +16,6 @@ metadata:
namespace: wordpress
labels:
dns.renner.uno/public: "true"
netbird.renner.uno/public: "true"
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
spec:
@@ -51,8 +50,10 @@ metadata:
name: wordpress-admin
namespace: wordpress
labels:
# external-dns-unifi runs with --label-filter=dns.renner.uno/public!=true,
# so an unlabelled Ingress on this host would gain a UDM A-record pointing
# LAN clients at the Traefik VIP.
dns.renner.uno/public: "true"
netbird.renner.uno/public: "true"
annotations:
auth.renner.uno/group: WordPress Authors
spec:
@@ -9,8 +9,6 @@ kind: Ingress
metadata:
name: zone-configurator
namespace: home-assistant
labels:
dns.renner.uno/internal: "true"
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
auth.renner.uno/group: Zone Configurators
@@ -1,26 +0,0 @@
# cloudflare-lb-sync controller: CloudflareLoadBalancer -> Cloudflare load balancers; ships the proxy.renner.uno object,
# the CRD in infrastructure/cloudflare-lb-sync-crds.
# 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: cloudflare-lb-sync
namespace: flux-system
spec:
interval: 10m
retryInterval: 2m
timeout: 5m
sourceRef:
kind: GitRepository
name: flux-system
path: ./kubernetes/apps/cloudflare-lb-sync
prune: true
wait: true
healthCheckExprs:
- apiVersion: external-secrets.io/v1
kind: ExternalSecret
failed: "has(status.conditions) && status.conditions.exists(c, c.type == 'Ready' && c.status == 'False' && (c.reason == 'SecretOwnedByOther' || c.reason == 'SecretImmutable'))"
current: "has(status.conditions) && status.conditions.exists(c, c.type == 'Ready' && c.status == 'True')"
dependsOn:
- name: infrastructure
- name: infrastructure-crds
@@ -1,5 +1,6 @@
# Reconciles NetBird reverse-proxy services from Ingresses that carry
# netbird.renner.uno/public: one TLS-passthrough service per host on :443,
# dns.renner.uno/public or netbird.renner.uno/public (proxy only, LAN DNS
# untouched): one TLS-passthrough service per host on :443,
# targeting Traefik's ClusterIP through a subnet resource over the service
# CIDR (ci/images/netbird-service-sync/controller.py).
#
@@ -1,269 +0,0 @@
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
kustomize.toolkit.fluxcd.io/prune: disabled
controller-gen.kubebuilder.io/version: v0.23.0
name: cloudflareloadbalancers.cloudflare.renner.uno
spec:
group: cloudflare.renner.uno
names:
kind: CloudflareLoadBalancer
listKind: CloudflareLoadBalancerList
plural: cloudflareloadbalancers
shortNames:
- cflb
singular: cloudflareloadbalancer
scope: Namespaced
versions:
- additionalPrinterColumns:
- jsonPath: .spec.hostname
name: Hostname
type: string
- jsonPath: .status.healthy
name: Healthy
type: boolean
- 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:
hostname:
maxLength: 253
pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?\.)+[a-z]{2,}$
type: string
x-kubernetes-validations:
- message: hostname is immutable
rule: self == oldSelf
monitor:
default: {}
description: MonitorSpec is the health monitor attached to the pool.
properties:
expectedCodes:
default: "200"
description: Ignored for type tcp.
minLength: 1
type: string
host:
description: Host header sent with the check.
minLength: 1
type: string
interval:
default: 60
description: Seconds.
format: int32
minimum: 1
type: integer
path:
default: /
description: Ignored for type tcp.
minLength: 1
type: string
port:
default: 443
format: int32
maximum: 65535
minimum: 1
type: integer
retries:
default: 2
format: int32
minimum: 0
type: integer
timeout:
default: 5
description: Seconds.
format: int32
minimum: 1
type: integer
type:
default: https
enum:
- http
- https
- tcp
type: string
type: object
x-kubernetes-validations:
- message: host is not allowed for type tcp
rule: self.type != 'tcp' || !has(self.host)
origins:
items:
description: OriginSpec is one origin of the pool.
properties:
address:
description: IP address or hostname.
minLength: 1
type: string
name:
maxLength: 63
pattern: ^[A-Za-z0-9_-]+$
type: string
port:
description: Unset means the default port of the protocol.
format: int32
maximum: 65535
minimum: 1
type: integer
weight:
default: 100
description: Percent; sent to Cloudflare as weight/100.
format: int32
maximum: 100
minimum: 0
type: integer
required:
- address
- name
type: object
minItems: 1
type: array
x-kubernetes-list-map-keys:
- name
x-kubernetes-list-type: map
steeringPolicy:
default: random
enum:
- "off"
- random
type: string
ttl:
default: 30
description: Seconds.
format: int32
minimum: 1
type: integer
zone:
description: Zone name, e.g. renner.uno.
maxLength: 253
pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?\.)+[a-z]{2,}$
type: string
required:
- hostname
- origins
- zone
type: object
x-kubernetes-validations:
- message: hostname must be the zone or a name below it
rule: self.hostname == self.zone || self.hostname.endsWith('.' + self.zone)
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
healthy:
description: Pool health as reported by Cloudflare; unset until reported.
type: boolean
loadBalancerID:
type: string
monitorID:
type: string
observedGeneration:
format: int64
type: integer
origins:
items:
description: OriginStatus is the health Cloudflare reports for one
origin.
properties:
failureReason:
type: string
healthy:
type: boolean
name:
type: string
required:
- name
type: object
type: array
x-kubernetes-list-map-keys:
- name
x-kubernetes-list-type: map
poolID:
type: string
type: object
required:
- spec
type: object
served: true
storage: true
subresources:
status: {}
@@ -1,4 +0,0 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- crd-cloudflareloadbalancers.yaml
@@ -46,7 +46,6 @@ metadata:
# Public DNS via Cloudflare; the proxies resolve it from outside. The UDM
# external-dns instance skips it, LAN reaches it via the hairpin NAT.
dns.renner.uno/public: "true"
netbird.renner.uno/public: "true"
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
# NOT behind the Authentik gate: the client is a daemon with an API key,
@@ -82,12 +82,25 @@ spec:
# once via the UDM UI instead.
domainFilters:
- renner.uno
# Opt-in: only resources labeled dns.renner.uno/internal=true get a UDM
# record. Public hosts without it resolve on-LAN via the public answer and
# the UDM hairpin (opentofu/unifi_nat_hairpin.tf). The filter is
# per-resource; policy: sync deletes the records of resources that lose
# the label.
labelFilter: dns.renner.uno/internal=true
# The label controls where a domain is registered: resources labeled
# dns.renner.uno/public=true are publicly-served (Cloudflare wildcard ->
# NetBird proxy) and must NOT get a LAN-only A/AAAA on the UDM — a phone
# that caches 10.2.99.86 / fd99::86 on wifi and then roams to cellular
# blackholes on it (RFC1918/ULA gives no RST and no ICMP, so the app
# hangs until TCP timeout). This instance therefore skips labeled
# resources; LAN clients follow the public answer and the UDM hairpins
# the proxy addresses back to the cluster (opentofu/unifi_nat_hairpin.tf).
#
# The filter is per-RESOURCE: every Ingress/IngressRoute/Service that
# carries a public host must be labeled, and a public host must not share
# a resource with a LAN-registered host (split it, see immich-photos).
#
# Unlike a domain filter, the label filter applies only to the SOURCE
# side: records already on the UDM stay visible through the TXT registry,
# so labeling a resource drops its names from desired state and
# policy: sync deletes the now-stale records automatically. No manual
# cleanup step exists in this design.
labelFilter: dns.renner.uno/public!=true
sources:
- ingress
- service
@@ -45,7 +45,6 @@ existing `flux-system` GitRepository whose Secret was created by
| 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 |
| cloudflare-lb-sync | `YYYY-MM-DD-HHMM` | In-house (build-ci-images.yml); newest tag wins |
| secret-rotator | `YYYY-MM-DD` | In-house (build-ci-images.yml); newest tag wins; staged: only the Home Assistant rotation CronJobs carry the marker so far |
| gatus | `5.x` | v-prefix filter |
| velero-plugin-for-aws | `1.13.x` | Tight: must match velero server major.minor |
@@ -1,23 +0,0 @@
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImageRepository
metadata:
name: cloudflare-lb-sync
namespace: flux-system
spec:
image: gitea.renner.uno/gitea_admin/cloudflare-lb-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: cloudflare-lb-sync
namespace: flux-system
spec:
imageRepositoryRef:
name: cloudflare-lb-sync
filterTags:
pattern: '^\d{4}-\d{2}-\d{2}-\d{4}$'
policy:
alphabetical:
order: asc
@@ -6,7 +6,6 @@ resources:
- authentik-gate-sync.yaml
- backup-verify.yaml
- ceph.yaml
- cloudflare-lb-sync.yaml
- cnpg-barman-plugin.yaml
- gatus.yaml
- gitea-mcp.yaml
@@ -26,7 +26,6 @@ resources:
- reloader/
- admission-policies/
- kuma-sync-crds/
- cloudflare-lb-sync-crds/
# The OpenBao ClusterSecretStore lives in post-crds/ (needs ESO's CRDs and
# an initialized OpenBao; see kubernetes/MIGRATION-sops-openbao.md).
# cnpg-barman-plugin/ lives in its own Flux Kustomization
@@ -295,12 +295,6 @@ kind: Namespace
metadata:
name: kuma-sync
---
# cloudflare-lb-sync controller; declared here because it has a CiliumNetworkPolicy applied by infrastructure-crds.
apiVersion: v1
kind: Namespace
metadata:
name: cloudflare-lb-sync
---
# Hermes Agent (kubernetes/apps/hermes). Declared here so it exists before
# infrastructure-crds applies its CNPs. No pod-security labels: the image's
# s6-overlay init starts as root and drops to the `hermes` user, which the
@@ -1,38 +0,0 @@
# Baseline policy for the `cloudflare-lb-sync` namespace: egress to kube-dns, kube-apiserver and api.cloudflare.com:443; ingress only kubelet probes.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: default-deny
namespace: cloudflare-lb-sync
spec:
endpointSelector: {}
ingress:
- fromEntities:
- host
toPorts:
- ports:
- port: "8081"
protocol: TCP
egress:
# rules.dns lets Cilium learn the toFQDNs mapping
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": kube-system
k8s-app: kube-dns
toPorts:
- ports:
- port: "53"
protocol: UDP
- port: "53"
protocol: TCP
rules:
dns:
- matchPattern: "*"
- toEntities:
- kube-apiserver
- toFQDNs:
- matchName: "api.cloudflare.com"
toPorts:
- ports:
- port: "443"
protocol: TCP
@@ -33,7 +33,6 @@ resources:
- navidrome-ns.yaml
- uptime-monitor-ns.yaml
- kuma-sync-ns.yaml
- cloudflare-lb-sync-ns.yaml
- renovate-ns.yaml
- hermes-ns.yaml
- allow-cilium-health.yaml

Some files were not shown because too many files have changed in this diff Show More