Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58196acebc | ||
|
|
95f3ff86b9 | ||
|
|
0e12e90509 | ||
|
|
613e1da011 | ||
|
|
6998845854 | ||
|
|
430c710fd0 | ||
|
|
6accd1d3cd | ||
|
|
6178063299 | ||
|
|
0b7a2b6c46 | ||
|
|
772311c574 | ||
|
|
7bf1681c2d | ||
|
|
c9cd0783df | ||
|
|
7e57b42316 | ||
|
|
9afb56536c | ||
|
|
23c089c8b7 | ||
|
|
a3eb69ee20 | ||
|
|
c6cb6b3aec | ||
|
|
29c299c0fe | ||
|
|
a3efc511c8 | ||
|
|
924b6e9e88 | ||
|
|
5bfa957f29 | ||
|
|
0806bdfab5 | ||
|
|
9d1b034d0f | ||
|
|
46af8158ca | ||
|
|
d30afa09c2 | ||
|
|
6a82260500 | ||
|
|
ab55215532 | ||
|
|
7b9886545f | ||
|
|
5be8ee6e0e | ||
|
|
b9d237562e | ||
|
|
444b20d70e | ||
|
|
5f2c02306b | ||
|
|
e367fc1c85 | ||
|
|
bbeaf622ed | ||
|
|
9439e0d58c | ||
|
|
919e7b9208 | ||
|
|
92615c0402 | ||
|
|
704e3d44b7 |
@@ -454,6 +454,32 @@ 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
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
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
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
# 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
|
||||
@@ -95,9 +95,8 @@ the VM enrolled as `claude-ws-190-112.nb.renner.uno` / `100.96.190.112` (+ IPv6)
|
||||
> inventory targets the tunnel DHCP IP.
|
||||
|
||||
> **DNS trap.** `claude-ws.obg.renner.uno` resolves to the VM's lease **only at
|
||||
> the off-site DNS** (`@10.3.40.1`). The home resolver catch-alls `*.renner.uno`
|
||||
> to the public proxy (`162.55.50.97`, the Netbird VPS) — so from home the FQDN
|
||||
> lands on `root@netbird`, **not** the VM. Never use it as `ansible_host`.
|
||||
> the off-site DNS** (`@10.3.40.1`). From home this FQDN does not resolve (it
|
||||
> exists only at the off-site DNS @10.3.40.1). Never use it as `ansible_host`.
|
||||
|
||||
**Optional — mesh route to the k8s API (redundant read path):** to reach the API
|
||||
over the mesh too, add a Netbird dashboard **network route** advertising the
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
#
|
||||
# Enforces the decisions the in-cluster CrowdSec already detects, at the point
|
||||
# where public traffic actually enters: these hosts terminate TLS for every
|
||||
# *.renner.uno name. A drop here never reaches the mesh, the relay or the
|
||||
# public renner.uno name. A drop here never reaches the mesh, the relay or the
|
||||
# cluster.
|
||||
#
|
||||
# Detection stays in the cluster. The reverse-proxy produces no parseable
|
||||
|
||||
@@ -157,6 +157,29 @@
|
||||
ansible.builtin.pause:
|
||||
seconds: 10
|
||||
|
||||
# The host's mesh client must not manage DNS: containers here resolve through
|
||||
# the host stub, and the renner.uno nameserver group is unreachable from it.
|
||||
- name: Check for the host NetBird client
|
||||
ansible.builtin.stat:
|
||||
path: /usr/bin/netbird
|
||||
register: _netbird_client
|
||||
|
||||
- name: Read the host NetBird client config
|
||||
ansible.builtin.command: netbird debug config
|
||||
register: _netbird_client_config
|
||||
changed_when: false
|
||||
when: _netbird_client.stat.exists
|
||||
|
||||
# disable-dns is only applied on a fresh `up`, not to a connected client.
|
||||
- name: Reconnect the host NetBird client with DNS management disabled
|
||||
ansible.builtin.shell: |
|
||||
set -eu
|
||||
netbird down
|
||||
netbird up --disable-dns
|
||||
when:
|
||||
- _netbird_client.stat.exists
|
||||
- not (_netbird_client_config.stdout | from_json).disableDns
|
||||
|
||||
- name: Display deployment status
|
||||
ansible.builtin.debug:
|
||||
msg: |
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
---
|
||||
- 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,34 +1,26 @@
|
||||
#!/usr/bin/env python3
|
||||
"""authentik-gate-sync: an ingress class that puts an Authentik proxy gate in front of an app.
|
||||
"""authentik-gate-sync: the Authentik side of the gate Middleware.
|
||||
|
||||
Source of truth: every Ingress with spec.ingressClassName "authentik-gate". Traefik
|
||||
ignores that class (its IngressClass names another controller), so a source
|
||||
Ingress is never served directly. For each one this controller builds:
|
||||
An Ingress is gated when its traefik.ingress.kubernetes.io/router.middlewares
|
||||
annotation names the gate Middleware (authentik/gate, a ForwardAuth against the
|
||||
embedded outpost in authentik-server; kubernetes/apps/authentik/gate.yaml). The
|
||||
gate's configuration lives on that Ingress; for each one this controller builds:
|
||||
|
||||
Authentik proxy provider + application + outpost (+ group-membership
|
||||
expression policy bound to the application).
|
||||
Authentik's own Kubernetes integration then runs the outpost as
|
||||
ak-outpost-<gate> in the authentik namespace.
|
||||
app ns ExternalName Service <ingress>-gate -> the outpost Service,
|
||||
Traefik Ingress <ingress>-gate: the source's hosts, paths, TLS,
|
||||
labels and annotations, backend swapped for the gate,
|
||||
CNP <ingress>-gate: the backend pods admit this outpost.
|
||||
authentik CNP gate-<gate>: the outpost may reach only those backend pods.
|
||||
Authentik forward_single proxy provider for the Ingress's host +
|
||||
application (+ group-membership expression policy bound to it),
|
||||
the provider attached to the embedded outpost.
|
||||
app ns CNP <ingress>-gate: the backend pods admit Traefik.
|
||||
|
||||
Like any ingress controller it publishes the source's status.loadBalancer: the
|
||||
address Traefik reports for the route, or none while the gate has no route.
|
||||
|
||||
The source Ingress's backend is the real upstream; it becomes the provider's
|
||||
internal host and its host the external host. Annotations (all optional):
|
||||
Annotations (all optional):
|
||||
|
||||
auth.renner.uno/group group whose members may pass; none = any user
|
||||
auth.renner.uno/login-flow authentication flow slug; none = the brand's
|
||||
auth.renner.uno/authorization-flow authorization flow slug (default below)
|
||||
auth.renner.uno/skip-path provider skip_path_regex
|
||||
|
||||
Fail closed: the Traefik Ingress is applied last and only after the Authentik
|
||||
side reconciled, and is removed when that fails (e.g. the named group is gone),
|
||||
so there is never a route to an unguarded backend.
|
||||
Fail closed: until the provider exists, or after it is removed (e.g. the named
|
||||
group is gone), the embedded outpost has no provider for the host and rejects
|
||||
the request; Traefik answers 500 when the outpost is unreachable.
|
||||
|
||||
Ownership: Kubernetes objects carry app.kubernetes.io/managed-by; Authentik
|
||||
applications carry meta_publisher. Anything without the marker is never touched.
|
||||
@@ -48,14 +40,11 @@ LOG = logging.getLogger("authentik-gate-sync")
|
||||
|
||||
AK_URL = os.environ.get("AK_URL", "http://authentik-server.authentik.svc.cluster.local").rstrip("/")
|
||||
AK_TOKEN_FILE = os.environ.get("AK_TOKEN_FILE", "/var/run/secrets/authentik-gate-sync/token")
|
||||
INGRESS_CLASS = os.environ.get("INGRESS_CLASS", "authentik-gate")
|
||||
TARGET_CLASS = os.environ.get("TARGET_INGRESS_CLASS", "traefik")
|
||||
OUTPOST_NS = os.environ.get("OUTPOST_NAMESPACE", "authentik")
|
||||
SERVICE_CONNECTION = os.environ.get("SERVICE_CONNECTION", "Local Kubernetes Cluster")
|
||||
GATE_MIDDLEWARE = os.environ.get("GATE_MIDDLEWARE", "authentik-gate@kubernetescrd")
|
||||
INGRESS_NS = os.environ.get("INGRESS_NAMESPACE", "ingress")
|
||||
DEFAULT_AUTHZ_FLOW = os.environ.get("DEFAULT_AUTHORIZATION_FLOW", "default-provider-authorization-implicit-consent")
|
||||
INVALIDATION_FLOW = os.environ.get("INVALIDATION_FLOW", "default-provider-invalidation-flow")
|
||||
COOKIE_DOMAIN = os.environ.get("COOKIE_DOMAIN", "renner.uno")
|
||||
AK_BROWSER_URL = os.environ.get("AUTHENTIK_HOST_BROWSER", "https://auth.renner.uno/")
|
||||
INTERVAL = int(os.environ.get("INTERVAL_SECONDS", "60"))
|
||||
HTTP_TIMEOUT = 20
|
||||
|
||||
@@ -63,14 +52,9 @@ ANN = "auth.renner.uno/"
|
||||
MANAGER = "authentik-gate-sync"
|
||||
MANAGED_BY = {"app.kubernetes.io/managed-by": MANAGER}
|
||||
GATE_LABEL = "auth.renner.uno/gate"
|
||||
OUTPOST_PORT = 9000
|
||||
|
||||
# Never copied from the source onto the generated Ingress: cert-manager would
|
||||
# issue a second Certificate for the same secret, Flux would treat it as its own,
|
||||
# and the gate annotations mean nothing to Traefik.
|
||||
DROP_LABEL_PREFIXES = ("kustomize.toolkit.fluxcd.io/", "app.kubernetes.io/managed-by")
|
||||
DROP_ANNOTATION_PREFIXES = (ANN, "cert-manager.io/", "acme.cert-manager.io/",
|
||||
"kubectl.kubernetes.io/last-applied-configuration")
|
||||
EMBEDDED_OUTPOST = "goauthentik.io/outposts/embedded"
|
||||
TRAEFIK = {"k8s:io.kubernetes.pod.namespace": INGRESS_NS, "app.kubernetes.io/name": "traefik"}
|
||||
MIDDLEWARES_ANN = "traefik.ingress.kubernetes.io/router.middlewares"
|
||||
|
||||
K8S_API = "https://kubernetes.default.svc"
|
||||
K8S_TOKEN_FILE = "/var/run/secrets/kubernetes.io/serviceaccount/token"
|
||||
@@ -114,7 +98,7 @@ def http(method, url, body=None, headers=None, cafile=None, content_type="applic
|
||||
|
||||
def gate_name(ing):
|
||||
"""<ns>-<ingress>, truncated, plus a hash of both: unambiguous (a-b/c vs a/b-c)
|
||||
and short enough for label values and ak-outpost-<name> (63)."""
|
||||
and short enough for label values (63)."""
|
||||
ns, name = ing["metadata"]["namespace"], ing["metadata"]["name"]
|
||||
digest = hashlib.sha256(f"{ns}/{name}".encode()).hexdigest()[:6]
|
||||
return f"{ns}-{name}"[:45].rstrip("-") + "-" + digest
|
||||
@@ -126,6 +110,24 @@ def group_expression(group):
|
||||
return f"return ak_is_group_member(request.user, name={group!r})"
|
||||
|
||||
|
||||
def is_gated(ing):
|
||||
chain = ((ing.get("metadata") or {}).get("annotations") or {}).get(MIDDLEWARES_ANN, "")
|
||||
return GATE_MIDDLEWARE in (m.strip() for m in chain.split(","))
|
||||
|
||||
|
||||
def shared_hosts(sources):
|
||||
"""Hosts named by more than one gated Ingress. The outpost finds the provider
|
||||
by host, so such a host would get one gate for several Ingresses."""
|
||||
seen, shared = set(), set()
|
||||
for ing in sources:
|
||||
for r in ing.get("spec", {}).get("rules") or []:
|
||||
host = r.get("host")
|
||||
if host in seen:
|
||||
shared.add(host)
|
||||
seen.add(host)
|
||||
return shared
|
||||
|
||||
|
||||
def gate_spec(ing):
|
||||
"""Translate a source Ingress into the gate it asks for. Raises GateError."""
|
||||
meta = ing["metadata"]
|
||||
@@ -174,63 +176,15 @@ def gate_labels(gate):
|
||||
return {**MANAGED_BY, GATE_LABEL: gate["name"]}
|
||||
|
||||
|
||||
def outpost_service(gate):
|
||||
return f"ak-outpost-{gate['name']}"
|
||||
|
||||
|
||||
def external_name_service(gate):
|
||||
return {"apiVersion": "v1", "kind": "Service",
|
||||
"metadata": {"name": f"{gate['ingress']}-gate", "namespace": gate["namespace"],
|
||||
"labels": gate_labels(gate), "ownerReferences": owner_ref(gate)},
|
||||
"spec": {"type": "ExternalName",
|
||||
"externalName": f"{outpost_service(gate)}.{OUTPOST_NS}.svc.cluster.local",
|
||||
"ports": [{"name": "http", "port": OUTPOST_PORT, "targetPort": OUTPOST_PORT}]}}
|
||||
|
||||
|
||||
def route_ingress(ing, gate):
|
||||
"""The Traefik Ingress: the source with its backend swapped for the gate."""
|
||||
meta = ing["metadata"]
|
||||
labels = {k: v for k, v in (meta.get("labels") or {}).items() if not k.startswith(DROP_LABEL_PREFIXES)}
|
||||
annotations = {k: v for k, v in (meta.get("annotations") or {}).items()
|
||||
if not k.startswith(DROP_ANNOTATION_PREFIXES)}
|
||||
backend = {"service": {"name": f"{gate['ingress']}-gate", "port": {"number": OUTPOST_PORT}}}
|
||||
rules = []
|
||||
for r in ing["spec"]["rules"]:
|
||||
paths = [{"path": p.get("path", "/"), "pathType": p.get("pathType", "Prefix"), "backend": backend}
|
||||
for p in r["http"]["paths"]]
|
||||
rules.append({"host": r["host"], "http": {"paths": paths}})
|
||||
spec = {"ingressClassName": TARGET_CLASS, "rules": rules}
|
||||
if ing["spec"].get("tls"):
|
||||
spec["tls"] = ing["spec"]["tls"]
|
||||
return {"apiVersion": "networking.k8s.io/v1", "kind": "Ingress",
|
||||
"metadata": {"name": f"{gate['ingress']}-gate", "namespace": gate["namespace"],
|
||||
"labels": {**labels, **gate_labels(gate)}, "annotations": annotations,
|
||||
"ownerReferences": owner_ref(gate)},
|
||||
"spec": spec}
|
||||
|
||||
|
||||
def outpost_selector(gate):
|
||||
return {"k8s:io.kubernetes.pod.namespace": OUTPOST_NS, "goauthentik.io/outpost-name": gate["name"]}
|
||||
|
||||
|
||||
def backend_ingress_cnp(gate, pod_selector, target_port):
|
||||
return {"apiVersion": "cilium.io/v2", "kind": "CiliumNetworkPolicy",
|
||||
"metadata": {"name": f"{gate['ingress']}-gate", "namespace": gate["namespace"],
|
||||
"labels": gate_labels(gate), "ownerReferences": owner_ref(gate)},
|
||||
"spec": {"endpointSelector": {"matchLabels": pod_selector},
|
||||
"ingress": [{"fromEndpoints": [{"matchLabels": outpost_selector(gate)}],
|
||||
"ingress": [{"fromEndpoints": [{"matchLabels": TRAEFIK}],
|
||||
"toPorts": [{"ports": [{"port": str(target_port), "protocol": "TCP"}]}]}]}}
|
||||
|
||||
|
||||
def outpost_egress_cnp(gate, pod_selector, target_port):
|
||||
peer = {"k8s:io.kubernetes.pod.namespace": gate["namespace"], **pod_selector}
|
||||
return {"apiVersion": "cilium.io/v2", "kind": "CiliumNetworkPolicy",
|
||||
"metadata": {"name": f"gate-{gate['name']}", "namespace": OUTPOST_NS, "labels": gate_labels(gate)},
|
||||
"spec": {"endpointSelector": {"matchLabels": {"goauthentik.io/outpost-name": gate["name"]}},
|
||||
"egress": [{"toEndpoints": [{"matchLabels": peer}],
|
||||
"toPorts": [{"ports": [{"port": str(target_port), "protocol": "TCP"}]}]}]}}
|
||||
|
||||
|
||||
def resolve_backend(gate, service):
|
||||
"""(service port number, pod selector, target port) for the gate's backend.
|
||||
A named targetPort stays a name: Cilium resolves named ports against the
|
||||
@@ -254,34 +208,12 @@ def resolve_backend(gate, service):
|
||||
return sp["port"], selector, target
|
||||
|
||||
|
||||
def outpost_config():
|
||||
return {
|
||||
"log_level": "info",
|
||||
"authentik_host": AK_URL,
|
||||
"authentik_host_insecure": False,
|
||||
"authentik_host_browser": AK_BROWSER_URL,
|
||||
"refresh_interval": "minutes=5",
|
||||
"container_image": None,
|
||||
"object_naming_template": "ak-outpost-%(name)s",
|
||||
"kubernetes_namespace": OUTPOST_NS,
|
||||
"kubernetes_replicas": 1,
|
||||
"kubernetes_service_type": "ClusterIP",
|
||||
"kubernetes_disabled_components": ["ingress"],
|
||||
"kubernetes_image_pull_secrets": [],
|
||||
"kubernetes_ingress_annotations": {},
|
||||
"kubernetes_ingress_class_name": None,
|
||||
"kubernetes_ingress_secret_name": "authentik-outpost-tls",
|
||||
"kubernetes_json_patches": None,
|
||||
}
|
||||
|
||||
|
||||
def provider_body(gate, svc_port, flows):
|
||||
def provider_body(gate, flows):
|
||||
return {
|
||||
"name": gate["name"],
|
||||
"mode": "proxy",
|
||||
"mode": "forward_single",
|
||||
"external_host": f"{gate['scheme']}://{gate['host']}",
|
||||
"internal_host": f"http://{gate['service']}.{gate['namespace']}.svc.cluster.local:{svc_port}",
|
||||
"internal_host_ssl_validation": False,
|
||||
"internal_host": "",
|
||||
"cookie_domain": COOKIE_DOMAIN,
|
||||
"access_token_validity": "hours=24",
|
||||
"refresh_token_validity": "days=30",
|
||||
@@ -293,18 +225,6 @@ def provider_body(gate, svc_port, flows):
|
||||
}
|
||||
|
||||
|
||||
def lb_ingress(obj):
|
||||
return ((obj or {}).get("status") or {}).get("loadBalancer", {}).get("ingress") or []
|
||||
|
||||
|
||||
def status_patch(source, addresses):
|
||||
"""Merge patch publishing `addresses` as the source Ingress's load-balancer
|
||||
status, as an ingress controller does; None when it already matches."""
|
||||
if lb_ingress(source) == addresses:
|
||||
return None
|
||||
return {"status": {"loadBalancer": {"ingress": addresses or None}}}
|
||||
|
||||
|
||||
def drift(current, desired):
|
||||
return {k: v for k, v in desired.items() if current.get(k) != v}
|
||||
|
||||
@@ -342,23 +262,9 @@ class K8s:
|
||||
if e.code != 404:
|
||||
raise
|
||||
|
||||
def patch_status(self, ns, name, patch):
|
||||
path = f"/apis/networking.k8s.io/v1/namespaces/{ns}/ingresses/{name}/status"
|
||||
return http("PATCH", K8S_API + path, patch, headers=self._h(), cafile=K8S_CA_FILE,
|
||||
content_type="application/merge-patch+json")
|
||||
|
||||
def delete_managed(self, obj):
|
||||
try:
|
||||
live = self.get(object_path(obj))
|
||||
except ApiError as e:
|
||||
if e.code == 404:
|
||||
return
|
||||
raise
|
||||
if (live["metadata"].get("labels") or {}).get("app.kubernetes.io/managed-by") == MANAGER:
|
||||
self.delete(obj)
|
||||
LOG.info("deleted %s %s/%s", obj["kind"], obj["metadata"]["namespace"], obj["metadata"]["name"])
|
||||
|
||||
|
||||
# Swept by label: any object of these kinds marked as this controller's that no
|
||||
# gate wants is deleted. It creates only CNPs; Ingresses and Services carrying
|
||||
# the label are routes a gate must not have besides its own Ingress.
|
||||
COLLECTIONS = {
|
||||
"Ingress": "/apis/networking.k8s.io/v1/namespaces/{ns}/ingresses",
|
||||
"Service": "/api/v1/namespaces/{ns}/services",
|
||||
@@ -425,7 +331,16 @@ def get_application(ak, slug):
|
||||
return ak.get(f"core/applications/{slug}/")
|
||||
|
||||
|
||||
def ensure_gate(ak, gate, svc_port, flows, connection):
|
||||
def embedded_outpost(ak):
|
||||
# Matched again here: a filter the server ignored must not select another outpost.
|
||||
found = [o for o in ak.list("outposts/instances/", managed__iexact=EMBEDDED_OUTPOST)
|
||||
if o.get("managed") == EMBEDDED_OUTPOST]
|
||||
if len(found) != 1:
|
||||
raise ApiError(f"expected one embedded outpost, found {len(found)}")
|
||||
return found[0]
|
||||
|
||||
|
||||
def ensure_gate(ak, gate, flows):
|
||||
name = gate["name"]
|
||||
if gate["group"] and ak.one("core/groups/", name=gate["group"]) is None:
|
||||
raise GateError(f"group {gate['group']!r} not found in Authentik")
|
||||
@@ -434,7 +349,7 @@ def ensure_gate(ak, gate, svc_port, flows, connection):
|
||||
if app is not None and app.get("meta_publisher") != MANAGER:
|
||||
raise GateError(f"application {name} exists and is not managed by {MANAGER}")
|
||||
|
||||
want = provider_body(gate, svc_port, flows)
|
||||
want = provider_body(gate, flows)
|
||||
provider = ak.one("providers/proxy/", name__iexact=name)
|
||||
if provider is None:
|
||||
provider = ak.req("POST", "providers/proxy/", want)
|
||||
@@ -461,17 +376,22 @@ def ensure_gate(ak, gate, svc_port, flows, connection):
|
||||
|
||||
ensure_access(ak, gate, app["pbm_uuid"])
|
||||
|
||||
want_outpost = {"name": name, "type": "proxy", "service_connection": connection,
|
||||
"providers": [provider["pk"]], "config": outpost_config()}
|
||||
outpost = ak.one("outposts/instances/", name__iexact=name)
|
||||
if outpost is None:
|
||||
ak.req("POST", "outposts/instances/", want_outpost)
|
||||
LOG.info("%s: created outpost", name)
|
||||
elif set(outpost.get("providers") or []) - {provider["pk"]}:
|
||||
raise GateError(f"outpost {name} serves providers this controller does not own")
|
||||
elif drift(outpost, want_outpost):
|
||||
ak.req("PATCH", f"outposts/instances/{outpost['pk']}/", drift(outpost, want_outpost))
|
||||
LOG.info("%s: updated outpost %s", name, sorted(drift(outpost, want_outpost)))
|
||||
# Re-read per gate: the providers list is replaced whole.
|
||||
embedded = embedded_outpost(ak)
|
||||
if provider["pk"] not in (embedded.get("providers") or []):
|
||||
ak.req("PATCH", f"outposts/instances/{embedded['pk']}/",
|
||||
{"providers": [*(embedded.get("providers") or []), provider["pk"]]})
|
||||
LOG.info("%s: attached provider to the embedded outpost", name)
|
||||
remove_dedicated_outposts(ak, name, provider["pk"])
|
||||
|
||||
|
||||
def remove_dedicated_outposts(ak, name, provider):
|
||||
"""Outposts named after the gate that serve only its provider: the embedded
|
||||
outpost serves every gate, so Authentik tears these and their pods down."""
|
||||
for o in ak.list("outposts/instances/", name__iexact=name):
|
||||
if o.get("providers") == [provider] and not o.get("managed"):
|
||||
ak.req("DELETE", f"outposts/instances/{o['pk']}/")
|
||||
LOG.info("%s: removed dedicated outpost", name)
|
||||
|
||||
|
||||
def ensure_access(ak, gate, target):
|
||||
@@ -511,15 +431,12 @@ def ensure_access(ak, gate, target):
|
||||
|
||||
|
||||
def remove_gate(ak, app):
|
||||
"""Delete an owned gate. The outpost serving only its provider goes first
|
||||
(Authentik tears its pods down), then the application (bindings cascade; its
|
||||
provider is PROTECTed while referenced), the provider, the policy. Ownership
|
||||
is found through the application, so until it is gone a failed step is
|
||||
retried on the next pass."""
|
||||
"""Delete an owned gate: the application (bindings cascade; its provider is
|
||||
PROTECTed while referenced), then the provider (which leaves the embedded
|
||||
outpost with it), the policy. Ownership is found through the application,
|
||||
so until it is gone a failed step is retried on the next pass."""
|
||||
name, provider = app["slug"], app.get("provider")
|
||||
for o in ak.list("outposts/instances/", name__iexact=name):
|
||||
if o.get("providers") == [provider]:
|
||||
ak.req("DELETE", f"outposts/instances/{o['pk']}/")
|
||||
remove_dedicated_outposts(ak, name, provider)
|
||||
ak.req("DELETE", f"core/applications/{name}/")
|
||||
if provider:
|
||||
ak.req("DELETE", f"providers/proxy/{provider}/")
|
||||
@@ -543,32 +460,14 @@ def owned_applications(ak):
|
||||
|
||||
# --- reconcile --------------------------------------------------------------
|
||||
|
||||
def publish_status(k8s, source, addresses):
|
||||
"""Status is cosmetic for the gate itself: a failure is logged, never fatal."""
|
||||
patch = status_patch(source, addresses)
|
||||
if patch is None:
|
||||
return
|
||||
m = source["metadata"]
|
||||
try:
|
||||
k8s.patch_status(m["namespace"], m["name"], patch)
|
||||
LOG.info("%s/%s: status %s", m["namespace"], m["name"],
|
||||
[a.get("ip") or a.get("hostname") for a in addresses])
|
||||
except Exception as e:
|
||||
LOG.warning("%s/%s: publishing status: %s", m["namespace"], m["name"], e)
|
||||
|
||||
|
||||
def reconcile():
|
||||
k8s = K8s()
|
||||
with open(AK_TOKEN_FILE) as f:
|
||||
ak = Authentik(f.read().strip())
|
||||
|
||||
sources = [i for i in k8s.get("/apis/networking.k8s.io/v1/ingresses").get("items", [])
|
||||
if i.get("spec", {}).get("ingressClassName") == INGRESS_CLASS]
|
||||
sources = [i for i in k8s.get("/apis/networking.k8s.io/v1/ingresses").get("items", []) if is_gated(i)]
|
||||
shared = shared_hosts(sources)
|
||||
flows = {f["slug"]: f["pk"] for f in ak.list("flows/instances/")}
|
||||
connection = ak.one("outposts/service_connections/kubernetes/", name=SERVICE_CONNECTION)
|
||||
if connection is None:
|
||||
raise ApiError(f"Kubernetes service connection {SERVICE_CONNECTION!r} not found")
|
||||
connection = connection["pk"]
|
||||
|
||||
desired = set() # (kind, ns, name) of every Kubernetes object some gate wants
|
||||
gates = set() # gate names whose Authentik side should exist
|
||||
@@ -578,6 +477,8 @@ def reconcile():
|
||||
ref = f'{ing["metadata"]["namespace"]}/{ing["metadata"]["name"]}'
|
||||
try:
|
||||
gate = gate_spec(ing)
|
||||
if gate["host"] in shared:
|
||||
raise GateError(f"host {gate['host']} is gated by more than one Ingress")
|
||||
for slug in filter(None, (gate["authorization_flow"], gate["login_flow"], INVALIDATION_FLOW)):
|
||||
if slug not in flows:
|
||||
raise GateError(f"flow {slug!r} not found in Authentik")
|
||||
@@ -588,28 +489,18 @@ def reconcile():
|
||||
if e.code == 404:
|
||||
raise GateError(f"backend Service {gate['service']} does not exist") from None
|
||||
raise
|
||||
svc_port, pods, target_port = resolve_backend(gate, service)
|
||||
_, pods, target_port = resolve_backend(gate, service)
|
||||
gates.add(gate["name"])
|
||||
ensure_gate(ak, gate, svc_port, flows, connection)
|
||||
objs = [outpost_egress_cnp(gate, pods, target_port), backend_ingress_cnp(gate, pods, target_port),
|
||||
external_name_service(gate), route_ingress(ing, gate)] # the route goes last
|
||||
for obj in objs:
|
||||
live = k8s.apply(obj)
|
||||
desired.add((obj["kind"], obj["metadata"]["namespace"], obj["metadata"]["name"]))
|
||||
publish_status(k8s, ing, lb_ingress(live)) # the route's address, from Traefik
|
||||
ensure_gate(ak, gate, flows)
|
||||
cnp = backend_ingress_cnp(gate, pods, target_port)
|
||||
k8s.apply(cnp)
|
||||
desired.add((cnp["kind"], cnp["metadata"]["namespace"], cnp["metadata"]["name"]))
|
||||
except GateError as e:
|
||||
# The Ingress cannot be gated as written: its route goes now, the
|
||||
# rest with the sweep below.
|
||||
# The Ingress cannot be gated as written: its provider goes with the
|
||||
# sweep below, after which the outpost rejects the host.
|
||||
LOG.error("%s: %s", ref, e)
|
||||
failed += 1
|
||||
gates.discard(gate_name(ing))
|
||||
route = {"kind": "Ingress", "metadata": {"namespace": ing["metadata"]["namespace"],
|
||||
"name": f'{ing["metadata"]["name"]}-gate'}}
|
||||
try:
|
||||
k8s.delete_managed(route)
|
||||
except Exception as e: # the sweep retries
|
||||
LOG.error("%s: removing route: %s", ref, e)
|
||||
publish_status(k8s, ing, [])
|
||||
except Exception as e:
|
||||
# Transient: leave this gate exactly as the last good pass left it.
|
||||
LOG.error("%s: %s", ref, e)
|
||||
|
||||
@@ -11,11 +11,12 @@ LLAMA = {
|
||||
"cert-manager.io/cluster-issuer": "letsencrypt-cluster",
|
||||
"auth.renner.uno/login-flow": "sso-authentication",
|
||||
"auth.renner.uno/skip-path": r"^/(manifest\.webmanifest|sw\.js)$",
|
||||
"traefik.ingress.kubernetes.io/router.middlewares": "ingress-foo@kubernetescrd",
|
||||
"traefik.ingress.kubernetes.io/router.middlewares":
|
||||
"authentik-gate@kubernetescrd, ingress-foo@kubernetescrd",
|
||||
},
|
||||
},
|
||||
"spec": {
|
||||
"ingressClassName": "authentik-gate",
|
||||
"ingressClassName": "traefik",
|
||||
"tls": [{"hosts": ["llm.renner.uno"], "secretName": "llama-server-tls"}],
|
||||
"rules": [{"host": "llm.renner.uno", "http": {"paths": [
|
||||
{"path": "/", "pathType": "Prefix",
|
||||
@@ -41,7 +42,7 @@ class Names(unittest.TestCase):
|
||||
|
||||
def test_bounded(self):
|
||||
n = c.gate_name(ing("n" * 63, "i" * 253))
|
||||
self.assertLessEqual(len(c.outpost_service({"name": n})), 63)
|
||||
self.assertLessEqual(len(n), 63)
|
||||
self.assertNotIn("--", n)
|
||||
|
||||
def test_group_expression_is_a_literal(self):
|
||||
@@ -90,6 +91,22 @@ class GateSpec(unittest.TestCase):
|
||||
with self.assertRaises(c.GateError):
|
||||
c.gate_spec(ing)
|
||||
|
||||
def test_shared_hosts(self):
|
||||
other = copy.deepcopy(LLAMA)
|
||||
other["metadata"]["name"] = "llama-admin"
|
||||
self.assertEqual(c.shared_hosts([LLAMA, other]), {"llm.renner.uno"})
|
||||
other["spec"]["rules"][0]["host"] = "admin.renner.uno"
|
||||
self.assertEqual(c.shared_hosts([LLAMA, other]), set())
|
||||
|
||||
def test_gated_by_middleware_reference(self):
|
||||
self.assertTrue(c.is_gated(LLAMA))
|
||||
ing = copy.deepcopy(LLAMA)
|
||||
ing["metadata"]["annotations"]["traefik.ingress.kubernetes.io/router.middlewares"] = \
|
||||
"ingress-foo@kubernetescrd,authentik-gate-other@kubernetescrd"
|
||||
self.assertFalse(c.is_gated(ing))
|
||||
del ing["metadata"]["annotations"]
|
||||
self.assertFalse(c.is_gated(ing))
|
||||
|
||||
|
||||
class Backend(unittest.TestCase):
|
||||
def test_named_target_port_stays_named(self):
|
||||
@@ -126,62 +143,26 @@ class Objects(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.g = c.gate_spec(LLAMA)
|
||||
|
||||
def test_route_ingress(self):
|
||||
r = c.route_ingress(LLAMA, self.g)
|
||||
self.assertEqual(r["metadata"]["name"], "llama-server-gate")
|
||||
self.assertEqual(r["spec"]["ingressClassName"], "traefik")
|
||||
self.assertEqual(r["spec"]["tls"], LLAMA["spec"]["tls"])
|
||||
backend = r["spec"]["rules"][0]["http"]["paths"][0]["backend"]["service"]
|
||||
self.assertEqual(backend, {"name": "llama-server-gate", "port": {"number": 9000}})
|
||||
labels, ann = r["metadata"]["labels"], r["metadata"]["annotations"]
|
||||
self.assertEqual(labels["dns.renner.uno/public"], "true")
|
||||
self.assertNotIn("kustomize.toolkit.fluxcd.io/name", labels)
|
||||
self.assertEqual(labels["app.kubernetes.io/managed-by"], c.MANAGER)
|
||||
self.assertEqual(ann, {"traefik.ingress.kubernetes.io/router.middlewares": "ingress-foo@kubernetescrd"})
|
||||
self.assertEqual(r["metadata"]["ownerReferences"][0]["uid"], "u-1")
|
||||
|
||||
def test_external_name(self):
|
||||
s = c.external_name_service(self.g)
|
||||
self.assertEqual(s["spec"]["externalName"], f"ak-outpost-{self.g['name']}.authentik.svc.cluster.local")
|
||||
|
||||
def test_cnps(self):
|
||||
def test_backend_admits_traefik_only(self):
|
||||
pods = {"app.kubernetes.io/name": "llama-server"}
|
||||
e = c.outpost_egress_cnp(self.g, pods, 8080)
|
||||
self.assertEqual(e["metadata"]["namespace"], "authentik")
|
||||
self.assertEqual(e["spec"]["endpointSelector"]["matchLabels"],
|
||||
{"goauthentik.io/outpost-name": self.g["name"]})
|
||||
self.assertEqual(e["spec"]["egress"][0]["toEndpoints"][0]["matchLabels"],
|
||||
{"k8s:io.kubernetes.pod.namespace": "ai", "app.kubernetes.io/name": "llama-server"})
|
||||
i = c.backend_ingress_cnp(self.g, pods, 8080)
|
||||
self.assertEqual(i["metadata"]["namespace"], "ai")
|
||||
self.assertEqual(i["spec"]["ingress"][0]["fromEndpoints"][0]["matchLabels"],
|
||||
{"k8s:io.kubernetes.pod.namespace": "authentik",
|
||||
"goauthentik.io/outpost-name": self.g["name"]})
|
||||
self.assertEqual(i["spec"]["ingress"][0]["fromEndpoints"],
|
||||
[{"matchLabels": {"k8s:io.kubernetes.pod.namespace": "ingress",
|
||||
"app.kubernetes.io/name": "traefik"}}])
|
||||
self.assertEqual(i["spec"]["ingress"][0]["toPorts"][0]["ports"][0]["port"], "8080")
|
||||
|
||||
def test_provider_body(self):
|
||||
flows = {c.DEFAULT_AUTHZ_FLOW: "az", "sso-authentication": "au",
|
||||
c.INVALIDATION_FLOW: "inv"}
|
||||
p = c.provider_body(self.g, 8080, flows)
|
||||
p = c.provider_body(self.g, flows)
|
||||
self.assertEqual(p["mode"], "forward_single")
|
||||
self.assertEqual(p["external_host"], "https://llm.renner.uno")
|
||||
self.assertEqual(p["internal_host"], "http://llama-server.ai.svc.cluster.local:8080")
|
||||
self.assertEqual(p["internal_host"], "")
|
||||
self.assertEqual((p["authorization_flow"], p["authentication_flow"], p["invalidation_flow"]),
|
||||
("az", "au", "inv"))
|
||||
self.assertEqual(p["skip_path_regex"], r"^/(manifest\.webmanifest|sw\.js)$")
|
||||
|
||||
def test_status_mirrors_route_address(self):
|
||||
addrs = [{"ip": "10.2.99.86"}, {"ip": "fd99::86"}]
|
||||
route = {"status": {"loadBalancer": {"ingress": addrs}}}
|
||||
self.assertEqual(c.status_patch(LLAMA, c.lb_ingress(route)),
|
||||
{"status": {"loadBalancer": {"ingress": addrs}}})
|
||||
synced = dict(LLAMA, status={"loadBalancer": {"ingress": addrs}})
|
||||
self.assertIsNone(c.status_patch(synced, addrs))
|
||||
|
||||
def test_status_cleared_without_route(self):
|
||||
stale = dict(LLAMA, status={"loadBalancer": {"ingress": [{"ip": "10.2.99.86"}]}})
|
||||
self.assertEqual(c.status_patch(stale, []), {"status": {"loadBalancer": {"ingress": None}}})
|
||||
self.assertIsNone(c.status_patch(LLAMA, []))
|
||||
|
||||
def test_drift(self):
|
||||
self.assertEqual(c.drift({"a": 1, "b": 2}, {"a": 1, "b": 3}), {"b": 3})
|
||||
|
||||
@@ -267,20 +248,60 @@ class Reconcile(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.ak = FakeAuthentik()
|
||||
self.g = c.gate_spec(LLAMA)
|
||||
self.embedded = self.ak.add("outposts/instances/", {
|
||||
"name": "authentik Embedded Outpost", "managed": c.EMBEDDED_OUTPOST,
|
||||
"providers": [], "config": {"log_level": "info"}})
|
||||
|
||||
def gate(self, group=None):
|
||||
g = dict(self.g, group=group)
|
||||
c.ensure_gate(self.ak, g, 8080, FLOWS, "conn")
|
||||
c.ensure_gate(self.ak, g, FLOWS)
|
||||
return g
|
||||
|
||||
def embedded_providers(self):
|
||||
return self.ak.get(f"outposts/instances/{self.embedded['pk']}/")["providers"]
|
||||
|
||||
def test_creates_owned_gate(self):
|
||||
self.gate()
|
||||
app = self.ak.get(f"core/applications/{self.g['name']}/")
|
||||
self.assertEqual(app["meta_publisher"], c.MANAGER)
|
||||
outpost = self.ak.one("outposts/instances/", name__iexact=self.g["name"])
|
||||
self.assertEqual(outpost["providers"], [app["provider"]])
|
||||
self.assertEqual(self.embedded_providers(), [app["provider"]])
|
||||
self.assertEqual(self.ak.list("outposts/instances/", name__iexact=self.g["name"]), [])
|
||||
self.assertEqual(self.ak.list("policies/bindings/"), [])
|
||||
|
||||
def test_keeps_other_providers_on_embedded_outpost(self):
|
||||
self.ak.db["outposts/instances/"][self.embedded["pk"]]["providers"] = [999]
|
||||
self.gate()
|
||||
app = self.ak.get(f"core/applications/{self.g['name']}/")
|
||||
self.assertEqual(self.embedded_providers(), [999, app["provider"]])
|
||||
|
||||
def test_removes_dedicated_outpost(self):
|
||||
self.gate()
|
||||
app = self.ak.get(f"core/applications/{self.g['name']}/")
|
||||
self.ak.add("outposts/instances/", {"name": self.g["name"], "providers": [app["provider"]]})
|
||||
shared = self.ak.add("outposts/instances/", {"name": self.g["name"].upper(),
|
||||
"providers": [app["provider"], 999]})
|
||||
self.gate()
|
||||
self.assertEqual([o["pk"] for o in self.ak.list("outposts/instances/", name__iexact=self.g["name"])],
|
||||
[shared["pk"]])
|
||||
|
||||
def test_embedded_lookup_ignores_unfiltered_results(self):
|
||||
ldap = self.ak.add("outposts/instances/", {"name": "LDAP Outpost", "managed": None, "providers": [7]})
|
||||
unfiltered = self.ak.list
|
||||
self.ak.list = lambda path, **q: unfiltered(path) # a server that ignores the filter
|
||||
self.assertEqual(c.embedded_outpost(self.ak)["pk"], self.embedded["pk"])
|
||||
del self.ak.db["outposts/instances/"][self.embedded["pk"]]
|
||||
with self.assertRaises(c.ApiError):
|
||||
c.embedded_outpost(self.ak)
|
||||
self.assertEqual(self.ak.get(f"outposts/instances/{ldap['pk']}/")["providers"], [7])
|
||||
|
||||
def test_provider_moves_from_proxy_mode(self):
|
||||
self.ak.add("providers/proxy/", {"name": self.g["name"], "mode": "proxy",
|
||||
"internal_host": "http://llama-server.ai.svc.cluster.local:8080",
|
||||
"assigned_application_slug": None})
|
||||
self.gate()
|
||||
provider = self.ak.one("providers/proxy/", name__iexact=self.g["name"])
|
||||
self.assertEqual((provider["mode"], provider["internal_host"]), ("forward_single", ""))
|
||||
|
||||
def test_second_pass_is_a_noop(self):
|
||||
self.gate()
|
||||
before = len(self.ak.calls)
|
||||
@@ -329,15 +350,15 @@ class Reconcile(unittest.TestCase):
|
||||
self.assertIsNone(self.ak.one("policies/expression/", name=self.g["name"]))
|
||||
|
||||
def test_remove_gate_and_ownership_scan(self):
|
||||
self.ak.add("providers/proxy/", {"name": "foreign", "assigned_application_slug": "foreign"})
|
||||
self.ak.add("core/applications/", {"slug": "foreign", "meta_publisher": "", "provider": 1})
|
||||
foreign = self.ak.add("providers/proxy/", {"name": "foreign", "assigned_application_slug": "foreign"})
|
||||
self.ak.add("core/applications/", {"slug": "foreign", "meta_publisher": "", "provider": foreign["pk"]})
|
||||
self.gate()
|
||||
owned = list(c.owned_applications(self.ak))
|
||||
self.assertEqual([a["slug"] for a in owned], [self.g["name"]])
|
||||
c.remove_gate(self.ak, owned[0])
|
||||
self.assertIsNone(self.ak.get(f"core/applications/{self.g['name']}/"))
|
||||
self.assertEqual([p["name"] for p in self.ak.list("providers/proxy/")], ["foreign"])
|
||||
self.assertEqual(self.ak.list("outposts/instances/"), [])
|
||||
self.assertEqual([o["pk"] for o in self.ak.list("outposts/instances/")], [self.embedded["pk"]])
|
||||
|
||||
def test_adopts_provider_orphaned_by_interrupted_teardown(self):
|
||||
self.gate()
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
bin/
|
||||
@@ -0,0 +1 @@
|
||||
/bin/
|
||||
@@ -0,0 +1,16 @@
|
||||
# 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"]
|
||||
@@ -0,0 +1,26 @@
|
||||
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 ./...
|
||||
@@ -0,0 +1,132 @@
|
||||
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{})
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// +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
|
||||
)
|
||||
@@ -0,0 +1,191 @@
|
||||
//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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
//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
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
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...)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// 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())
|
||||
}
|
||||
+268
@@ -0,0 +1,268 @@
|
||||
---
|
||||
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: {}
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
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
|
||||
@@ -0,0 +1,77 @@
|
||||
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
|
||||
)
|
||||
@@ -0,0 +1,195 @@
|
||||
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=
|
||||
@@ -0,0 +1,77 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
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())
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
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
|
||||
}
|
||||
+471
@@ -0,0 +1,471 @@
|
||||
// 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)
|
||||
}
|
||||
+514
@@ -0,0 +1,514 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
|
||||
|
||||
ARG TALOS_VERSION=v1.13.10
|
||||
ARG TALOS_VERSION=v1.13.12
|
||||
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 "28e9ccd9aebd690fa03d8cf2582221d87ea38b2a1704f411d75a03589c58f2aa /usr/local/bin/talosctl" | sha256sum -c - \
|
||||
&& echo "4ce4218764c24d6a6bd561097199b60ed8619db073792fce5b7cfce7fa8d12fc /usr/local/bin/talosctl" | sha256sum -c - \
|
||||
&& chmod +x /usr/local/bin/talosctl
|
||||
|
||||
RUN curl -sfL -o /usr/local/bin/kubectl \
|
||||
|
||||
Binary file not shown.
@@ -1,10 +1,9 @@
|
||||
#!/usr/bin/env python3
|
||||
"""netbird-service-sync: reconcile NetBird reverse-proxy services from Ingresses.
|
||||
|
||||
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.
|
||||
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.
|
||||
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
|
||||
@@ -45,7 +44,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", "dns.renner.uno/public,netbird.renner.uno/public").split(",")
|
||||
PUBLIC_LABELS = os.environ.get("PUBLIC_LABELS", "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
|
||||
|
||||
@@ -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.10
|
||||
ARG TALOS_VERSION=v1.13.12
|
||||
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 "28e9ccd9aebd690fa03d8cf2582221d87ea38b2a1704f411d75a03589c58f2aa /usr/local/bin/talosctl" | sha256sum -c - \
|
||||
&& echo "4ce4218764c24d6a6bd561097199b60ed8619db073792fce5b7cfce7fa8d12fc /usr/local/bin/talosctl" | sha256sum -c - \
|
||||
&& chmod +x /usr/local/bin/talosctl
|
||||
|
||||
RUN curl -sfL -o /usr/local/bin/kubectl \
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# 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.
|
||||
@@ -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.10/metal-amd64.iso
|
||||
https://factory.talos.dev/image/0f6fb8bd8bb53013e9743b7c7b099d96fb9c32fd2464ced32693c2dc0ea5feb9/v1.13.12/metal-amd64.iso
|
||||
```
|
||||
|
||||
Write it to a USB stick and boot each MS-01 from it. Each comes up in
|
||||
|
||||
@@ -4,9 +4,10 @@ metadata:
|
||||
name: audiobookshelf
|
||||
namespace: audiobookshelf
|
||||
labels:
|
||||
# Registered publicly (Cloudflare wildcard -> NetBird proxy); the UDM
|
||||
# external-dns instance skips it, LAN reaches it via the hairpin NAT.
|
||||
# Registered publicly (explicit CNAME -> NetBird proxy). No
|
||||
# dns.renner.uno/internal label: external-dns-unifi writes no UDM record.
|
||||
dns.renner.uno/public: "true"
|
||||
netbird.renner.uno/public: "true"
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-cluster
|
||||
spec:
|
||||
|
||||
@@ -23,7 +23,7 @@ spec:
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
dns:
|
||||
# cluster services have no own record; they resolve through the *.renner.uno wildcard
|
||||
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
|
||||
hostname: audiobooks.renner.uno
|
||||
resolveType: CNAME
|
||||
resolveServer: "2606:4700:4700::1111,1.1.1.1"
|
||||
|
||||
@@ -374,16 +374,39 @@ data:
|
||||
name: lidarr-gate
|
||||
state: absent
|
||||
|
||||
# No `providers`: authentik-gate-sync attaches the gates, and an update
|
||||
# of an existing outpost is partial. `config` is replaced whole, so it
|
||||
# carries every key, kubernetes_namespace included.
|
||||
- model: authentik_outposts.outpost
|
||||
identifiers:
|
||||
name: authentik Embedded Outpost
|
||||
state: present
|
||||
attrs:
|
||||
type: proxy
|
||||
service_connection: !KeyOf k8s-conn
|
||||
# No providers attached — each gate outpost serves its own
|
||||
# /outpost.goauthentik.io/* path on the app host.
|
||||
providers: []
|
||||
config:
|
||||
log_level: info
|
||||
# The embedded outpost builds the sign-in redirect from
|
||||
# authentik_host, so it is the public URL.
|
||||
authentik_host: "https://auth.renner.uno/"
|
||||
authentik_host_insecure: false
|
||||
authentik_host_browser: "https://auth.renner.uno/"
|
||||
# A replica whose outpost websocket is down picks up gate changes
|
||||
# only on this interval; the reconnect backoff never resets
|
||||
# (goauthentik/authentik#26463).
|
||||
refresh_interval: "minutes=1"
|
||||
object_naming_template: "ak-outpost-%(name)s"
|
||||
container_image: null
|
||||
docker_labels: null
|
||||
docker_network: ladmin_default
|
||||
docker_map_ports: true
|
||||
kubernetes_namespace: default
|
||||
kubernetes_replicas: 1
|
||||
kubernetes_service_type: ClusterIP
|
||||
kubernetes_disabled_components: []
|
||||
kubernetes_image_pull_secrets: []
|
||||
kubernetes_ingress_annotations: {}
|
||||
kubernetes_ingress_class_name: null
|
||||
kubernetes_ingress_secret_name: "authentik-outpost-tls"
|
||||
kubernetes_json_patches: null
|
||||
|
||||
# Shared LDAPS cert for the LDAP Outpost. Authentik's data model
|
||||
# owns the cert per-LDAPProvider (no outpost-level cert field), so
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
# The authentik-gate ingress class: an Ingress of this class becomes an
|
||||
# Authentik proxy gate (provider, application, outpost, group binding) plus the
|
||||
# Traefik Ingress, ExternalName Service and CiliumNetworkPolicies in front of
|
||||
# its backend. Traefik ignores the class (it only serves classes naming
|
||||
# traefik.io/ingress-controller). Controller: ci/images/authentik-gate-sync.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: IngressClass
|
||||
metadata:
|
||||
name: authentik-gate
|
||||
spec:
|
||||
controller: renner.uno/authentik-gate
|
||||
---
|
||||
# authentik-gate-sync: for every Ingress that names the gate Middleware
|
||||
# (gate.yaml) it builds the Authentik provider (on the embedded outpost),
|
||||
# application and group binding from the Ingress's auth.renner.uno/*
|
||||
# annotations, and a CiliumNetworkPolicy admitting Traefik to its backend.
|
||||
# Controller: ci/images/authentik-gate-sync.
|
||||
#
|
||||
# API token of the authentik-gate-sync service account. Generated once; the
|
||||
# blueprint (gate-sync.yaml key in blueprints-configmap.yaml) sets it as the
|
||||
# token's key via AUTHENTIK_GATE_SYNC_TOKEN, the controller reads the same Secret.
|
||||
@@ -51,8 +45,9 @@ metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: authentik-gate-sync
|
||||
---
|
||||
# Cluster-wide: gated Ingresses live in any namespace, and so do the route
|
||||
# Ingress, ExternalName Service and backend CNP generated next to them.
|
||||
# Cluster-wide: gated Ingresses live in any namespace, and so do their backend
|
||||
# Services and the CNP generated next to them. Delete on Ingresses and Services
|
||||
# covers objects labelled as this controller's that no gate wants.
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
@@ -60,14 +55,10 @@ metadata:
|
||||
rules:
|
||||
- apiGroups: ["networking.k8s.io"]
|
||||
resources: ["ingresses"]
|
||||
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
|
||||
# The source Ingress's load-balancer status mirrors its generated route.
|
||||
- apiGroups: ["networking.k8s.io"]
|
||||
resources: ["ingresses/status"]
|
||||
verbs: ["get", "patch", "update"]
|
||||
verbs: ["get", "list", "watch", "delete"]
|
||||
- apiGroups: [""]
|
||||
resources: ["services"]
|
||||
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
|
||||
verbs: ["get", "list", "watch", "delete"]
|
||||
- apiGroups: ["cilium.io"]
|
||||
resources: ["ciliumnetworkpolicies"]
|
||||
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
|
||||
@@ -114,7 +105,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: controller
|
||||
image: gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-10-21-0755 # {"$imagepolicy": "flux-system:authentik-gate-sync"}
|
||||
image: gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-10-23-0757 # {"$imagepolicy": "flux-system:authentik-gate-sync"}
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
@@ -123,10 +114,6 @@ spec:
|
||||
env:
|
||||
- name: AK_URL
|
||||
value: http://authentik-server.authentik.svc.cluster.local
|
||||
- name: AUTHENTIK_HOST_BROWSER
|
||||
value: https://auth.renner.uno/
|
||||
- name: SERVICE_CONNECTION
|
||||
value: Local Kubernetes Cluster
|
||||
- name: DEFAULT_AUTHORIZATION_FLOW
|
||||
value: default-provider-authorization-implicit-consent
|
||||
- name: INTERVAL_SECONDS
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# The Authentik gate. An Ingress opts in by naming this Middleware in
|
||||
# traefik.ingress.kubernetes.io/router.middlewares (authentik-gate@kubernetescrd);
|
||||
# its auth.renner.uno/* annotations configure the gate, and authentik-gate-sync
|
||||
# (gate-sync.yaml) builds the Authentik provider and the backend CNP from them.
|
||||
# The embedded outpost picks the provider by Host and rejects hosts it has no
|
||||
# provider for.
|
||||
#
|
||||
# authResponseHeadersRegex makes Traefik delete every client-sent X-authentik-*
|
||||
# header before copying the outpost's, so a gated backend only sees identity
|
||||
# Authentik issued.
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: Middleware
|
||||
metadata:
|
||||
name: gate
|
||||
namespace: authentik
|
||||
spec:
|
||||
forwardAuth:
|
||||
address: http://authentik-server.authentik.svc.cluster.local/outpost.goauthentik.io/auth/traefik
|
||||
authResponseHeadersRegex: "(?i)^x-authentik-"
|
||||
---
|
||||
# The outpost's own paths (sign-in callback, sign-out) on every host. Above the
|
||||
# app routes by priority; on hosts without a gate provider the outpost rejects.
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: IngressRoute
|
||||
metadata:
|
||||
name: gate-outpost
|
||||
namespace: authentik
|
||||
spec:
|
||||
entryPoints:
|
||||
- websecure
|
||||
routes:
|
||||
- kind: Rule
|
||||
match: PathPrefix(`/outpost.goauthentik.io/`)
|
||||
priority: 100000
|
||||
services:
|
||||
- name: authentik-server
|
||||
port: 80
|
||||
tls: {}
|
||||
@@ -161,9 +161,10 @@ spec:
|
||||
enabled: true
|
||||
ingressClassName: traefik
|
||||
labels:
|
||||
# Registered publicly (Cloudflare wildcard -> NetBird proxy); the
|
||||
# UDM external-dns instance skips it, LAN uses the hairpin NAT.
|
||||
# Registered publicly (explicit CNAME -> NetBird proxy). No
|
||||
# dns.renner.uno/internal label: external-dns-unifi writes no UDM record.
|
||||
dns.renner.uno/public: "true"
|
||||
netbird.renner.uno/public: "true"
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-cluster
|
||||
hosts:
|
||||
|
||||
@@ -23,7 +23,7 @@ spec:
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
dns:
|
||||
# cluster services have no own record; they resolve through the *.renner.uno wildcard
|
||||
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
|
||||
hostname: auth.renner.uno
|
||||
resolveType: CNAME
|
||||
resolveServer: "2606:4700:4700::1111,1.1.1.1"
|
||||
|
||||
@@ -6,6 +6,7 @@ resources:
|
||||
- blueprints-configmap.yaml
|
||||
- helmrelease.yaml
|
||||
- gate-sync.yaml
|
||||
- gate.yaml
|
||||
- ldap-outpost-cert.yaml
|
||||
- ldap-outpost-lb.yaml
|
||||
- ldap-outpost-cnp.yaml
|
||||
|
||||
@@ -27,6 +27,7 @@ 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
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# 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
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
# 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
|
||||
@@ -0,0 +1,7 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- externalsecret.yaml
|
||||
- rbac.yaml
|
||||
- deployment.yaml
|
||||
- proxy-renner-uno.yaml
|
||||
@@ -0,0 +1,29 @@
|
||||
# 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
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
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
|
||||
@@ -3,6 +3,8 @@ 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
|
||||
- --default-targets=d2386ccd-a665-41e7-8631-603fdafcb006.cfargotunnel.com
|
||||
# Every public host is a DNS-only CNAME to the proxy load balancer (kubernetes/apps/cloudflare-lb-sync).
|
||||
- --default-targets=proxy.renner.uno
|
||||
- --force-default-targets
|
||||
domainFilters:
|
||||
- renner.uno
|
||||
sources:
|
||||
- ingress
|
||||
txtOwnerId: k8s-homelab
|
||||
policy: upsert-only
|
||||
policy: sync
|
||||
# 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: external-dns/cloudflare=true
|
||||
labelFilter: dns.renner.uno/public=true
|
||||
|
||||
@@ -211,7 +211,7 @@ data:
|
||||
# Paperless sits entirely behind the Authentik gate — every path,
|
||||
# including /api/, answers 302 to unauthenticated callers, so there is no
|
||||
# endpoint that proves the app rather than the gate. This check is
|
||||
# therefore reachability only: DNS, TLS, Traefik and the outpost. It
|
||||
# therefore reachability only: DNS, TLS, Traefik and forward auth. It
|
||||
# stays green if paperless itself is dead behind a live gate.
|
||||
#
|
||||
# Closing that blind spot needs an in-cluster probe of the paperless
|
||||
|
||||
@@ -4,9 +4,10 @@ metadata:
|
||||
name: gatus
|
||||
namespace: apps
|
||||
labels:
|
||||
# Registered publicly (Cloudflare wildcard -> NetBird proxy); the UDM
|
||||
# external-dns instance skips it, LAN reaches it via the hairpin NAT.
|
||||
# Registered publicly (explicit CNAME -> NetBird proxy). No
|
||||
# dns.renner.uno/internal label: external-dns-unifi writes no UDM record.
|
||||
dns.renner.uno/public: "true"
|
||||
netbird.renner.uno/public: "true"
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-cluster
|
||||
spec:
|
||||
|
||||
@@ -23,7 +23,7 @@ spec:
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
dns:
|
||||
# cluster services have no own record; they resolve through the *.renner.uno wildcard
|
||||
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
|
||||
hostname: status.renner.uno
|
||||
resolveType: CNAME
|
||||
resolveServer: "2606:4700:4700::1111,1.1.1.1"
|
||||
|
||||
@@ -23,7 +23,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: gitea-mcp
|
||||
image: docker.gitea.com/gitea-mcp-server:1.10.0 # {"$imagepolicy": "flux-system:gitea-mcp"}
|
||||
image: docker.gitea.com/gitea-mcp-server:1.11.0 # {"$imagepolicy": "flux-system:gitea-mcp"}
|
||||
# Image has no ENTRYPOINT (binary is in CMD), so args alone would replace it.
|
||||
command:
|
||||
- /app/gitea-mcp
|
||||
|
||||
@@ -7,6 +7,8 @@ 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:
|
||||
|
||||
@@ -23,7 +23,7 @@ spec:
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
dns:
|
||||
# cluster services have no own record; they resolve through the *.renner.uno wildcard
|
||||
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
|
||||
hostname: gitea-mcp.renner.uno
|
||||
resolveType: CNAME
|
||||
resolveServer: "2606:4700:4700::1111,1.1.1.1"
|
||||
|
||||
@@ -8,6 +8,8 @@ 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:
|
||||
|
||||
@@ -23,7 +23,7 @@ spec:
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
dns:
|
||||
# cluster services have no own record; they resolve through the *.renner.uno wildcard
|
||||
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
|
||||
hostname: git.renner.uno
|
||||
resolveType: CNAME
|
||||
resolveServer: "2606:4700:4700::1111,1.1.1.1"
|
||||
@@ -90,7 +90,7 @@ spec:
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
dns:
|
||||
# cluster services have no own record; they resolve through the *.renner.uno wildcard
|
||||
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
|
||||
hostname: gitea.renner.uno
|
||||
resolveType: CNAME
|
||||
resolveServer: "2606:4700:4700::1111,1.1.1.1"
|
||||
|
||||
@@ -11,6 +11,8 @@ kind: Ingress
|
||||
metadata:
|
||||
name: hermes
|
||||
namespace: hermes
|
||||
labels:
|
||||
dns.renner.uno/internal: "true"
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-cluster
|
||||
spec:
|
||||
@@ -38,6 +40,8 @@ kind: Ingress
|
||||
metadata:
|
||||
name: hermes-api
|
||||
namespace: hermes
|
||||
labels:
|
||||
dns.renner.uno/internal: "true"
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-cluster
|
||||
spec:
|
||||
|
||||
@@ -5,8 +5,12 @@
|
||||
is a TLS-passthrough service on :443 with a PROXY v2 header, going to Traefik `websecure`.
|
||||
There are no UDM port forwards and no other public L4 path. LB VIPs (`bgp-pool`,
|
||||
`fd99::/64`, `10.2.99.0/24`) are LAN/mesh-only.
|
||||
- DNS labels on an Ingress: `dns.renner.uno/internal` writes the UDM (LAN) record
|
||||
(opt-in), `dns.renner.uno/public` writes the Cloudflare CNAME to `proxy.renner.uno`, and
|
||||
`netbird.renner.uno/public` makes `netbird-service-sync` publish the proxy service. A
|
||||
public host needs both public labels.
|
||||
- `netbird-service-sync` publishes **only hosts on Ingresses** labelled
|
||||
`dns.renner.uno/public` (or `netbird.renner.uno/public`), and it reads no other kind.
|
||||
`netbird.renner.uno/public`, and it reads no other kind.
|
||||
To publish a TLS host served by an `IngressRouteTCP` (TLS terminated by Traefik), put the
|
||||
host on its **own** labelled Ingress annotated
|
||||
`traefik.ingress.kubernetes.io/router.entrypoints: web` with **no** `tls:` section, and
|
||||
|
||||
@@ -16,15 +16,15 @@ monitor, and its history starts over.
|
||||
the KumaMonitor `healthCheckExprs` entry with `current: "true"` (see SKILL.md pitfalls).
|
||||
|
||||
## Which hosts are public
|
||||
The public set is exactly the hosts on Ingresses labelled `dns.renner.uno/public` or
|
||||
`netbird.renner.uno/public`. To confirm it, do a TLS handshake against the proxy-1 and
|
||||
A public host needs both labels on its Ingress: `netbird.renner.uno/public` for the proxy
|
||||
service and `dns.renner.uno/public` for the Cloudflare CNAME. To confirm it, do a TLS handshake against the proxy-1 and
|
||||
proxy-2 addresses (resolved via DoH) with each candidate hostname as the SNI. Only public hosts
|
||||
return a CA-verified certificate. The proxies do not answer for their own names, so a
|
||||
handshake with `proxy-N.renner.uno` as the SNI fails.
|
||||
|
||||
## Checks per host (owner's spec: DNS + HTTP reachability + response validation)
|
||||
- **DNS.** A cluster host has no record of its own and resolves through the `*.renner.uno`
|
||||
wildcard. Check `resolveType: CNAME` with `conditions: [{operator: equals, value:
|
||||
- **DNS.** A cluster host's public record is a CNAME to `proxy.renner.uno` created by
|
||||
external-dns from `dns.renner.uno/public`. Check `resolveType: CNAME` with `conditions: [{operator: equals, value:
|
||||
proxy.renner.uno}]`. An external host checks its own AAAA record, or A when it has no
|
||||
AAAA. Use `resolveServer: "2606:4700:4700::1111,1.1.1.1"` so IPv6 comes first; Kuma
|
||||
fails over through the list in order.
|
||||
@@ -71,7 +71,7 @@ reached Kuma.
|
||||
parent exists and is a group.
|
||||
- Emulate each http/keyword/json-query check the way Kuma does it: axios, Kuma's
|
||||
`evaluateJsonQuery` logic via `jsonata`, and a custom `lookup` that resolves through DoH,
|
||||
because this pod's DNS is split-horizon. Run each wildcard host pinned to proxy-1 and to
|
||||
because this pod's DNS is split-horizon. Run each public host pinned to proxy-1 and to
|
||||
proxy-2.
|
||||
- DNS monitors can't be checked from the cluster, because outbound :53 to external
|
||||
resolvers is blocked. After merge, read `monitor_status` from Prometheus instead
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
# /etc/nginx/conf.d/ at start.
|
||||
#
|
||||
# shelly-manager signs in whoever X-authentik-username names when the peer is
|
||||
# in Auth__ReverseProxy__TrustedProxies (the pod CIDR). Only the Authentik
|
||||
# outpost may reach :8080 directly. Other clients reach this :8081 instead,
|
||||
# in Auth__ReverseProxy__TrustedProxies (the pod CIDR). Only Traefik may
|
||||
# reach :8080 directly. Other clients reach this :8081 instead,
|
||||
# which drops the identity header, so every request here falls through
|
||||
# to the API key (or the login cookie) and a forged header gets 401.
|
||||
#
|
||||
|
||||
@@ -75,7 +75,7 @@ spec:
|
||||
# macvlan net1 interface on the IoT VLAN — exposing the
|
||||
# unauthenticated UI to that untrusted segment past the
|
||||
# Authentik gate. POD_IP is the eth0 IPv4 (status.podIP); the
|
||||
# Service, the Authentik outpost, and the kubelet httpGet
|
||||
# Service, Traefik, and the kubelet httpGet
|
||||
# probes all reach the app on that address. mDNS discovery is
|
||||
# unaffected: ZeroconfResolver binds its own multicast sockets
|
||||
# on net1 independently of Kestrel.
|
||||
@@ -90,8 +90,7 @@ spec:
|
||||
# BLE radios: ESP32 esphome bluetooth_proxy nodes (e.g. the M5 Atom
|
||||
# Echo) are AUTO-DISCOVERED on the IoT VLAN via mDNS
|
||||
# (_esphomelib._tcp) over the same net1 macvlan route as the Shellys —
|
||||
# no static hostname/DNS (which the *.renner.uno wildcard would poison
|
||||
# over IPv6). The shared fleet Noise key (= the dashboard's
|
||||
# no static hostname/DNS. The shared fleet Noise key (= the dashboard's
|
||||
# shelly_ble_proxy_api_key) lets shelly-manager complete the encrypted
|
||||
# native-API handshake to each discovered proxy: DeviceInfo on
|
||||
# /ble-radios today, the BLE scan/GATT provisioning path next.
|
||||
@@ -114,11 +113,11 @@ spec:
|
||||
secretKeyRef:
|
||||
name: shelly-manager-auth
|
||||
key: api-key
|
||||
# SSO: the Authentik proxy outpost (authentik-gate class,
|
||||
# which the ingress routes through) injects X-authentik-username;
|
||||
# SSO: Authentik forward auth (authentik-gate class,
|
||||
# via Traefik ForwardAuth) sets X-authentik-username;
|
||||
# the app signs that identity in directly, so the gate login is the
|
||||
# only login. The header is only honored from TrustedProxies — the
|
||||
# outpost pod's address is dynamic, so this trusts the cluster pod
|
||||
# Traefik pod's address is dynamic, so this trusts the cluster pod
|
||||
# CIDR and leans on the namespace CNP to keep other pods off the
|
||||
# app port. ADMIN_PASSWORD (above) stays as break-glass, e.g. for
|
||||
# port-forward access when authentik is down. Never open :8080 to
|
||||
@@ -170,13 +169,13 @@ spec:
|
||||
memory: 128Mi
|
||||
limits:
|
||||
memory: 512Mi
|
||||
# API-key-only listener for clients other than the Authentik outpost
|
||||
# API-key-only listener for clients other than Traefik
|
||||
# (REN-931). nginx on ${POD_IP}:8081 proxies to
|
||||
# :8080 and drops X-authentik-username, so the ProxyHeader sign-in
|
||||
# cannot be reached through it. Config and reasoning:
|
||||
# shelly-manager-api-proxy.conf. Service: shelly-manager-api.
|
||||
# No probes: it only serves the API-key path, and a probe failure would
|
||||
# also pull the outpost's endpoint out of the shelly-manager Service.
|
||||
# also pull the pod out of the shelly-manager Service.
|
||||
- name: api-proxy
|
||||
image: nginxinc/nginx-unprivileged:1.31-alpine@sha256:b9241c6e7b8e9a862f129d8d4199ab64b10390949a78bdd5603379b32c844083
|
||||
env:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# shelly-manager has no authentication of its own: anything reaching it can
|
||||
# rewrite Shelly config, push scripts and read stored device AdminPasswords, so
|
||||
# the Authentik gate (ingress class authentik-gate) is the only thing in front
|
||||
# the Authentik gate (the authentik-gate Middleware) is the only thing in front
|
||||
# of it: Authentik SSO login, Shelly Managers only.
|
||||
#
|
||||
# SignalR/WebSocket: the websecure entrypoint idleTimeout (180s) is kept alive
|
||||
@@ -12,11 +12,14 @@ kind: Ingress
|
||||
metadata:
|
||||
name: shelly-manager
|
||||
namespace: home-assistant
|
||||
labels:
|
||||
dns.renner.uno/internal: "true"
|
||||
annotations:
|
||||
traefik.ingress.kubernetes.io/router.middlewares: authentik-gate@kubernetescrd
|
||||
cert-manager.io/cluster-issuer: letsencrypt-cluster
|
||||
auth.renner.uno/group: Shelly Managers
|
||||
spec:
|
||||
ingressClassName: authentik-gate
|
||||
ingressClassName: traefik
|
||||
rules:
|
||||
- host: shelly-manager.home.renner.uno
|
||||
http:
|
||||
|
||||
@@ -15,7 +15,7 @@ spec:
|
||||
# API-key-only entry for in-cluster clients (REN-931, REN-935): the api-proxy
|
||||
# sidecar, which drops the X-authentik-username header. Clients call
|
||||
# http://shelly-manager-api.home-assistant.svc/api/v1/... with the API key.
|
||||
# :8080 stays outpost-only.
|
||||
# :8080 stays Traefik-only.
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
|
||||
@@ -16,6 +16,7 @@ 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
|
||||
|
||||
@@ -23,7 +23,7 @@ spec:
|
||||
# flashing is done in the browser over USB (ESP Web Tools / Web Serial),
|
||||
# and building/compiling is offline. So the 0.0.0.0:6052 bind stays on the
|
||||
# Cilium-governed pod network only, where the home-assistant CNP admits
|
||||
# ONLY the esphome gate outpost (generated CNP esphome-gate; see
|
||||
# ONLY Traefik (generated CNP esphome-gate; see
|
||||
# esphome-ingress.yaml) — no unauthenticated IoT-VLAN exposure like the
|
||||
# macvlan-on-0.0.0.0 hole shelly-manager had to pin Kestrel around.
|
||||
#
|
||||
|
||||
@@ -3,20 +3,23 @@ kind: Ingress
|
||||
metadata:
|
||||
name: esphome
|
||||
namespace: home-assistant
|
||||
labels:
|
||||
dns.renner.uno/internal: "true"
|
||||
annotations:
|
||||
traefik.ingress.kubernetes.io/router.middlewares: authentik-gate@kubernetescrd
|
||||
cert-manager.io/cluster-issuer: letsencrypt-cluster
|
||||
auth.renner.uno/group: ESPHome Managers
|
||||
spec:
|
||||
# ESPHome Device Builder has no auth of its own: anyone reaching it can edit
|
||||
# device YAML (incl. wifi/api secrets), compile, and flash/OTA firmware, so the
|
||||
# Authentik gate (ingress class authentik-gate) is the only thing in front of
|
||||
# Authentik gate (the authentik-gate Middleware) is the only thing in front of
|
||||
# it: Authentik SSO login, ESPHome Managers only.
|
||||
#
|
||||
# WebSocket: the dashboard streams build/OTA logs over a websocket; Traefik's
|
||||
# websecure entrypoint passes it through. Browser USB flashing uses Web
|
||||
# Serial, which needs a secure context; the serial link never traverses the
|
||||
# proxy.
|
||||
ingressClassName: authentik-gate
|
||||
ingressClassName: traefik
|
||||
rules:
|
||||
- host: esphome.home.renner.uno
|
||||
http:
|
||||
|
||||
@@ -7,6 +7,7 @@ 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,6 +13,7 @@ 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
|
||||
|
||||
@@ -3,21 +3,24 @@
|
||||
#
|
||||
# code-server runs with --auth=none as root and mounts the entire /config
|
||||
# (plaintext secrets.yaml, .storage, the git PAT in the PVC remote URL), so the
|
||||
# Authentik gate (ingress class authentik-gate) is the only thing in front of
|
||||
# Authentik gate (the authentik-gate Middleware) is the only thing in front of
|
||||
# it: Authentik SSO login, Home Assistant Config Editors only.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: home-assistant-vscode
|
||||
namespace: home-assistant
|
||||
labels:
|
||||
dns.renner.uno/internal: "true"
|
||||
annotations:
|
||||
traefik.ingress.kubernetes.io/router.middlewares: authentik-gate@kubernetescrd
|
||||
cert-manager.io/cluster-issuer: letsencrypt-cluster
|
||||
auth.renner.uno/group: Home Assistant Config Editors
|
||||
# code-server's manifest and service worker are fetched without
|
||||
# credentials. Version-specific: re-check after a code-server bump.
|
||||
auth.renner.uno/skip-path: ^/(_static/|manifest\.json|.*\.webmanifest|.*serviceWorker\.js).*
|
||||
spec:
|
||||
ingressClassName: authentik-gate
|
||||
ingressClassName: traefik
|
||||
rules:
|
||||
- host: vscode.home.renner.uno
|
||||
http:
|
||||
|
||||
@@ -3,18 +3,21 @@ kind: Ingress
|
||||
metadata:
|
||||
name: zigbee2mqtt
|
||||
namespace: home-assistant
|
||||
labels:
|
||||
dns.renner.uno/internal: "true"
|
||||
annotations:
|
||||
traefik.ingress.kubernetes.io/router.middlewares: authentik-gate@kubernetescrd
|
||||
cert-manager.io/cluster-issuer: letsencrypt-cluster
|
||||
auth.renner.uno/group: Zigbee2MQTT Managers
|
||||
spec:
|
||||
# The Z2M frontend has no auth of its own: anyone reaching it can rename
|
||||
# devices, toggle permit_join, trigger OTA, and control the whole Zigbee
|
||||
# network, so the Authentik gate (ingress class authentik-gate) is the only
|
||||
# network, so the Authentik gate (the authentik-gate Middleware) is the only
|
||||
# thing in front of it: Authentik SSO login, Zigbee2MQTT Managers only.
|
||||
#
|
||||
# WebSocket: the frontend streams live device state / logs over a websocket;
|
||||
# Traefik's websecure entrypoint passes it through.
|
||||
ingressClassName: authentik-gate
|
||||
ingressClassName: traefik
|
||||
rules:
|
||||
- host: zigbee2mqtt.home.renner.uno
|
||||
http:
|
||||
|
||||
@@ -104,6 +104,8 @@ kind: Ingress
|
||||
metadata:
|
||||
name: ical-proxy
|
||||
namespace: apps
|
||||
labels:
|
||||
dns.renner.uno/internal: "true"
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-cluster
|
||||
spec:
|
||||
|
||||
@@ -4,9 +4,10 @@ metadata:
|
||||
name: immich
|
||||
namespace: immich
|
||||
labels:
|
||||
# Registered publicly (Cloudflare wildcard -> NetBird proxy); the UDM
|
||||
# external-dns instance skips it, LAN reaches it via the hairpin NAT.
|
||||
# Registered publicly (explicit CNAME -> NetBird proxy). No
|
||||
# dns.renner.uno/internal label: external-dns-unifi writes no UDM record.
|
||||
dns.renner.uno/public: "true"
|
||||
netbird.renner.uno/public: "true"
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-cluster
|
||||
spec:
|
||||
@@ -27,9 +28,8 @@ spec:
|
||||
hosts:
|
||||
- immich.renner.uno
|
||||
---
|
||||
# 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
|
||||
# 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
|
||||
# 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,6 +37,8 @@ kind: Ingress
|
||||
metadata:
|
||||
name: immich-photos
|
||||
namespace: immich
|
||||
labels:
|
||||
dns.renner.uno/internal: "true"
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-cluster
|
||||
spec:
|
||||
|
||||
@@ -23,7 +23,7 @@ spec:
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
dns:
|
||||
# cluster services have no own record; they resolve through the *.renner.uno wildcard
|
||||
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
|
||||
hostname: immich.renner.uno
|
||||
resolveType: CNAME
|
||||
resolveServer: "2606:4700:4700::1111,1.1.1.1"
|
||||
|
||||
@@ -30,7 +30,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: controller
|
||||
image: gitea.renner.uno/gitea_admin/kuma-sync:2026-10-21-0756 # {"$imagepolicy": "flux-system:kuma-sync"}
|
||||
image: gitea.renner.uno/gitea_admin/kuma-sync:2026-10-22-1858 # {"$imagepolicy": "flux-system:kuma-sync"}
|
||||
env:
|
||||
- name: KUMA_URL
|
||||
value: https://kuma.renner.uno
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
# llm.renner.uno — the chat WebUI, public and behind an Authentik gate
|
||||
# (ingress class authentik-gate, kubernetes/apps/authentik/gate-sync.yaml).
|
||||
# (the authentik-gate Middleware, kubernetes/apps/authentik/gate.yaml).
|
||||
#
|
||||
# The gate is the entire access control here: llama-server has no
|
||||
# authentication of its own and serves a full OpenAI-compatible API on the same
|
||||
@@ -8,24 +8,25 @@
|
||||
#
|
||||
# In-cluster consumers — Home Assistant, Paperless, CI — address
|
||||
# llama-server.ai.svc.cluster.local:8080 directly and never pass through
|
||||
# Traefik or the outpost.
|
||||
# Traefik or Authentik.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: llama-server
|
||||
namespace: ai
|
||||
labels:
|
||||
# Public: Cloudflare wildcard → tunnel → NetBird proxy → Traefik. The UDM
|
||||
# external-dns instance skips anything carrying this, and LAN clients reach
|
||||
# the same hostname through the hairpin NAT.
|
||||
# Public: Cloudflare CNAME → NetBird proxy → Traefik. No
|
||||
# dns.renner.uno/internal label: external-dns-unifi writes no UDM record.
|
||||
dns.renner.uno/public: "true"
|
||||
netbird.renner.uno/public: "true"
|
||||
annotations:
|
||||
traefik.ingress.kubernetes.io/router.middlewares: authentik-gate@kubernetescrd
|
||||
cert-manager.io/cluster-issuer: letsencrypt-cluster
|
||||
# Manifest and service worker are fetched without credentials; neither
|
||||
# serves authenticated content.
|
||||
auth.renner.uno/skip-path: ^/(manifest\.webmanifest|sw\.js)$
|
||||
spec:
|
||||
ingressClassName: authentik-gate
|
||||
ingressClassName: traefik
|
||||
tls:
|
||||
- hosts:
|
||||
- llm.renner.uno
|
||||
|
||||
@@ -23,7 +23,7 @@ spec:
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
dns:
|
||||
# cluster services have no own record; they resolve through the *.renner.uno wildcard
|
||||
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
|
||||
hostname: llm.renner.uno
|
||||
resolveType: CNAME
|
||||
resolveServer: "2606:4700:4700::1111,1.1.1.1"
|
||||
|
||||
@@ -35,6 +35,8 @@ 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
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
# Public (REN-663): each Ingress carries dns.renner.uno/public, so
|
||||
# netbird-service-sync publishes its host through the NetBird proxy
|
||||
# (Cloudflare wildcard -> proxy.renner.uno -> TLS passthrough to Traefik) and
|
||||
# external-dns-unifi skips it; LAN clients follow the public answer and the UDM
|
||||
# hairpins back in. Same path as audiobooks/immich. Federation stays off.
|
||||
# (explicit Cloudflare CNAME -> proxy.renner.uno -> TLS passthrough to Traefik)
|
||||
# and there is no dns.renner.uno/internal label, so external-dns-unifi writes
|
||||
# no UDM record; LAN clients follow the public answer and the UDM hairpins
|
||||
# back in. Same path as audiobooks/immich. Federation stays off.
|
||||
#
|
||||
# Synapse: only the client-facing paths. /_synapse/admin stays off the
|
||||
# Ingress (in-cluster only); /_matrix/federation and /_matrix/key are not
|
||||
@@ -13,9 +14,10 @@ metadata:
|
||||
name: synapse
|
||||
namespace: matrix
|
||||
labels:
|
||||
# Registered publicly (Cloudflare wildcard -> NetBird proxy); the UDM
|
||||
# external-dns instance skips it, LAN reaches it via the hairpin NAT.
|
||||
# Registered publicly (explicit CNAME -> NetBird proxy). No
|
||||
# dns.renner.uno/internal label: external-dns-unifi writes no UDM record.
|
||||
dns.renner.uno/public: "true"
|
||||
netbird.renner.uno/public: "true"
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-cluster
|
||||
spec:
|
||||
@@ -122,9 +124,10 @@ metadata:
|
||||
name: element-web
|
||||
namespace: matrix
|
||||
labels:
|
||||
# Registered publicly (Cloudflare wildcard -> NetBird proxy); the UDM
|
||||
# external-dns instance skips it, LAN reaches it via the hairpin NAT.
|
||||
# Registered publicly (explicit CNAME -> NetBird proxy). No
|
||||
# dns.renner.uno/internal label: external-dns-unifi writes no UDM record.
|
||||
dns.renner.uno/public: "true"
|
||||
netbird.renner.uno/public: "true"
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-cluster
|
||||
spec:
|
||||
@@ -152,9 +155,10 @@ metadata:
|
||||
name: mas
|
||||
namespace: matrix
|
||||
labels:
|
||||
# Registered publicly (Cloudflare wildcard -> NetBird proxy); the UDM
|
||||
# external-dns instance skips it, LAN reaches it via the hairpin NAT.
|
||||
# Registered publicly (explicit CNAME -> NetBird proxy). No
|
||||
# dns.renner.uno/internal label: external-dns-unifi writes no UDM record.
|
||||
dns.renner.uno/public: "true"
|
||||
netbird.renner.uno/public: "true"
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-cluster
|
||||
spec:
|
||||
|
||||
@@ -23,7 +23,7 @@ spec:
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
dns:
|
||||
# cluster services have no own record; they resolve through the *.renner.uno wildcard
|
||||
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
|
||||
hostname: matrix.renner.uno
|
||||
resolveType: CNAME
|
||||
resolveServer: "2606:4700:4700::1111,1.1.1.1"
|
||||
@@ -136,7 +136,7 @@ spec:
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
dns:
|
||||
# cluster services have no own record; they resolve through the *.renner.uno wildcard
|
||||
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
|
||||
hostname: account.matrix.renner.uno
|
||||
resolveType: CNAME
|
||||
resolveServer: "2606:4700:4700::1111,1.1.1.1"
|
||||
@@ -202,7 +202,7 @@ spec:
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
dns:
|
||||
# cluster services have no own record; they resolve through the *.renner.uno wildcard
|
||||
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
|
||||
hostname: element.renner.uno
|
||||
resolveType: CNAME
|
||||
resolveServer: "2606:4700:4700::1111,1.1.1.1"
|
||||
@@ -289,7 +289,7 @@ spec:
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
dns:
|
||||
# cluster services have no own record; they resolve through the *.renner.uno wildcard
|
||||
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
|
||||
hostname: rtc.matrix.renner.uno
|
||||
resolveType: CNAME
|
||||
resolveServer: "2606:4700:4700::1111,1.1.1.1"
|
||||
@@ -354,7 +354,7 @@ spec:
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
dns:
|
||||
# cluster services have no own record; they resolve through the *.renner.uno wildcard
|
||||
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
|
||||
hostname: turn.matrix.renner.uno
|
||||
resolveType: CNAME
|
||||
resolveServer: "2606:4700:4700::1111,1.1.1.1"
|
||||
|
||||
@@ -317,6 +317,7 @@ 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
|
||||
@@ -369,6 +370,7 @@ metadata:
|
||||
namespace: matrix
|
||||
labels:
|
||||
dns.renner.uno/public: "true"
|
||||
netbird.renner.uno/public: "true"
|
||||
annotations:
|
||||
traefik.ingress.kubernetes.io/router.entrypoints: web
|
||||
spec:
|
||||
|
||||
@@ -27,7 +27,7 @@ spec:
|
||||
- https://home.renner.uno
|
||||
# prometheus.renner.uno and alerts.renner.uno are Authentik-gated
|
||||
# (PR #147 / #146). These EXTERNAL probes now measure the full
|
||||
# gated path — DNS, TLS, Traefik, the gate outpost — following the
|
||||
# gated path — DNS, TLS, Traefik, Authentik forward auth — following the
|
||||
# 302 to the Authentik login (200). They are REACHABILITY checks,
|
||||
# not app health: with the gate up, they stay green even if the app
|
||||
# behind it is dead (the probe never reaches the app). Real app
|
||||
@@ -98,7 +98,7 @@ spec:
|
||||
# rule as the block above (monitoring-vantage-point-rule), but these
|
||||
# are the first targets here that leave the monitoring namespace.
|
||||
#
|
||||
# Both sit entirely behind an Authentik gate outpost (ingress class
|
||||
# Both sit entirely behind Authentik forward auth (ingress class
|
||||
# authentik-gate; ../navidrome/ingress.yaml, ../navidrome/lidarr.yaml) — so an
|
||||
# EXTERNAL probe of either name measures the gate, not the app, and
|
||||
# stays green while the app behind
|
||||
@@ -219,7 +219,7 @@ spec:
|
||||
# (monitoring-vantage-point-rule); the blind spot it closes is the one
|
||||
# gatus already documents in its Web Service 11 comment
|
||||
# (../gatus/configmap.yaml#gatus-endpoints) — through the
|
||||
# gate outpost EVERY path, /api/ included,
|
||||
# gate EVERY path, /api/ included,
|
||||
# answers 302 to an unauthenticated caller, so no external probe of
|
||||
# paperless.renner.uno can tell a live app from a live gate. That
|
||||
# external signal is still wanted, and REN-2 adds it from UptimeRobot;
|
||||
@@ -268,7 +268,7 @@ spec:
|
||||
# anchor: shelly-manager-app-health
|
||||
# APP-HEALTH probe for shelly-manager (REN-531/REN-533). Same rule as
|
||||
# the blocks above (monitoring-vantage-point-rule): shelly.renner.uno
|
||||
# sits behind a gate outpost, so only the in-cluster
|
||||
# sits behind an Authentik gate, so only the in-cluster
|
||||
# Service can tell a dead app from a live gate.
|
||||
#
|
||||
# /health is unauthenticated and answers a plain 200 — not a 3xx —
|
||||
|
||||
@@ -42,7 +42,7 @@ spec:
|
||||
description: >-
|
||||
The endpoint is not answering as expected. For the Authentik-gated
|
||||
hostnames this is a reachability check covering DNS, TLS, Traefik
|
||||
and the gate outpost — it says nothing about the app behind the
|
||||
and Authentik forward auth — it says nothing about the app behind the
|
||||
gate, which the internal /-/healthy probes cover separately.
|
||||
labels:
|
||||
severity: warning
|
||||
|
||||
@@ -25,8 +25,8 @@ spec:
|
||||
summary: "{{ $labels.dns_name }} no longer resolves to its LAN address"
|
||||
description: >-
|
||||
The UDM stopped answering for a name external-dns publishes from
|
||||
a LoadBalancer Service, or answered with the public
|
||||
*.renner.uno wildcard instead of the record. Clients that use
|
||||
a LoadBalancer Service, or answered without the
|
||||
record (no answer, or a public CNAME). Clients that use
|
||||
the name rather than the address lose the service while
|
||||
everything in the cluster stays healthy — nothing else in the
|
||||
stack reports this. Check the external-dns alerts first, then
|
||||
|
||||
@@ -31,8 +31,9 @@ spec:
|
||||
{{ $value | humanizePercentage }} of probes in the last 10m got
|
||||
the Ingress address for {{ $labels.dns_name }} ({{ $labels.record }})
|
||||
from k8s-gateway. Pods then resolve Ingress names through the UDM
|
||||
and the public wildcard (CNAME proxy.renner.uno) and leave the
|
||||
cluster. Check the rendered Corefile in the k8s-gateway ConfigMap
|
||||
and the public zone (CNAME proxy.renner.uno for public hosts)
|
||||
and leave the cluster. Check the rendered Corefile in the
|
||||
k8s-gateway ConfigMap
|
||||
(kube-system) for the renner.uno zone, the k8s-gateway logs, and
|
||||
whether the Traefik VIP in infrastructure/traefik/helmrelease.yaml
|
||||
still matches the dns_k8s_gateway_auth_* modules in
|
||||
|
||||
@@ -30,7 +30,7 @@ tests:
|
||||
record: A
|
||||
exp_annotations:
|
||||
summary: "k8s-gateway does not answer auth.renner.uno A with the Traefik VIP"
|
||||
description: "20% of probes in the last 10m got the Ingress address for auth.renner.uno (A) from k8s-gateway. Pods then resolve Ingress names through the UDM and the public wildcard (CNAME proxy.renner.uno) and leave the cluster. Check the rendered Corefile in the k8s-gateway ConfigMap (kube-system) for the renner.uno zone, the k8s-gateway logs, and whether the Traefik VIP in infrastructure/traefik/helmrelease.yaml still matches the dns_k8s_gateway_auth_* modules in apps/monitoring/helmrelease-blackbox-exporter.yaml. It also fails when the auth.renner.uno Ingress (authentik) is gone."
|
||||
description: "20% of probes in the last 10m got the Ingress address for auth.renner.uno (A) from k8s-gateway. Pods then resolve Ingress names through the UDM and the public zone (CNAME proxy.renner.uno for public hosts) and leave the cluster. Check the rendered Corefile in the k8s-gateway ConfigMap (kube-system) for the renner.uno zone, the k8s-gateway logs, and whether the Traefik VIP in infrastructure/traefik/helmrelease.yaml still matches the dns_k8s_gateway_auth_* modules in apps/monitoring/helmrelease-blackbox-exporter.yaml. It also fails when the auth.renner.uno Ingress (authentik) is gone."
|
||||
- eval_time: 17m
|
||||
alertname: K8sGatewayProbesAbsent
|
||||
exp_alerts: []
|
||||
@@ -53,7 +53,7 @@ tests:
|
||||
record: AAAA
|
||||
exp_annotations:
|
||||
summary: "k8s-gateway does not answer auth.renner.uno AAAA with the Traefik VIP"
|
||||
description: "50% of probes in the last 10m got the Ingress address for auth.renner.uno (AAAA) from k8s-gateway. Pods then resolve Ingress names through the UDM and the public wildcard (CNAME proxy.renner.uno) and leave the cluster. Check the rendered Corefile in the k8s-gateway ConfigMap (kube-system) for the renner.uno zone, the k8s-gateway logs, and whether the Traefik VIP in infrastructure/traefik/helmrelease.yaml still matches the dns_k8s_gateway_auth_* modules in apps/monitoring/helmrelease-blackbox-exporter.yaml. It also fails when the auth.renner.uno Ingress (authentik) is gone."
|
||||
description: "50% of probes in the last 10m got the Ingress address for auth.renner.uno (AAAA) from k8s-gateway. Pods then resolve Ingress names through the UDM and the public zone (CNAME proxy.renner.uno for public hosts) and leave the cluster. Check the rendered Corefile in the k8s-gateway ConfigMap (kube-system) for the renner.uno zone, the k8s-gateway logs, and whether the Traefik VIP in infrastructure/traefik/helmrelease.yaml still matches the dns_k8s_gateway_auth_* modules in apps/monitoring/helmrelease-blackbox-exporter.yaml. It also fails when the auth.renner.uno Ingress (authentik) is gone."
|
||||
|
||||
# Two consecutive failures in an otherwise healthy hour stay silent
|
||||
# (average exactly 0.8).
|
||||
|
||||
@@ -97,7 +97,7 @@ tests:
|
||||
instance: https://other.renner.uno
|
||||
exp_annotations:
|
||||
summary: "Blackbox probe of https://other.renner.uno has been failing for 10 minutes"
|
||||
description: "The endpoint is not answering as expected. For the Authentik-gated hostnames this is a reachability check covering DNS, TLS, Traefik and the gate outpost — it says nothing about the app behind the gate, which the internal /-/healthy probes cover separately."
|
||||
description: "The endpoint is not answering as expected. For the Authentik-gated hostnames this is a reachability check covering DNS, TLS, Traefik and Authentik forward auth — it says nothing about the app behind the gate, which the internal /-/healthy probes cover separately."
|
||||
|
||||
# Healthy probe stays silent.
|
||||
- interval: 1m
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
# alerts.renner.uno — Alertmanager. Its v2 API is writable (POST
|
||||
# /api/v2/silences can suppress all alerting) and it has no auth of its own, so
|
||||
# the Authentik gate (ingress class authentik-gate) is the only thing in front
|
||||
# the Authentik gate (the authentik-gate Middleware) is the only thing in front
|
||||
# of it: Monitoring Access only. Replaces the chart-generated Ingress
|
||||
# (alertmanager.ingress.enabled=false in helmrelease-prometheus.yaml).
|
||||
apiVersion: networking.k8s.io/v1
|
||||
@@ -9,11 +9,14 @@ kind: Ingress
|
||||
metadata:
|
||||
name: alertmanager
|
||||
namespace: monitoring
|
||||
labels:
|
||||
dns.renner.uno/internal: "true"
|
||||
annotations:
|
||||
traefik.ingress.kubernetes.io/router.middlewares: authentik-gate@kubernetescrd
|
||||
cert-manager.io/cluster-issuer: letsencrypt-cluster
|
||||
auth.renner.uno/group: Monitoring Access
|
||||
spec:
|
||||
ingressClassName: authentik-gate
|
||||
ingressClassName: traefik
|
||||
rules:
|
||||
- host: alerts.renner.uno
|
||||
http:
|
||||
|
||||
@@ -229,6 +229,7 @@ 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
|
||||
|
||||
@@ -78,11 +78,10 @@ spec:
|
||||
# passed per scrape. The Probes in monitoring-rules/probes-dns.yaml
|
||||
# point these at the UDM.
|
||||
#
|
||||
# Both answer checks are load-bearing. valid_rcodes alone passes a
|
||||
# deleted record two labels deep (NOERROR, no answer), and the public
|
||||
# *.renner.uno wildcard answers one label deep with a CNAME to
|
||||
# proxy.renner.uno — NOERROR with the WAN proxy behind it. Requiring
|
||||
# an A and rejecting a CNAME separates a real record from both.
|
||||
# Both answer checks are load-bearing: valid_rcodes alone passes a
|
||||
# deleted record (NOERROR, no answer, any depth). Requiring an A
|
||||
# separates a real record from that; a CNAME means the UDM answered with a
|
||||
# public-zone target instead of the LAN address.
|
||||
dns_lan_ha:
|
||||
prober: dns
|
||||
timeout: 5s
|
||||
|
||||
@@ -132,6 +132,8 @@ spec:
|
||||
ingress:
|
||||
enabled: true
|
||||
ingressClassName: traefik
|
||||
labels:
|
||||
dns.renner.uno/internal: "true"
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-cluster
|
||||
hosts:
|
||||
|
||||
@@ -23,7 +23,7 @@ spec:
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
dns:
|
||||
# target of the *.renner.uno wildcard
|
||||
# target of every public CNAME (NetBird proxy load balancer)
|
||||
hostname: proxy.renner.uno
|
||||
resolveType: AAAA
|
||||
resolveServer: "2606:4700:4700::1111,1.1.1.1"
|
||||
@@ -228,7 +228,7 @@ spec:
|
||||
retryInterval: 60
|
||||
maxRetries: 1
|
||||
dns:
|
||||
# Cloudflare-proxied apex, not the wildcard
|
||||
# Cloudflare-proxied apex
|
||||
hostname: renner.uno
|
||||
resolveType: AAAA
|
||||
resolveServer: "2606:4700:4700::1111,1.1.1.1"
|
||||
|
||||
@@ -3,6 +3,8 @@ 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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
# prometheus.renner.uno — Prometheus. It has no auth of its own and its metrics
|
||||
# DB exposes hostnames, IPs and service inventory, so the Authentik gate
|
||||
# (ingress class authentik-gate) is the only thing in front of it: Monitoring
|
||||
# (the authentik-gate Middleware) is the only thing in front of it: Monitoring
|
||||
# Access only. Replaces the chart-generated Ingress
|
||||
# (prometheus.ingress.enabled=false in helmrelease-prometheus.yaml).
|
||||
apiVersion: networking.k8s.io/v1
|
||||
@@ -9,11 +9,14 @@ kind: Ingress
|
||||
metadata:
|
||||
name: prometheus
|
||||
namespace: monitoring
|
||||
labels:
|
||||
dns.renner.uno/internal: "true"
|
||||
annotations:
|
||||
traefik.ingress.kubernetes.io/router.middlewares: authentik-gate@kubernetescrd
|
||||
cert-manager.io/cluster-issuer: letsencrypt-cluster
|
||||
auth.renner.uno/group: Monitoring Access
|
||||
spec:
|
||||
ingressClassName: authentik-gate
|
||||
ingressClassName: traefik
|
||||
rules:
|
||||
- host: prometheus.renner.uno
|
||||
http:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user