Compare commits
112
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
774907eaf1 | ||
|
|
717c5a5f8a | ||
|
|
4fd3c21308 | ||
|
|
5e79157e5e | ||
|
|
4c36913fee | ||
|
|
605690f9ff | ||
|
|
fe6fa9ec4c | ||
|
|
c954043f90 | ||
|
|
31be9a115a | ||
|
|
deda43ab85 | ||
|
|
19a464b2bf | ||
|
|
b29174dff2 | ||
|
|
451e5002e9 | ||
|
|
d443f435aa | ||
|
|
63a3aebe84 | ||
|
|
ee2d5d1d25 | ||
|
|
60b4286910 | ||
|
|
cc52181352 | ||
|
|
56d55b6228 | ||
|
|
6416f2f609 | ||
|
|
80c2def518 | ||
|
|
0ca1753418 | ||
|
|
84b415a0e0 | ||
|
|
c0fa69321b | ||
|
|
f774ca4bca | ||
|
|
30362e23d7 | ||
|
|
7efa556397 | ||
|
|
4f422eb7de | ||
|
|
6bc23cd306 | ||
|
|
6b0617b4a7 | ||
|
|
193bcb362e | ||
|
|
35bad376b8 | ||
|
|
6b7736ea58 | ||
|
|
7ff20e5b68 | ||
|
|
296accd1cb | ||
|
|
2665e5ebf3 | ||
|
|
0fe512746b | ||
|
|
4e2303f455 | ||
|
|
483093a104 | ||
|
|
8d348e7f88 | ||
|
|
6f9ff9d624 | ||
|
|
7bf65783be | ||
|
|
dd9fb01359 | ||
|
|
da8a725b32 | ||
|
|
6008d59c9f | ||
|
|
5aa6c0609d | ||
|
|
0ebe113abb | ||
|
|
1723f73b5d | ||
|
|
8d8df3b635 | ||
|
|
29c91769a7 | ||
|
|
2c47c68a8b | ||
|
|
ceff817d88 | ||
|
|
ea1032e184 | ||
|
|
2856deb4f4 | ||
|
|
4d13acc9dd | ||
|
|
1d21196060 | ||
|
|
ebb214f290 | ||
|
|
c9ec97f6b6 | ||
|
|
7e8077714e | ||
|
|
8e9eedfd16 | ||
|
|
c7fd3e208a | ||
|
|
5d52e42887 | ||
|
|
57b2485487 | ||
|
|
49c33b9b0f | ||
|
|
cbd47596bf | ||
|
|
99827fb776 | ||
|
|
19f8d5bc51 | ||
|
|
a88d3cd224 | ||
|
|
4d629bfd66 | ||
|
|
93c0e33dac | ||
|
|
c595451548 | ||
|
|
078b250fbd | ||
|
|
d31d07ef87 | ||
|
|
05b20ec74c | ||
|
|
1e4bb9ef8a | ||
|
|
da67da70ee | ||
|
|
f4795ff3f1 | ||
|
|
35e3aead0f | ||
|
|
afad580452 | ||
|
|
41dbcb67c9 | ||
|
|
3dcb4ec320 | ||
|
|
a4b659c965 | ||
|
|
939028fadd | ||
|
|
b1345a4662 | ||
|
|
26ae750219 | ||
|
|
ac16e53098 | ||
|
|
19824a176a | ||
|
|
eec1f36c44 | ||
|
|
7ef86e3948 | ||
|
|
4c723015ed | ||
|
|
a467e2925c | ||
|
|
6b6258aa5e | ||
|
|
d071be1c8a | ||
|
|
47e3d30870 | ||
|
|
b3278290c2 | ||
|
|
66a9e5ff52 | ||
|
|
b0b1527247 | ||
|
|
aa1af65807 | ||
|
|
da11c1cb5e | ||
|
|
784c0d460a | ||
|
|
19dbd05783 | ||
|
|
a358e8cfd5 | ||
|
|
b8116a1cf0 | ||
|
|
3563a9e67e | ||
|
|
088fce90a4 | ||
|
|
9ee8cfb4bc | ||
|
|
325baf7047 | ||
|
|
a667920bb0 | ||
|
|
53fe8efa02 | ||
|
|
e0a00f6b05 | ||
|
|
1ab901d746 | ||
|
|
18d3f3c850 |
@@ -42,7 +42,7 @@ when:
|
||||
|
||||
steps:
|
||||
- name: fetch-alerts
|
||||
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-08-10
|
||||
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-09-08
|
||||
environment:
|
||||
SOPS_AGE_KEY:
|
||||
from_secret: sops_age_key
|
||||
|
||||
+131
-12
@@ -19,12 +19,13 @@
|
||||
# the admin account could (#330).
|
||||
#
|
||||
# The token is still OWNED by gitea_admin, because Gitea scopes packages to
|
||||
# a user or org namespace and these images live under `gitea_admin/*` — a
|
||||
# different account cannot publish there. Moving them to a dedicated org is
|
||||
# the remaining cleanup; it is a 38-reference migration across pipelines and
|
||||
# Flux-deployed CronJobs, so it is tracked separately in #330 rather than
|
||||
# bundled here. Note this pipeline runs no LLM agent, so the exfil path that
|
||||
# motivated the other cutovers does not apply to it.
|
||||
# a user or org namespace and most of these images live under `gitea_admin/*`.
|
||||
# The rebuild-critical ones additionally publish to `konairius/` — an org
|
||||
# created for the ghcr mirror path, with gitea_admin as a member so this same
|
||||
# token can push there. The full gitea_admin/* -> org migration is a
|
||||
# 38-reference change tracked separately in #330 rather than bundled here.
|
||||
# Note this pipeline runs no LLM agent, so the exfil path that motivated the
|
||||
# other cutovers does not apply to it.
|
||||
when:
|
||||
- event: push
|
||||
path:
|
||||
@@ -87,6 +88,30 @@ steps:
|
||||
--destination gitea.renner.uno/gitea_admin/ci-k8s:$TAG \
|
||||
--destination gitea.renner.uno/gitea_admin/ci-k8s:latest
|
||||
|
||||
- name: build-vmtest
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
|
||||
directory: ci/images/vmtest
|
||||
depends_on: []
|
||||
when:
|
||||
- event: deployment
|
||||
- event: push
|
||||
path: ["ci/images/vmtest/**", ".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
|
||||
- TAG=$(date +%Y-%m-%d)
|
||||
- |
|
||||
/kaniko/executor \
|
||||
--context . \
|
||||
--dockerfile Containerfile \
|
||||
--destination gitea.renner.uno/gitea_admin/ci-vmtest:$TAG \
|
||||
--destination gitea.renner.uno/gitea_admin/ci-vmtest:latest
|
||||
|
||||
- name: build-tofu
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
|
||||
directory: ci/images/tofu
|
||||
@@ -147,6 +172,28 @@ steps:
|
||||
# not for who uses it — its first consumer is the zigbee2mqtt seed-data init
|
||||
# container, but nothing about it is zigbee-specific. Exists so tools come from
|
||||
# the image rather than an `apk add` on every container start.
|
||||
#
|
||||
# Published THREE times, and the third one is not a typo. Its consumers — the
|
||||
# zigbee2mqtt seed-data init container and the trmnl renderer — start while
|
||||
# the cluster is still being rebuilt, and at that moment gitea.renner.uno does
|
||||
# not exist, because Gitea runs inside the cluster being built. A cold-rebuild
|
||||
# rehearsal caught both in ImagePullBackOff.
|
||||
#
|
||||
# So there is a copy on ghcr.io. But the manifests must NOT point at it:
|
||||
# normal operation pulls from the local registry, and an external reference
|
||||
# would make every steady-state pull leave the network. The recovery source
|
||||
# and the everyday source are different things, exactly as they are for Flux's
|
||||
# git source.
|
||||
#
|
||||
# Talos resolves that with machine.registries.mirrors, which takes an ordered
|
||||
# endpoint list — gitea first, ghcr second (bootstrap.sh). Containerd keeps
|
||||
# the repository path when it falls through, so the two registries have to
|
||||
# agree on it: `gitea_admin/alpine-jq` cannot be served by ghcr, where the
|
||||
# namespace is fixed to the account name. Hence the second Gitea destination
|
||||
# under konairius/, which the manifests reference and which matches the ghcr
|
||||
# path exactly.
|
||||
#
|
||||
# The gitea_admin/ copy stays until nothing references it any more.
|
||||
- name: build-alpine-jq
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
|
||||
directory: ci/images/alpine-jq
|
||||
@@ -159,23 +206,47 @@ steps:
|
||||
GITEA_USER: gitea_admin
|
||||
GITEA_TOKEN:
|
||||
from_secret: ci_images_token
|
||||
GHCR_USER: konairius
|
||||
GHCR_TOKEN:
|
||||
from_secret: ghcr_mirror_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
|
||||
- GH_AUTH=$(printf '%s:%s' "$GHCR_USER" "$GHCR_TOKEN" | base64 | tr -d '\n')
|
||||
- printf '{"auths":{"gitea.renner.uno":{"auth":"%s"},"ghcr.io":{"auth":"%s"}}}' "$AUTH" "$GH_AUTH" > /kaniko/.docker/config.json
|
||||
- TAG=$(date +%Y-%m-%d)
|
||||
- |
|
||||
/kaniko/executor \
|
||||
--context . \
|
||||
--dockerfile Containerfile \
|
||||
--destination gitea.renner.uno/gitea_admin/alpine-jq:$TAG \
|
||||
--destination gitea.renner.uno/gitea_admin/alpine-jq:latest
|
||||
--destination gitea.renner.uno/gitea_admin/alpine-jq:latest \
|
||||
--destination gitea.renner.uno/konairius/alpine-jq:$TAG \
|
||||
--destination gitea.renner.uno/konairius/alpine-jq:latest \
|
||||
--destination ghcr.io/konairius/alpine-jq:$TAG \
|
||||
--destination ghcr.io/konairius/alpine-jq:latest
|
||||
|
||||
# Runtime image, not CI tooling: upstream containernetworking/plugins binaries
|
||||
# baked in, consumed by the multus cni-plugins-installer DaemonSet. Replaces a
|
||||
# node-side fetch from GitHub that had NO checksum verification and sat on the
|
||||
# critical path of every node rebuild. The download now happens here, once,
|
||||
# where the sha256 is checked — see the Containerfile for the full rationale.
|
||||
#
|
||||
# This one ALSO publishes to ghcr.io. A cold-rebuild rehearsal proved why: the
|
||||
# DaemonSet could not pull from gitea.renner.uno, because Gitea runs inside the
|
||||
# very cluster being rebuilt. Without the plugins there is no macvlan, so
|
||||
# Multus cannot attach ceph-san, so Rook's network canary never starts, so the
|
||||
# CephCluster sits at "Configuring Ceph Mons" — and OpenBao waits forever for a
|
||||
# PVC that will never bind. Moving the download here fixed the integrity gap
|
||||
# but quietly swapped github.com on the critical path for something strictly
|
||||
# worse: a registry that by definition does not exist during a recovery. The
|
||||
# GHCR copy is public, so a bare node can pull it with no credential and no
|
||||
# Gitea.
|
||||
#
|
||||
# The test that proved this also proved the rule is not specific to this
|
||||
# image. alpine-jq and alpine-openssl mirror for the same reason — see the
|
||||
# note on build-alpine-jq. Anything an app needs while the cluster is still
|
||||
# being built cannot live only in that cluster's own registry.
|
||||
- name: build-cni-plugins
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
|
||||
directory: ci/images/cni-plugins
|
||||
@@ -188,23 +259,36 @@ steps:
|
||||
GITEA_USER: gitea_admin
|
||||
GITEA_TOKEN:
|
||||
from_secret: ci_images_token
|
||||
GHCR_USER: konairius
|
||||
GHCR_TOKEN:
|
||||
from_secret: ghcr_mirror_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
|
||||
- GH_AUTH=$(printf '%s:%s' "$GHCR_USER" "$GHCR_TOKEN" | base64 | tr -d '\n')
|
||||
- printf '{"auths":{"gitea.renner.uno":{"auth":"%s"},"ghcr.io":{"auth":"%s"}}}' "$AUTH" "$GH_AUTH" > /kaniko/.docker/config.json
|
||||
- TAG=$(date +%Y-%m-%d)
|
||||
- |
|
||||
/kaniko/executor \
|
||||
--context . \
|
||||
--dockerfile Containerfile \
|
||||
--destination gitea.renner.uno/gitea_admin/cni-plugins:$TAG \
|
||||
--destination gitea.renner.uno/gitea_admin/cni-plugins:latest
|
||||
--destination gitea.renner.uno/gitea_admin/cni-plugins:latest \
|
||||
--destination gitea.renner.uno/konairius/cni-plugins:$TAG \
|
||||
--destination gitea.renner.uno/konairius/cni-plugins:latest \
|
||||
--destination ghcr.io/konairius/cni-plugins:$TAG \
|
||||
--destination ghcr.io/konairius/cni-plugins:latest
|
||||
|
||||
# Runtime image, not CI tooling: alpine + the openssl CLI, for shell scripts
|
||||
# needing s_client/x509. Named for what it is; first consumer is the paperless
|
||||
# fetch-bridge-cert init container. Replaces an `apk add openssl` that ran on
|
||||
# every paperless pod start. The openssl CLI is genuinely absent from the alpine
|
||||
# base, so this one needs an image rather than a busybox substitute.
|
||||
#
|
||||
# Mirrored to ghcr.io for the reason given on build-alpine-jq: it is an init
|
||||
# container, so paperless does not start without it, and during a rebuild
|
||||
# gitea.renner.uno is not there to serve it. A cold-rebuild rehearsal left
|
||||
# paperless in Init:ErrImagePull on exactly this image.
|
||||
- name: build-alpine-openssl
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
|
||||
directory: ci/images/alpine-openssl
|
||||
@@ -217,17 +301,25 @@ steps:
|
||||
GITEA_USER: gitea_admin
|
||||
GITEA_TOKEN:
|
||||
from_secret: ci_images_token
|
||||
GHCR_USER: konairius
|
||||
GHCR_TOKEN:
|
||||
from_secret: ghcr_mirror_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
|
||||
- GH_AUTH=$(printf '%s:%s' "$GHCR_USER" "$GHCR_TOKEN" | base64 | tr -d '\n')
|
||||
- printf '{"auths":{"gitea.renner.uno":{"auth":"%s"},"ghcr.io":{"auth":"%s"}}}' "$AUTH" "$GH_AUTH" > /kaniko/.docker/config.json
|
||||
- TAG=$(date +%Y-%m-%d)
|
||||
- |
|
||||
/kaniko/executor \
|
||||
--context . \
|
||||
--dockerfile Containerfile \
|
||||
--destination gitea.renner.uno/gitea_admin/alpine-openssl:$TAG \
|
||||
--destination gitea.renner.uno/gitea_admin/alpine-openssl:latest
|
||||
--destination gitea.renner.uno/gitea_admin/alpine-openssl:latest \
|
||||
--destination gitea.renner.uno/konairius/alpine-openssl:$TAG \
|
||||
--destination gitea.renner.uno/konairius/alpine-openssl:latest \
|
||||
--destination ghcr.io/konairius/alpine-openssl:$TAG \
|
||||
--destination ghcr.io/konairius/alpine-openssl:latest
|
||||
|
||||
# Heating CAN readers: open3e (Vitocal/VX3) + E3onCAN (E380 meter), one image
|
||||
# run as two services on edge-1. Replaces pip-into-venv on the host, where an
|
||||
@@ -257,3 +349,30 @@ steps:
|
||||
--dockerfile Containerfile \
|
||||
--destination gitea.renner.uno/gitea_admin/heating-can:$TAG \
|
||||
--destination gitea.renner.uno/gitea_admin/heating-can:latest
|
||||
|
||||
# netbird-proxy-services: the controller that reconciles NetBird reverse-proxy
|
||||
# services (public Ingress hosts -> TLS passthrough -> Traefik ClusterIP).
|
||||
# Runtime artifact like heating-can; same registry so the audit tracks it.
|
||||
- name: build-netbird-proxy-services
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
|
||||
directory: ci/images/netbird-proxy-services
|
||||
depends_on: []
|
||||
when:
|
||||
- event: deployment
|
||||
- event: push
|
||||
path: ["ci/images/netbird-proxy-services/**", ".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
|
||||
- TAG=$(date +%Y-%m-%d)
|
||||
- |
|
||||
/kaniko/executor \
|
||||
--context . \
|
||||
--dockerfile Containerfile \
|
||||
--destination gitea.renner.uno/gitea_admin/netbird-proxy-services:$TAG \
|
||||
--destination gitea.renner.uno/gitea_admin/netbird-proxy-services:latest
|
||||
|
||||
@@ -60,7 +60,7 @@ steps:
|
||||
# run that never touched them. Tracked in #461.
|
||||
- name: kustomize-build
|
||||
depends_on: [] # see note above `steps:`
|
||||
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-08-10
|
||||
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-09-08
|
||||
commands:
|
||||
- |
|
||||
set -eu
|
||||
@@ -158,7 +158,7 @@ steps:
|
||||
# whenever it trails the deployed server. If you bump the server, bump here.
|
||||
- name: woodpecker-lint
|
||||
depends_on: [] # see note above `steps:`
|
||||
image: woodpeckerci/woodpecker-cli:v3.17.0-alpine
|
||||
image: woodpeckerci/woodpecker-cli:v3.18.0-alpine
|
||||
commands:
|
||||
- |
|
||||
set -eu
|
||||
@@ -246,7 +246,7 @@ steps:
|
||||
# this step fails permanently on its own documentation.
|
||||
- name: no-digest-pins
|
||||
depends_on: [] # see note above `steps:`
|
||||
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-08-10
|
||||
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-09-08
|
||||
commands:
|
||||
- |
|
||||
set -eu
|
||||
|
||||
@@ -4,7 +4,7 @@ when:
|
||||
|
||||
steps:
|
||||
- name: e2e
|
||||
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-08-10
|
||||
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-09-08
|
||||
environment:
|
||||
SOPS_AGE_KEY:
|
||||
from_secret: sops_age_key
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ steps:
|
||||
- sh scripts/ci/test-model-selection.sh
|
||||
|
||||
- name: e2e-test
|
||||
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-08-10
|
||||
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-09-08
|
||||
environment:
|
||||
SOPS_AGE_KEY:
|
||||
from_secret: sops_age_key
|
||||
|
||||
@@ -49,7 +49,7 @@ steps:
|
||||
# (the issue-pick logic needs jq, which only the ci-claude image has);
|
||||
# minting a 1h read-only token per run is cheap. Degrades to
|
||||
# snapshot-only if the ci-diagnostics ServiceAccount isn't reconciled.
|
||||
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-08-10
|
||||
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-09-08
|
||||
environment:
|
||||
SOPS_AGE_KEY:
|
||||
from_secret: sops_age_key
|
||||
|
||||
@@ -32,8 +32,13 @@ netbird_watchdog_fail_threshold: 3
|
||||
|
||||
# Minimum seconds between watchdog-issued restarts, so a genuinely broken
|
||||
# backend can't put the proxy into a restart loop. Shared by both the URL-probe
|
||||
# watchdog and the flood watchdog below.
|
||||
netbird_watchdog_cooldown_seconds: 600
|
||||
# watchdog and the flood watchdog below. One minute: the route-manager wedge
|
||||
# recurs on every routing-peer loss, and rolling node reboots lose one peer
|
||||
# every few minutes; a suppressed restart there leaves the wedge standing
|
||||
# until the URL probe's three rounds fire (3.5 min of 000 on every domain,
|
||||
# measured 2026-09-07). A minute still caps a loop at one restart per
|
||||
# probe interval.
|
||||
netbird_watchdog_cooldown_seconds: 60
|
||||
|
||||
# Log fingerprint of the reverse-proxy route-manager wedge, matched as a plain
|
||||
# substring in the proxy's docker logs by the flood watchdog. On this build the
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
FROM alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d
|
||||
|
||||
ARG TALOS_VERSION=v1.12.6
|
||||
ARG TALOS_VERSION=v1.13.9
|
||||
ARG VELERO_VERSION=v1.16.0
|
||||
ARG KUBECTL_VERSION=v1.36.2
|
||||
ARG FLUX_VERSION=2.9.2
|
||||
ARG KUBECTL_VERSION=v1.36.3
|
||||
ARG FLUX_VERSION=2.9.5
|
||||
|
||||
RUN apk add --no-cache \
|
||||
aws-cli \
|
||||
@@ -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 "4c41a3b10b075292e64b182283d558e9d8e935f43239de1ed4e4b14359593efb /usr/local/bin/talosctl" | sha256sum -c - \
|
||||
&& echo "7e1d4b7d5846964bdcf63a794e3c8161bb6ef2983d5ace58ea5322f3bf32a27e /usr/local/bin/talosctl" | sha256sum -c - \
|
||||
&& chmod +x /usr/local/bin/talosctl
|
||||
|
||||
RUN curl -sfL -o /usr/local/bin/kubectl \
|
||||
@@ -31,7 +31,7 @@ RUN curl -sfL -o /usr/local/bin/kubectl \
|
||||
|
||||
RUN curl -sfL -o /tmp/flux.tar.gz \
|
||||
"https://github.com/fluxcd/flux2/releases/download/v${FLUX_VERSION}/flux_${FLUX_VERSION}_linux_amd64.tar.gz" \
|
||||
&& echo "4092b367fd060097976fb7261601f79420ecf7266328417dfd8dfee27de0e6a3 /tmp/flux.tar.gz" | sha256sum -c - \
|
||||
&& echo "b853df82adfd7736f580692f9f734473d571606307139f8fd20c2a80dd1ff473 /tmp/flux.tar.gz" | sha256sum -c - \
|
||||
&& tar -xzf /tmp/flux.tar.gz -C /usr/local/bin flux \
|
||||
&& chmod +x /usr/local/bin/flux \
|
||||
&& rm -f /tmp/flux.tar.gz
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# netbird-proxy-services: reconciles NetBird reverse-proxy services (public
|
||||
# domains -> TLS passthrough -> Traefik ClusterIP) from Ingresses carrying the
|
||||
# public label. Standard library only, so the image is the interpreter plus one
|
||||
# file; runs unprivileged as its own user.
|
||||
FROM python:3.13-slim
|
||||
|
||||
RUN useradd --system --no-create-home --shell /usr/sbin/nologin controller
|
||||
COPY controller.py /opt/controller.py
|
||||
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
USER controller
|
||||
ENTRYPOINT ["python3", "/opt/controller.py"]
|
||||
@@ -0,0 +1,275 @@
|
||||
#!/usr/bin/env python3
|
||||
"""netbird-proxy-services: reconcile NetBird reverse-proxy services from Ingresses.
|
||||
|
||||
Source of truth: every Ingress carrying the public label (dns.renner.uno/public).
|
||||
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
|
||||
Traefik untouched, the proxy terminates nothing, and no domain resource is
|
||||
involved -- the routing peers stay ordinary IP routers. The target sends a
|
||||
PROXY protocol v2 header so Traefik (and CrowdSec behind its access log) sees
|
||||
the WAN client address instead of the routing peer's pod IP.
|
||||
|
||||
Ownership is derived from NetBird itself, not from local state: a service is
|
||||
managed iff its mode is "tls" and its target is this controller's subnet
|
||||
resource. Services that do not match (the hand-made HTTP/domain ones) are never
|
||||
touched; a desired host held by such a service is reported and skipped until
|
||||
that service is removed by hand.
|
||||
|
||||
Credentials: the NetBird PAT is read from OpenBao on every pass through the
|
||||
Kubernetes auth role, exactly like the setup-key rotator; it never lands in a
|
||||
Kubernetes Secret or in a log line.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
LOG = logging.getLogger("netbird-proxy-services")
|
||||
|
||||
NB = os.environ.get("NB_MANAGEMENT_URL", "https://netbird.renner.uno").rstrip("/")
|
||||
BAO_ADDR = os.environ.get("BAO_ADDR", "http://openbao.openbao.svc.cluster.local:8200").rstrip("/")
|
||||
BAO_ROLE = os.environ.get("BAO_ROLE", "netbird-proxy-services")
|
||||
BAO_PAT_PATH = os.environ.get("BAO_PAT_PATH", "homelab/data/infrastructure/secret-rotators-bootstrap/netbird")
|
||||
BAO_PAT_KEY = os.environ.get("BAO_PAT_KEY", "pat")
|
||||
SERVICE_CIDR = os.environ.get("SERVICE_CIDR", "10.96.0.0/12")
|
||||
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_LABEL = os.environ.get("PUBLIC_LABEL", "dns.renner.uno/public")
|
||||
LISTEN_PORT = int(os.environ.get("LISTEN_PORT", "443"))
|
||||
INTERVAL = int(os.environ.get("INTERVAL_SECONDS", "60"))
|
||||
HTTP_TIMEOUT = 20
|
||||
|
||||
K8S_API = "https://kubernetes.default.svc"
|
||||
K8S_TOKEN_FILE = "/var/run/secrets/kubernetes.io/serviceaccount/token"
|
||||
K8S_CA_FILE = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"
|
||||
|
||||
|
||||
class ApiError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def http(method, url, body=None, headers=None, cafile=None):
|
||||
data = None if body is None else json.dumps(body).encode()
|
||||
req = urllib.request.Request(url, data=data, method=method)
|
||||
req.add_header("Accept", "application/json")
|
||||
if data is not None:
|
||||
req.add_header("Content-Type", "application/json")
|
||||
for k, v in (headers or {}).items():
|
||||
req.add_header(k, v)
|
||||
ctx = None
|
||||
if cafile:
|
||||
import ssl
|
||||
ctx = ssl.create_default_context(cafile=cafile)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT, context=ctx) as resp:
|
||||
raw = resp.read()
|
||||
return json.loads(raw) if raw else None
|
||||
except urllib.error.HTTPError as e:
|
||||
detail = e.read().decode(errors="replace")[:300]
|
||||
raise ApiError(f"{method} {url} -> {e.code}: {detail}") from None
|
||||
|
||||
|
||||
# --- Kubernetes -------------------------------------------------------------
|
||||
|
||||
def k8s(path):
|
||||
with open(K8S_TOKEN_FILE) as f:
|
||||
token = f.read().strip()
|
||||
return http("GET", K8S_API + path, headers={"Authorization": f"Bearer {token}"}, cafile=K8S_CA_FILE)
|
||||
|
||||
|
||||
def desired_hosts():
|
||||
sel = urllib.parse.quote(f"{PUBLIC_LABEL}=true")
|
||||
ingresses = k8s(f"/apis/networking.k8s.io/v1/ingresses?labelSelector={sel}")
|
||||
hosts = {}
|
||||
for ing in ingresses.get("items", []):
|
||||
ref = f'{ing["metadata"]["namespace"]}/{ing["metadata"]["name"]}'
|
||||
for rule in ing.get("spec", {}).get("rules", []):
|
||||
host = rule.get("host")
|
||||
if not host or host.startswith("*"):
|
||||
continue
|
||||
hosts.setdefault(host, ref)
|
||||
return hosts
|
||||
|
||||
|
||||
def traefik_cluster_ip():
|
||||
svc = k8s(f"/api/v1/namespaces/{TRAEFIK_NS}/services/{TRAEFIK_SVC}")
|
||||
for ip in svc["spec"].get("clusterIPs", []):
|
||||
if ":" not in ip:
|
||||
return ip
|
||||
raise ApiError(f"{TRAEFIK_NS}/{TRAEFIK_SVC} has no IPv4 ClusterIP")
|
||||
|
||||
|
||||
# --- OpenBao ----------------------------------------------------------------
|
||||
|
||||
def netbird_pat():
|
||||
with open(K8S_TOKEN_FILE) as f:
|
||||
jwt = f.read().strip()
|
||||
login = http("POST", f"{BAO_ADDR}/v1/auth/kubernetes/login", {"role": BAO_ROLE, "jwt": jwt})
|
||||
bao_token = login["auth"]["client_token"]
|
||||
kv = http("GET", f"{BAO_ADDR}/v1/{BAO_PAT_PATH}", headers={"X-Vault-Token": bao_token})
|
||||
pat = kv["data"]["data"].get(BAO_PAT_KEY)
|
||||
if not pat:
|
||||
raise ApiError(f"OpenBao {BAO_PAT_PATH} has no key {BAO_PAT_KEY}")
|
||||
return pat
|
||||
|
||||
|
||||
# --- NetBird management -----------------------------------------------------
|
||||
|
||||
class NetBird:
|
||||
def __init__(self, pat):
|
||||
self.h = {"Authorization": f"Token {pat}"}
|
||||
|
||||
def get(self, path):
|
||||
return http("GET", f"{NB}/api/{path}", headers=self.h)
|
||||
|
||||
def list(self, path):
|
||||
# An empty collection comes back as an empty body / null, not [].
|
||||
return self.get(path) or []
|
||||
|
||||
def post(self, path, body):
|
||||
return http("POST", f"{NB}/api/{path}", body, headers=self.h)
|
||||
|
||||
def put(self, path, body):
|
||||
return http("PUT", f"{NB}/api/{path}", body, headers=self.h)
|
||||
|
||||
def delete(self, path):
|
||||
return http("DELETE", f"{NB}/api/{path}", headers=self.h)
|
||||
|
||||
|
||||
def group_id(nb, name):
|
||||
for g in nb.list("groups"):
|
||||
if g["name"] == name:
|
||||
return g["id"]
|
||||
raise ApiError(f"NetBird group {name!r} not found")
|
||||
|
||||
|
||||
def ensure_network(nb):
|
||||
for n in nb.list("networks"):
|
||||
if n["name"] == NETWORK_NAME:
|
||||
return n["id"]
|
||||
n = nb.post("networks", {"name": NETWORK_NAME,
|
||||
"description": "managed by netbird-proxy-services; public ingress via TLS passthrough"})
|
||||
LOG.info("created network %s (%s)", NETWORK_NAME, n["id"])
|
||||
return n["id"]
|
||||
|
||||
|
||||
def ensure_resource(nb, net):
|
||||
for r in nb.list(f"networks/{net}/resources"):
|
||||
if r.get("address") == SERVICE_CIDR:
|
||||
return r["id"]
|
||||
r = nb.post(f"networks/{net}/resources", {"name": "cluster service CIDR", "address": SERVICE_CIDR,
|
||||
"description": "managed by netbird-proxy-services",
|
||||
"enabled": True, "groups": []})
|
||||
LOG.info("created subnet resource %s (%s)", SERVICE_CIDR, r["id"])
|
||||
return r["id"]
|
||||
|
||||
|
||||
def ensure_router(nb, net, router_group):
|
||||
for r in nb.list(f"networks/{net}/routers"):
|
||||
if router_group in (r.get("peer_groups") or []):
|
||||
return r["id"]
|
||||
r = nb.post(f"networks/{net}/routers", {"peer_groups": [router_group], "metric": 9999,
|
||||
"masquerade": True, "enabled": True})
|
||||
LOG.info("created router on group %s (%s)", ROUTER_GROUP, r["id"])
|
||||
return r["id"]
|
||||
|
||||
|
||||
def ensure_policy(nb, resource, source_group):
|
||||
name = f"{NETWORK_NAME} access"
|
||||
for p in nb.list("policies"):
|
||||
if p["name"] == name:
|
||||
return p["id"]
|
||||
p = nb.post("policies", {"name": name, "enabled": True, "rules": [{
|
||||
"name": name, "enabled": True, "action": "accept", "protocol": "tcp",
|
||||
"ports": [str(LISTEN_PORT)], "bidirectional": False, "sources": [source_group],
|
||||
"destinationResource": {"id": resource, "type": "subnet"}}]})
|
||||
LOG.info("created policy %s (%s)", name, p["id"])
|
||||
return p["id"]
|
||||
|
||||
|
||||
def service_body(host, resource, target_ip):
|
||||
# proxy_protocol: PROXY v2 header carries the WAN client address to
|
||||
# Traefik (proxyProtocol.trustedIPs on websecure); TCP/TLS modes only.
|
||||
return {"name": host, "domain": host, "mode": "tls", "listen_port": LISTEN_PORT, "enabled": True,
|
||||
"targets": [{"target_type": "subnet", "target_id": resource, "protocol": "tcp",
|
||||
"host": target_ip, "port": 443, "enabled": True,
|
||||
"options": {"proxy_protocol": True}}]}
|
||||
|
||||
|
||||
def is_managed(svc, resource):
|
||||
return svc.get("mode") == "tls" and any(t.get("target_id") == resource for t in svc.get("targets") or [])
|
||||
|
||||
|
||||
def target_matches(svc, target_ip):
|
||||
if svc.get("listen_port") != LISTEN_PORT or not svc.get("enabled"):
|
||||
return False
|
||||
ts = svc.get("targets") or []
|
||||
return len(ts) == 1 and ts[0].get("host") == target_ip and ts[0].get("port") == 443 \
|
||||
and ts[0].get("protocol") == "tcp" and ts[0].get("enabled") \
|
||||
and (ts[0].get("options") or {}).get("proxy_protocol") is True
|
||||
|
||||
|
||||
def reconcile():
|
||||
hosts = desired_hosts()
|
||||
target_ip = traefik_cluster_ip()
|
||||
nb = NetBird(netbird_pat())
|
||||
|
||||
router_group = group_id(nb, ROUTER_GROUP)
|
||||
source_group = group_id(nb, SOURCE_GROUP)
|
||||
net = ensure_network(nb)
|
||||
resource = ensure_resource(nb, net)
|
||||
ensure_router(nb, net, router_group)
|
||||
ensure_policy(nb, resource, source_group)
|
||||
|
||||
by_domain = {s["domain"]: s for s in nb.list("reverse-proxies/services")}
|
||||
created = updated = deleted = held = 0
|
||||
|
||||
for host, ref in sorted(hosts.items()):
|
||||
svc = by_domain.get(host)
|
||||
if svc is None:
|
||||
nb.post("reverse-proxies/services", service_body(host, resource, target_ip))
|
||||
LOG.info("created %s (from %s) -> %s:443", host, ref, target_ip)
|
||||
created += 1
|
||||
elif not is_managed(svc, resource):
|
||||
LOG.warning("%s is held by unmanaged service %s (mode %s); remove it to migrate",
|
||||
host, svc["id"], svc.get("mode"))
|
||||
held += 1
|
||||
elif not target_matches(svc, target_ip):
|
||||
nb.put(f"reverse-proxies/services/{svc['id']}", service_body(host, resource, target_ip))
|
||||
LOG.info("updated %s -> %s:443", host, target_ip)
|
||||
updated += 1
|
||||
|
||||
for domain, svc in by_domain.items():
|
||||
if is_managed(svc, resource) and domain not in hosts:
|
||||
nb.delete(f"reverse-proxies/services/{svc['id']}")
|
||||
LOG.info("deleted %s (no public Ingress carries it any more)", domain)
|
||||
deleted += 1
|
||||
|
||||
LOG.info("reconciled: %d hosts desired, %d created, %d updated, %d deleted, %d held by unmanaged services",
|
||||
len(hosts), created, updated, deleted, held)
|
||||
|
||||
|
||||
def main():
|
||||
logging.basicConfig(level=os.environ.get("LOG_LEVEL", "INFO"), stream=sys.stdout,
|
||||
format="%(asctime)s %(levelname)s %(message)s")
|
||||
if os.environ.get("ONCE") == "1":
|
||||
reconcile()
|
||||
return
|
||||
while True:
|
||||
try:
|
||||
reconcile()
|
||||
except Exception as e: # keep running; the next pass retries
|
||||
LOG.error("reconcile failed: %s", e)
|
||||
time.sleep(INTERVAL)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,84 @@
|
||||
FROM alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d
|
||||
|
||||
# Everything `bootstrap.sh vmtest` needs, so the rebuild test can run as a Job in
|
||||
# the cluster instead of on a workstation. The workstation has 31G and the test
|
||||
# wants ~14.5G for four VMs — a single run left under 1G headroom, and two
|
||||
# overlapping runs OOM-killed the session. The nodes have ~94G with 70G free.
|
||||
#
|
||||
# Separate from ci-k8s rather than built on it: that image pins talosctl v1.12.6,
|
||||
# and this test creates a cluster at the version talos/patches/cluster.yaml
|
||||
# installs. A talosctl older than the Talos it provisions is exactly the kind of
|
||||
# skew the test exists to catch, so it must not be baked in here.
|
||||
#
|
||||
# KEEP TALOS_VERSION IN STEP with the installer image tag in
|
||||
# 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.9
|
||||
ARG KUBECTL_VERSION=v1.36.2
|
||||
ARG FLUX_VERSION=2.9.2
|
||||
ARG KOPIA_VERSION=0.23.1
|
||||
|
||||
# qemu-system-x86_64 and qemu-img are the provisioner's actual backend, and ovmf
|
||||
# supplies the UEFI firmware those VMs boot from — without it the provisioner
|
||||
# stops with "the required flash image was not found in any of the expected
|
||||
# paths", having already downloaded the ISO and generated the PKI. Note the
|
||||
# firmware comes from ovmf specifically: qemu-system-x86_64 ships an edk2 CODE
|
||||
# image but no VARS, which is the half the provisioner needs.
|
||||
#
|
||||
# iptables is shelled out to by the CNI plugins that build the VM network; the
|
||||
# provisioner fails with "error accessing iptables" without it. The rest is
|
||||
# what the phase shells out to: helm for the Cilium install, yq to read the
|
||||
# HelmRelease and the installer image, jq for OpenBao's seal status.
|
||||
#
|
||||
# Versions come from the digest-pinned base rather than being pinned one by one,
|
||||
# the same way ci-ansible does it.
|
||||
RUN apk add --no-cache \
|
||||
bash \
|
||||
ca-certificates \
|
||||
curl \
|
||||
git \
|
||||
helm \
|
||||
iptables \
|
||||
jq \
|
||||
openssl \
|
||||
ovmf \
|
||||
qemu-img \
|
||||
qemu-system-x86_64 \
|
||||
yq
|
||||
|
||||
RUN curl -sfL -o /usr/local/bin/talosctl \
|
||||
"https://github.com/siderolabs/talos/releases/download/${TALOS_VERSION}/talosctl-linux-amd64" \
|
||||
&& echo "7e1d4b7d5846964bdcf63a794e3c8161bb6ef2983d5ace58ea5322f3bf32a27e /usr/local/bin/talosctl" | sha256sum -c - \
|
||||
&& chmod +x /usr/local/bin/talosctl
|
||||
|
||||
RUN curl -sfL -o /usr/local/bin/kubectl \
|
||||
"https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl" \
|
||||
&& curl -sfL -o /tmp/kubectl.sha256 \
|
||||
"https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl.sha256" \
|
||||
&& echo "$(cat /tmp/kubectl.sha256) /usr/local/bin/kubectl" | sha256sum -c - \
|
||||
&& rm -f /tmp/kubectl.sha256 \
|
||||
&& chmod +x /usr/local/bin/kubectl
|
||||
|
||||
RUN curl -sfL -o /tmp/flux.tar.gz \
|
||||
"https://github.com/fluxcd/flux2/releases/download/v${FLUX_VERSION}/flux_${FLUX_VERSION}_linux_amd64.tar.gz" \
|
||||
&& echo "4092b367fd060097976fb7261601f79420ecf7266328417dfd8dfee27de0e6a3 /tmp/flux.tar.gz" | sha256sum -c - \
|
||||
&& tar -xzf /tmp/flux.tar.gz -C /usr/local/bin flux \
|
||||
&& chmod +x /usr/local/bin/flux \
|
||||
&& rm -f /tmp/flux.tar.gz
|
||||
|
||||
# kopia reads the Velero backup repository directly, which is what the OpenBao
|
||||
# restore does when there is no Velero yet to do it: the credentials Velero
|
||||
# needs live in OpenBao, so OpenBao cannot be restored by Velero. Its absence
|
||||
# was found by a rebuild rehearsal reaching the restore for the first time and
|
||||
# stopping at "kopia not found".
|
||||
#
|
||||
# Same checksum-verified download as the tools above; the tarball unpacks into
|
||||
# a versioned directory, so the binary is pulled out by name.
|
||||
RUN curl -sfL -o /tmp/kopia.tar.gz \
|
||||
"https://github.com/kopia/kopia/releases/download/v${KOPIA_VERSION}/kopia-${KOPIA_VERSION}-linux-x64.tar.gz" \
|
||||
&& echo "416d0f84a3dbb321a8b2d8f0997b1a0a6e915babe79ee76fa6e4d2bd1e1c5178 /tmp/kopia.tar.gz" | sha256sum -c - \
|
||||
&& tar -xzf /tmp/kopia.tar.gz -C /tmp \
|
||||
&& mv "/tmp/kopia-${KOPIA_VERSION}-linux-x64/kopia" /usr/local/bin/kopia \
|
||||
&& chmod +x /usr/local/bin/kopia \
|
||||
&& rm -rf /tmp/kopia.tar.gz "/tmp/kopia-${KOPIA_VERSION}-linux-x64"
|
||||
@@ -50,15 +50,18 @@ pass insert homelab/openbao/root-token
|
||||
kubectl -n openbao exec -it openbao-0 -- bao operator unseal # paste key
|
||||
```
|
||||
|
||||
**Manual unseal is required after every pod restart** (node drain, upgrade,
|
||||
crash), **per replica** — the server runs three raft peers (see
|
||||
`infrastructure/openbao/helmrelease.yaml`), and a sealed one is neither a
|
||||
voter nor a standby, so it stays out of the cluster until unsealed:
|
||||
`kubectl -n openbao exec -it openbao-<N> -- bao operator unseal`, same key for
|
||||
all three. Three peers mean one sealed replica is survivable; two are not.
|
||||
Accepted for now; auto-unseal needs a second trust root (transit/KMS) and is
|
||||
explicitly out of scope. Symptom of sealed: ESO stores go NotReady, synced
|
||||
Secrets keep their last value (they are real k8s Secrets — pods keep running).
|
||||
**Unseal is automatic since 2026-09-08** (static seal, PR #825, issue #805):
|
||||
the seal key lives in the `openbao-seal-key` Secret (created by `bootstrap.sh`
|
||||
from `pass show homelab/openbao/seal-key`) and every restarted replica unseals
|
||||
itself. The shamir key above became the **recovery key** in the migration; it
|
||||
is kept in `pass` under its old name, `homelab/openbao/unseal-key`. The server
|
||||
runs three raft peers (see `infrastructure/openbao/helmrelease.yaml`); a sealed
|
||||
one is neither a voter nor a standby, so a replica that stays sealed after a
|
||||
restart — wrong or missing seal-key Secret — costs redundancy, two cost
|
||||
quorum. The trade-off taken with the static seal: the key that unlocks the
|
||||
store sits in the same etcd as the Secrets ESO already syncs there in
|
||||
plaintext. Symptom of sealed: ESO stores go NotReady, synced Secrets keep
|
||||
their last value (they are real k8s Secrets — pods keep running).
|
||||
|
||||
## Phase 2 — configure + ClusterSecretStore (follow-up PR)
|
||||
|
||||
@@ -234,16 +237,19 @@ one full reconcile + one OpenBao pod restart/unseal cycle:
|
||||
- **Backups:** ✅ nightly raft-snapshot CronJob (03:00, keeps 7 on a
|
||||
dedicated PVC; see infrastructure/openbao/snapshot-cronjob.yaml) + velero
|
||||
`openbao-daily` Schedule (06:15) ships that PVC offsite, 30 d retention.
|
||||
A raft snapshot restore needs the matching unseal key — which lives in
|
||||
A raft snapshot restore needs the matching seal key — which lives in
|
||||
`pass`, outside the cluster (GPG key backed up externally 2026-07-14).
|
||||
A failed snapshot Job is the sealed-after-restart alarm. Still owed:
|
||||
one tested restore before phase 4.
|
||||
- **Cluster rebuild order:** Flux brings up openbao (sealed, empty) → restore
|
||||
raft snapshot → unseal (key from `pass`) → ClusterSecretStore Ready → ESO
|
||||
materializes Secrets → apps start. Until unseal, secret-consuming apps
|
||||
simply stay Pending on missing Secrets — same as any missing dependency.
|
||||
- **Break-glass:** `pass` holds unseal key + root token (GPG, offline-capable).
|
||||
With those two, everything is recoverable from a raft snapshot alone.
|
||||
- **Cluster rebuild order:** seal-key Secret from `pass` → Flux brings up
|
||||
openbao (empty) → init → restore raft snapshot → replicas unseal themselves
|
||||
→ ClusterSecretStore Ready → ESO materializes Secrets → apps start
|
||||
(`bootstrap.sh flux` does all of it; RUNBOOK-cold-rebuild.md Step 4 by
|
||||
hand). Until then, secret-consuming apps simply stay Pending on missing
|
||||
Secrets — same as any missing dependency.
|
||||
- **Break-glass:** `pass` holds seal key, recovery key + root token (GPG,
|
||||
offline-capable). With those, everything is recoverable from a raft
|
||||
snapshot alone.
|
||||
- **Ansible Vault password:** `ansible/vault-password.sh` reads
|
||||
`ANSIBLE_VAULT_PASSWORD` from the environment with no offline backup as of
|
||||
2026-07-14. It gates decryption of several Ansible-Vault files (PBS
|
||||
|
||||
@@ -1,194 +1,103 @@
|
||||
# Proposal — separate the four traffic classes onto their own VLANs
|
||||
|
||||
Status: draft. The question this started with is **answered** — from the Cilium
|
||||
source and flag reference, not from a test — and the answer changes the shape
|
||||
of the proposal rather than confirming it.
|
||||
Status: open, issue #576. Two of the four classes are separated. Pod east-west
|
||||
cannot leave the node IP under Cilium. Both ways of moving it within Cilium
|
||||
(options A and B below) are rejected by the owner, and replacing the CNI
|
||||
(option C) is shelved.
|
||||
|
||||
Supersedes the framing in issue #576, which described this as "give node
|
||||
management its own NIC, and Ceph public its own VLAN". That was two of four
|
||||
classes and one of them turned out already solved.
|
||||
## Target
|
||||
|
||||
## The four classes
|
||||
Set by the owner on 2026-09-08:
|
||||
|
||||
| class | what it is | where it goes today |
|
||||
| class | link |
|
||||
|---|---|
|
||||
| Ceph replication | X710 p1, VLAN 49 |
|
||||
| pod-pod | X710 trunk, a VLAN of its own |
|
||||
| world-pod | X710 trunk, a VLAN of its own |
|
||||
| node management | 1 Gb onboard NIC |
|
||||
|
||||
Hard constraint: inter-node and pod traffic never traverses the UDM. It stays
|
||||
on the 10G switch.
|
||||
|
||||
## Where the classes are
|
||||
|
||||
Measured 2026-09-15.
|
||||
|
||||
| class | today | state |
|
||||
|---|---|---|
|
||||
| **Ceph replication** | OSD-to-OSD heartbeat, replication, recovery | VLAN 49, its own X710 port — **separated** |
|
||||
| **cluster-internal** | pod-to-pod east-west, including Ceph client I/O | pod network, VXLAN over `mgmt0` |
|
||||
| **cluster-external** | ingress to LoadBalancer VIPs, egress to LAN and internet | `mgmt0` |
|
||||
| **management** | Talos API, etcd, kube-apiserver, kubelet | `mgmt0` |
|
||||
| Ceph replication | `ceph-san`, VLAN 49, handed whole to the OSD pod (Multus `host-device`) | done |
|
||||
| world→pod | BGP from each node's `ingress0` (VLAN 47) to `fd02:47::1` / `10.2.47.1`; LB VIPs and PodCIDRs are advertised with that next-hop | done (#835, #836) |
|
||||
| pod→world | default routes via `mgmt0`: v4 metric 1024 against `ingress0`'s 2048, v6 from SLAAC on `mgmt0`; `devices` unset | open |
|
||||
| pod-pod | native routing, `<PodCIDR> via <peer InternalIP> dev mgmt0`, VLAN 40 | blocked, see below |
|
||||
| management | `mgmt0`, VLAN 40 | not started |
|
||||
|
||||
Three of four share one interface. That is not a design decision — until the
|
||||
bare-metal conversion there was one NIC to work with.
|
||||
`ingress0` (VLAN 47) and `pod0` (VLAN 48) exist on all three nodes (#831,
|
||||
#833). `pod0` holds addresses and carries no traffic.
|
||||
|
||||
Ceph client I/O living on the pod network is correct rather than a leftover:
|
||||
that traffic *is* cluster-internal. Ceph is done.
|
||||
## Why pod-pod is blocked
|
||||
|
||||
## Principle
|
||||
Cilium's route to a peer's pods uses the peer's Kubernetes InternalIP as the
|
||||
next-hop, in native mode, and as the VXLAN endpoint in tunnel mode:
|
||||
|
||||
**Each class gets its own VLAN. Which physical interface a VLAN lands on is a
|
||||
separate, changeable decision.**
|
||||
- `pkg/datapath/linux/node.go`: `installDirectRoute(…, nodeIP net.IP, …)` is
|
||||
fed from `newNode.GetNodeIP(false)`.
|
||||
- `pkg/node/types/node.go`: `GetNodeIP` prefers `NodeInternalIP`.
|
||||
- `pkg/node/sync/local_node_sync.go`: `initFromK8s()` runs after
|
||||
`initFromConfig()` and overwrites `--ipv4-node` / `--ipv6-node` from the
|
||||
Node object.
|
||||
- `--direct-routing-device` selects a device for the BPF NodePort path, not a
|
||||
next-hop.
|
||||
|
||||
Defining classes by VLAN means the separation survives recabling, port failures
|
||||
and capacity changes, and it does not require one port per class. Defining them
|
||||
by interface would freeze a capacity decision into the architecture.
|
||||
Tested on the cluster, none of these moved the next-hop:
|
||||
|
||||
## The finding that shapes everything else
|
||||
| attempt | result |
|
||||
|---|---|
|
||||
| `ipv4-node` / `ipv6-node` via `CiliumNodeConfig` | agent logged both flags, still published its `mgmt0` address |
|
||||
| `nodePort.directRoutingDevice: pod0` (#837) | agent logged `direct-routing-device=pod0`, still installed routes via `mgmt0` |
|
||||
| tunnel and native mode, v4 and v6 | endpoints and routes keyed to the peer's InternalIP |
|
||||
|
||||
The original open question was whether `nodePort.directRoutingDevice` moves the
|
||||
next-hop of Cilium's direct routes, or only selects an interface. It is
|
||||
answerable from the source, and the answer is: **only the interface.**
|
||||
The InternalIP comes from `machine.kubelet.nodeIP.validSubnets` in
|
||||
`talos/patches/site-homelab.yaml`, currently `10.2.40.0/24` and `fd40::/64`.
|
||||
Pod-pod traffic therefore sits on whichever VLAN holds the InternalIP. The
|
||||
cluster has been in native routing mode since #837. `directRoutingDevice: pod0`
|
||||
has no effect on pod-pod routing.
|
||||
|
||||
`installDirectRoute` takes the next-hop as a parameter:
|
||||
## Options for pod-pod
|
||||
|
||||
```go
|
||||
func installDirectRoute(log *slog.Logger, prefix netip.Prefix, nodeIP net.IP,
|
||||
skipUnreachable bool) (routeSpec *netlink.Route, err error)
|
||||
```
|
||||
|
||||
and `nodeUpdate` supplies it as `newNode.GetNodeIP(false)`. `GetNodeIP` returns,
|
||||
in priority order, `NodeInternalIP` → `NodeExternalIP` → anything else. The
|
||||
same `newIP4` is what tunnel mode uses as the VXLAN endpoint.
|
||||
|
||||
`NodeInternalIP` comes from the Kubernetes Node object. `--ipv4-node` looks like
|
||||
an override but is not one: `initFromConfig()` calls `SetNodeInternalIP` and
|
||||
`initFromK8s()` runs **after** it and overwrites the value. In a Kubernetes
|
||||
cluster there is no path by which Cilium's node IP differs from the kubelet's
|
||||
`InternalIP`.
|
||||
|
||||
`--direct-routing-device` is documented as "Device name used to connect nodes in
|
||||
direct routing mode" — a device, not an address.
|
||||
|
||||
**So cluster-internal traffic is welded to the node IP, and the node IP is the
|
||||
kubelet InternalIP.** Two consequences:
|
||||
|
||||
- The class cannot be moved off whatever VLAN the node IP lives on.
|
||||
- **Routing mode is irrelevant to this problem.** Tunnel mode sources the VXLAN
|
||||
from the node IP; native mode uses it as the direct-route next-hop. Native
|
||||
routing buys nothing here, so the disruptive, non-rolling step this proposal
|
||||
previously contained is simply gone.
|
||||
|
||||
## Consequence: draw the line the other way
|
||||
|
||||
If cluster-internal cannot leave the node IP, then separation has to come from
|
||||
moving *management services* off it — and each one has to be checked
|
||||
individually, because they do not share a mechanism.
|
||||
|
||||
| management service | can it leave the node IP? | lever |
|
||||
| | option | status |
|
||||
|---|---|---|
|
||||
| etcd | **yes** | `cluster.etcd.advertisedSubnets` (+ `listenSubnets`, which defaults to it) |
|
||||
| kube-apiserver endpoint | **yes** | already a VIP — `Layer2VIPConfig` with a `link:` field |
|
||||
| Talos API | **n/a** | `apid` answers on every node address (measured); a client and firewall choice, not a bind setting |
|
||||
| kubelet | **no** | it *is* the node IP, by definition |
|
||||
| A | move the node IP and etcd to `pod0` | **rejected** by the owner; #838 closed 2026-09-08 |
|
||||
| B | keep the node IP on VLAN 40, relabel VLAN 40 as the cluster VLAN, move etcd and the API VIP to a new management VLAN | **rejected** by the owner, repeatedly. Not to be proposed again |
|
||||
| C | replace Cilium with a CNI that picks the transport interface independently of the node IP (Antrea `transportInterface`) | **shelved** 2026-09-10; #839 closed. Needs a cold rebuild, rewriting the CiliumNetworkPolicy objects, and Antrea's BGP is alpha |
|
||||
|
||||
So four VLANs are reachable, but the boundary does not fall where the class
|
||||
names suggest: **kubelet stays with cluster-internal.** That is the honest
|
||||
result, and it is worth stating plainly rather than filing kubelet under
|
||||
management and hoping.
|
||||
|
||||
The remaining classes keep the levers established earlier:
|
||||
|
||||
- **cluster-external ingress** — `CiliumBGPNodeConfigOverride.spec.bgpInstances[].peers[].localAddress`.
|
||||
For eBGP the session source is the next-hop, so pinning the session pins
|
||||
where return traffic arrives. The session to the UDM has been running for
|
||||
months.
|
||||
- **cluster-external egress** — eBPF masquerading follows the output device, so
|
||||
this is a default-route decision plus `enable-masquerade-to-route-source`
|
||||
(today `false`).
|
||||
- **Ceph replication** — Multus macvlan on VLAN 49, done.
|
||||
|
||||
## The general escape hatch
|
||||
|
||||
Ceph is separated because its OSDs got a second interface and stopped using the
|
||||
pod network for replication. That is the *only* mechanism that fully detaches a
|
||||
workload from the node IP, and it generalises: anything that must not ride the
|
||||
node IP gets its own `NetworkAttachmentDefinition`.
|
||||
|
||||
It is also expensive per workload — an attachment, an IPAM pool, a pod spec
|
||||
change. Worth it for Ceph replication. Not worth it for east-west traffic in
|
||||
A per-workload Multus attachment detaches a single workload from the node IP,
|
||||
the way Ceph replication is detached. It does not cover east-west traffic in
|
||||
general.
|
||||
|
||||
## Recommended mapping
|
||||
## Levers per management service
|
||||
|
||||
Two ways to arrange this, and they differ only in which VLAN gets relabelled:
|
||||
These hold regardless of which option is chosen:
|
||||
|
||||
**A — move the node IP to a new cluster VLAN.** VLAN 40 keeps etcd, the API VIP
|
||||
and Talos API. Cleanest match to the class names, but it changes every node's
|
||||
identity, which is the one address the whole cluster is administered through.
|
||||
| service | can leave the node IP? | lever |
|
||||
|---|---|---|
|
||||
| etcd | yes | `cluster.etcd.advertisedSubnets` (+ `listenSubnets`, which defaults to it); needs a reboot per node |
|
||||
| kube-apiserver endpoint | yes | VIP, `Layer2VIPConfig` with a `link:` field |
|
||||
| Talos API | n/a | `apid` answers on every node address; a client and firewall choice |
|
||||
| kubelet | no | it is the node IP |
|
||||
|
||||
**B (recommended) — leave the node IP on VLAN 40 and move management off it.**
|
||||
VLAN 40 becomes the cluster VLAN; a new VLAN carries etcd and the API VIP.
|
||||
Identical end state, and no node ever changes its InternalIP.
|
||||
## Remaining, independent of pod-pod
|
||||
|
||||
Under B:
|
||||
- **pod→world egress**: default route and masquerade source onto `ingress0`,
|
||||
via route metrics, `devices` and `enable-masquerade-to-route-source`. This
|
||||
moves the nodes' own egress (image pulls, NTP) to VLAN 47 too.
|
||||
- **BGP rollback path**: the UDM still accepts sessions from VLAN 40; to be
|
||||
removed once ingress on VLAN 47 is settled.
|
||||
|
||||
| VLAN | carries |
|
||||
|---|---|
|
||||
| 49 | Ceph replication |
|
||||
| 40 (relabelled *cluster*) | pod east-west, kubelet |
|
||||
| new *management* | etcd, kube-apiserver VIP, Talos API by convention |
|
||||
| new *external* | LoadBalancer ingress, egress default route |
|
||||
## Sources
|
||||
|
||||
The property the user asked for — management carries only management traffic —
|
||||
holds under B. The label moves; the property is what matters.
|
||||
|
||||
## Migration shape
|
||||
|
||||
Every step is rolling and reversible. The routing-mode change is gone.
|
||||
|
||||
1. **New VLANs** on the switch, trunked to the X710. Nothing uses them.
|
||||
2. **Addresses** on the new VLANs via Talos, one node at a time, `--mode=try`.
|
||||
Still unused; this only proves the L2 works.
|
||||
3. **etcd** to the management VLAN via `advertisedSubnets`. One node at a time,
|
||||
waiting for a healthy quorum between each.
|
||||
4. **API VIP** to the management VLAN via `Layer2VIPConfig.link`. DNS for
|
||||
`k8s.renner.uno` follows.
|
||||
5. **cluster-external**: BGP `localAddress`, default route, masquerade flag.
|
||||
Reversible by reverting the override; failure is visible immediately in
|
||||
`cilium-dbg shell -- bgp/peers`.
|
||||
6. Verify VLAN 40 by what is *absent* from it, not by what still works.
|
||||
|
||||
Step 3 is the one that deserves care — etcd is the only step where a mistake
|
||||
costs quorum rather than connectivity.
|
||||
|
||||
## What this does not buy
|
||||
|
||||
Management currently shares a 10 Gb link and is not contended. Nothing observed
|
||||
today is slow because of this. The case for the split is that traffic classes
|
||||
with different failure modes and security postures should not be
|
||||
indistinguishable — not that something is broken.
|
||||
|
||||
## Sources and measurements
|
||||
|
||||
Source, Cilium v1.20.1 (the running version):
|
||||
|
||||
- `pkg/datapath/linux/node.go` — `installDirectRoute(…, nodeIP net.IP, …)`,
|
||||
fed from `newIP4 = newNode.GetNodeIP(false)`; `createDirectRouteSpec` rejects
|
||||
a next-hop that is not directly reachable.
|
||||
- `pkg/node/types/node.go` — `GetNodeIP` prefers `NodeInternalIP`.
|
||||
- `pkg/node/sync/local_node_sync.go` — `initFromConfig()` applies
|
||||
`--ipv4-node`, `initFromK8s()` runs afterwards and overwrites it from the
|
||||
Kubernetes Node.
|
||||
- `cilium-agent` flag reference — `--direct-routing-device`: "Device name used
|
||||
to connect nodes in direct routing mode".
|
||||
|
||||
Talos:
|
||||
|
||||
- Multihoming guide — `cluster.etcd.advertisedSubnets`; `listenSubnets`
|
||||
defaults to it. kube-apiserver and Talos API listen addresses are not
|
||||
covered there.
|
||||
- `kubernetes/talos/patches/k8s-{1,2,3}.yaml` — `Layer2VIPConfig` for
|
||||
`10.2.40.60` with `link: mgmt0`.
|
||||
|
||||
Measured on the cluster:
|
||||
|
||||
- `apid` answered on a node address that is not the InternalIP, so the Talos
|
||||
API is not bound to one address.
|
||||
- k8s-1 currently holds one routable v4 address plus the VIP on `mgmt0`; its
|
||||
InternalIP had changed since the last session, which is why nothing here
|
||||
names an address as fixed.
|
||||
- Cilium runs `routing-mode: tunnel` with `enable-masquerade-to-route-source`
|
||||
unset and no direct-routing device configured.
|
||||
|
||||
Not verified: whether the kube-apiserver's `--advertise-address` follows the
|
||||
node IP independently of the VIP. It does not affect the recommendation, since
|
||||
clients reach the API through the VIP either way.
|
||||
- Cilium v1.20.1 source files listed above; `cilium-agent` flag reference for
|
||||
`--direct-routing-device`.
|
||||
- Talos multihoming guide: `cluster.etcd.advertisedSubnets`, `listenSubnets`.
|
||||
- PRs #831, #833, #835, #836, #837, #838, #839 and the comments on #576.
|
||||
- Live state 2026-09-15: `kubectl get nodes -o wide`, `cilium-config`,
|
||||
`CiliumBGPNodeConfig` status, node routing tables on k8s-1 and k8s-2.
|
||||
|
||||
@@ -432,8 +432,8 @@ is why it belongs here.
|
||||
among its devices but not `ens19`, because `ens19` is deliberately
|
||||
address-less. Cilium therefore owns `mgmt0` and sees VLAN 40 frames already
|
||||
untagged by the 8021q layer, while VLAN 30 frames only ever meet the filter
|
||||
on the shared parent `enp2s0f0np0`, still tagged. Any future address-less
|
||||
VLAN carrying pod traffic will need the same treatment.
|
||||
on the shared parent `uplink`, still tagged. Any future address-less VLAN
|
||||
carrying pod traffic will need the same treatment.
|
||||
|
||||
The symptom does not look like a VLAN problem. A tc-layer drop reaches an
|
||||
AF_PACKET sender as ENOBUFS, so the CNI dhcp plugin reports `no buffer space
|
||||
|
||||
@@ -67,10 +67,12 @@ These must exist outside the cluster or the rebuild cannot complete:
|
||||
`flux` phase creates the `sops-age` Secret, and any not-yet-migrated sops
|
||||
files decrypt with it. (Post-OpenBao-phase-4 this may be retired — see
|
||||
`MIGRATION-sops-openbao.md`.)
|
||||
- [ ] **OpenBao unseal key + root token** — in the GPG `pass` store
|
||||
(`homelab/openbao/unseal-key`, `homelab/openbao/root-token`), and the GPG
|
||||
private key backed up off-machine. Without the unseal key, no OpenBao
|
||||
snapshot can be restored and no secret-consuming app can start.
|
||||
- [ ] **OpenBao seal key, recovery key + root token** — in the GPG `pass`
|
||||
store (`homelab/openbao/seal-key`, `homelab/openbao/unseal-key` — the
|
||||
former shamir key, now the recovery key — and `homelab/openbao/root-token`),
|
||||
and the GPG private key backed up off-machine. The seal key is the one
|
||||
the snapshots are encrypted under: without it, no OpenBao snapshot can be
|
||||
restored and no secret-consuming app can start.
|
||||
- [ ] **OpenBao raft snapshot** reachable — nightly PVC snapshots
|
||||
(`infrastructure/openbao/snapshot-cronjob.yaml`) plus the Velero
|
||||
`openbao-daily` offsite copy. In a total-loss rebuild the offsite Velero
|
||||
@@ -294,42 +296,65 @@ output** — a green step over a still-forming Ceph is a known trap.
|
||||
> Steady-state (non-rebuild) bootstrap is just `kubernetes/bootstrap.sh flux`
|
||||
> with `GITEA_TOKEN` set; `FLUX_SOURCE` defaults to `gitea`.
|
||||
|
||||
## Step 4 — openbao: restore the snapshot + unseal
|
||||
## Step 4 — openbao: restore the snapshot
|
||||
|
||||
After Step 3, OpenBao comes up **sealed and empty**. Until it is restored and
|
||||
unsealed, the `ClusterSecretStore` stays NotReady, ESO cannot materialize
|
||||
Secrets, and every secret-consuming app stays Pending. This step is **manual**
|
||||
(the unseal key lives in `pass`, off-cluster by design).
|
||||
`bootstrap.sh flux` does this step itself (`restore_openbao`): it creates the
|
||||
`openbao-seal-key` Secret from `pass` before Flux brings OpenBao up, initialises
|
||||
the empty server, restores the newest raft snapshot straight out of the Velero
|
||||
kopia repository, and waits until every replica has unsealed itself from the
|
||||
static seal. What follows is the same procedure by hand, for when the script
|
||||
is not an option.
|
||||
|
||||
OpenBao unseals itself from the static seal (`openbao-seal-key` Secret, key
|
||||
`key`, the 32-byte base64 key from `pass show homelab/openbao/seal-key`). The
|
||||
snapshots are encrypted under that key, so it must be the same key the backed
|
||||
up cluster used — a different one restores nothing. Until the snapshot is
|
||||
restored, the `ClusterSecretStore` stays NotReady, ESO cannot materialize
|
||||
Secrets, and every secret-consuming app stays Pending.
|
||||
|
||||
```sh
|
||||
# 1. Wait for the OpenBao pods to exist (all three sealed, 0/1 Ready is expected).
|
||||
kubectl -n openbao get pods
|
||||
# 1. The seal key, before the pods start (a pod without it never starts).
|
||||
kubectl create namespace openbao --dry-run=client -o yaml | kubectl apply -f -
|
||||
kubectl -n openbao create secret generic openbao-seal-key \
|
||||
--from-literal=key="$(pass show homelab/openbao/seal-key)"
|
||||
|
||||
# 2. Restore the most recent raft snapshot. Source it from the snapshot PVC
|
||||
# 2. Wait for openbao-0 (uninitialised: 0/1 Ready is expected), then
|
||||
# initialise it. With an auto-seal, init hands out recovery keys and the
|
||||
# server unseals itself. These keys are throwaway: the restore replaces them.
|
||||
kubectl -n openbao exec -it openbao-0 -- bao operator init \
|
||||
-recovery-shares=1 -recovery-threshold=1
|
||||
|
||||
# 3. Restore the most recent raft snapshot. Source it from the snapshot PVC
|
||||
# (infrastructure/openbao/snapshot-cronjob.yaml) or, in a total-loss rebuild,
|
||||
# from the Velero `openbao-daily` offsite copy. Copy it into the pod, then:
|
||||
# from the Velero `openbao-daily` offsite copy. Root token from the init
|
||||
# output. No -force: OpenBao checks the snapshot was written under this
|
||||
# server's seal key and refuses otherwise ("different autoseal key") — that
|
||||
# refusal is the signal the Secret is wrong. -force would skip the check and
|
||||
# leave a server that can never unseal.
|
||||
kubectl -n openbao cp <snapshot>.snap openbao-0:/tmp/restore.snap
|
||||
kubectl -n openbao exec -it openbao-0 -- bao operator raft snapshot restore /tmp/restore.snap
|
||||
kubectl -n openbao exec -it openbao-0 -- \
|
||||
env BAO_TOKEN=<init root token> bao operator raft snapshot restore /tmp/restore.snap
|
||||
|
||||
# 3. Unseal with the key from pass (threshold 1) — EVERY replica, same key.
|
||||
# A sealed server is neither a raft voter nor a standby, so unsealing only
|
||||
# openbao-0 leaves the three-peer cluster one node short of quorum.
|
||||
kubectl -n openbao exec -it openbao-0 -- bao operator unseal # paste homelab/openbao/unseal-key
|
||||
kubectl -n openbao exec -it openbao-1 -- bao operator unseal # same key
|
||||
kubectl -n openbao exec -it openbao-2 -- bao operator unseal # same key
|
||||
# 4. The server seals against the snapshot's keyring and unseals itself from
|
||||
# the static seal; openbao-1 and openbao-2 follow (OrderedReady). Nothing
|
||||
# to type. Afterwards the tokens and recovery key from pass apply again.
|
||||
kubectl -n openbao get pods -w
|
||||
```
|
||||
|
||||
Verify the store recovers before moving on:
|
||||
|
||||
```sh
|
||||
kubectl get clustersecretstore openbao # want: Valid / Ready
|
||||
kubectl -n <ns> get externalsecret # want: SecretSynced
|
||||
kubectl -n openbao exec openbao-0 -- bao status # want: Seal Type static, Sealed false
|
||||
kubectl get clustersecretstore openbao # want: Valid / Ready
|
||||
kubectl -n <ns> get externalsecret # want: SecretSynced
|
||||
```
|
||||
|
||||
> **Manual unseal is required after every OpenBao pod restart**, not just
|
||||
> during rebuilds, and for *each* replica that restarts — a rolling node
|
||||
> drain seals all three in turn. Details + rationale:
|
||||
> `MIGRATION-sops-openbao.md`.
|
||||
> A replica that stays **sealed** after a restart could not use the static
|
||||
> seal — the Secret is missing or does not match the key the data was
|
||||
> written under. Fix the Secret and restart the pod; `bao operator unseal`
|
||||
> with the recovery key (`pass show homelab/openbao/unseal-key`) does not
|
||||
> unseal an auto-sealed server and is only for recovery-mode operations.
|
||||
> Details + rationale: `MIGRATION-sops-openbao.md`.
|
||||
|
||||
## Step 5 — apps: let the rest reconcile
|
||||
|
||||
@@ -393,14 +418,15 @@ it: repo → **Settings → Repository → Mirror Settings** in Gitea, or
|
||||
- **`bootstrap.sh talos` is dead** — it cannot generate a config (#581), and
|
||||
Step 2 no longer uses it. Its `flux` and `wait-apps` phases are still used in
|
||||
Steps 3, 5 and 6 and work; do not read this as the whole script being gone.
|
||||
It has also never had an `openbao` phase — Step 4 is manual because the
|
||||
unseal key must not live in CI; see issue #187 on whether to wire a
|
||||
semi-automated OpenBao restore.
|
||||
- **OpenBao seals on every restart and each replica must be unsealed
|
||||
individually.** Three raft replicas mean the service survives losing one, but
|
||||
a sealed server is neither a voter nor a standby — unsealing only one leaves
|
||||
the cluster short of quorum. Step 4 covers this; it is repeated here because
|
||||
it is the step most likely to be half-done under pressure.
|
||||
Its `flux` phase carries the OpenBao restore (Step 4); the seal key and the
|
||||
other secrets are read from `pass` or streamed in at run time and never live
|
||||
in CI.
|
||||
- **OpenBao unseals itself, but only under the seal key the snapshot was
|
||||
written with.** Three raft replicas mean the service survives losing one; a
|
||||
sealed server is neither a voter nor a standby. A pod that stays sealed after
|
||||
a restart has the wrong `openbao-seal-key` Secret — Step 4 covers this; it is
|
||||
repeated here because it is the step most likely to be half-done under
|
||||
pressure.
|
||||
- Ceph's cluster network is a Multus attachment on VLAN 49 with whereabouts
|
||||
IPAM, not host networking. Nothing in the rebuild needs special handling for
|
||||
it — the nodes hold no address on that segment — but a rebuilt cluster will
|
||||
|
||||
@@ -21,6 +21,9 @@ metadata:
|
||||
namespace: audiobookshelf
|
||||
labels:
|
||||
app.kubernetes.io/name: audiobookshelf
|
||||
annotations:
|
||||
# envFrom does not restart the pod when the ConfigMap changes; Reloader does.
|
||||
configmap.reloader.stakater.com/reload: audiobookshelf-config
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
|
||||
@@ -120,7 +120,14 @@ spec:
|
||||
server:
|
||||
resources:
|
||||
requests:
|
||||
cpu: 200m
|
||||
# 500m, not 200m: the HPA below targets 70% of *this* request, so at
|
||||
# 200m the scale-up threshold sat at 140m — below the pod's own
|
||||
# ordinary peak. Measured over 7d, a warm server pod p99s at ~108m
|
||||
# and peaks at ~190m, and a freshly-started one burns ~300m booting
|
||||
# Django. That made the HPA oscillate 2<->3<->4 all day and pin at
|
||||
# maxReplicas (KubeHpaMaxedOut, issue #761). At 500m the threshold
|
||||
# is 350m/pod, above the boot burst, so only real load scales out.
|
||||
cpu: 500m
|
||||
memory: 1Gi
|
||||
limits:
|
||||
memory: 2Gi
|
||||
@@ -154,7 +161,11 @@ spec:
|
||||
worker:
|
||||
resources:
|
||||
requests:
|
||||
cpu: 200m
|
||||
# Same reasoning as server above, and the worker is burstier still:
|
||||
# over 7d its per-pod p99 reaches ~336m and peaks hit ~630m while
|
||||
# running blueprint applies and scheduled tasks. 200m understated
|
||||
# that badly enough that routine task batches read as saturation.
|
||||
cpu: 500m
|
||||
memory: 1Gi
|
||||
limits:
|
||||
memory: 2Gi
|
||||
|
||||
@@ -16,7 +16,6 @@ resources:
|
||||
- prometheus-gate-egress.yaml
|
||||
- wordpress-gate-egress.yaml
|
||||
- llama-gate-egress.yaml
|
||||
- outpost-servicemonitors.yaml
|
||||
- ldap-outpost-cert.yaml
|
||||
- ldap-outpost-lb.yaml
|
||||
- ldap-outpost-cnp.yaml
|
||||
|
||||
@@ -95,7 +95,7 @@ spec:
|
||||
memory: 32Mi
|
||||
containers:
|
||||
- name: home-assistant
|
||||
image: ghcr.io/home-assistant/home-assistant:2026.9.0 # {"$imagepolicy": "flux-system:home-assistant"}
|
||||
image: ghcr.io/home-assistant/home-assistant:2026.9.2 # {"$imagepolicy": "flux-system:home-assistant"}
|
||||
env:
|
||||
- name: TZ
|
||||
value: Europe/Berlin
|
||||
|
||||
@@ -60,7 +60,7 @@ spec:
|
||||
# Two processes rather than one: socat carries a single address pair per
|
||||
# invocation, and the families need different receive and send addresses.
|
||||
# They are independent — one crashing does not blind the other — and each
|
||||
# is restarted by the kubelet on its own.
|
||||
# carries its own supervisor loop.
|
||||
containers:
|
||||
# -u is load-bearing on both: unidirectional, left to right. Without it
|
||||
# socat would also carry HA's replies back out, and this must stay a
|
||||
@@ -77,17 +77,29 @@ spec:
|
||||
# UDP4-SENDTO takes its A record and UDP6-SENDTO its AAAA. See
|
||||
# mdns-relay-service.yaml for why that indirection is required and why
|
||||
# the Service is dual-stack.
|
||||
#
|
||||
# Supervisor loop: socat exits 1 on the first datagram it cannot
|
||||
# deliver, and the ClusterIP has no backend while HA restarts. The
|
||||
# trap forwards SIGTERM so the pod still terminates promptly.
|
||||
- name: socat-ipv4
|
||||
image: alpine/socat:1.8.0.3@sha256:beb4a68d9e4fe6b0f21ea774a0fde6c31f580dde6368939ed70100c5385b015e
|
||||
imagePullPolicy: IfNotPresent
|
||||
args:
|
||||
- -u
|
||||
- "UDP4-RECV:5353,ip-add-membership=224.0.0.251:net1,so-bindtodevice=net1,reuseaddr"
|
||||
- "UDP4-SENDTO:home-assistant-mdns.home-assistant.svc.cluster.local:5353"
|
||||
# No probes. socat is a single process with no health surface: if it
|
||||
# exits, the container exits and the kubelet restarts it, which is the
|
||||
# entire liveness story. A probe here could only re-assert that PID 1
|
||||
# is running.
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- |
|
||||
trap 'kill "$pid"; exit 0' TERM INT
|
||||
while :; do
|
||||
socat -u \
|
||||
"UDP4-RECV:5353,ip-add-membership=224.0.0.251:net1,so-bindtodevice=net1,reuseaddr" \
|
||||
"UDP4-SENDTO:home-assistant-mdns.home-assistant.svc.cluster.local:5353" &
|
||||
pid=$!
|
||||
wait "$pid"
|
||||
echo "mdns-relay: socat-ipv4 exited ($?); restarting in 2s" >&2
|
||||
sleep 2
|
||||
done
|
||||
# No probes. The supervisor loop is the liveness story; a probe here
|
||||
# could only re-assert that PID 1 is running.
|
||||
resources:
|
||||
requests:
|
||||
cpu: 10m
|
||||
@@ -114,10 +126,20 @@ spec:
|
||||
- name: socat-ipv6
|
||||
image: alpine/socat:1.8.0.3@sha256:beb4a68d9e4fe6b0f21ea774a0fde6c31f580dde6368939ed70100c5385b015e
|
||||
imagePullPolicy: IfNotPresent
|
||||
args:
|
||||
- -u
|
||||
- "UDP6-RECV:5353,ipv6-join-group=[ff02::fb]:net1,so-bindtodevice=net1,ipv6-v6only=1,reuseaddr"
|
||||
- "UDP6-SENDTO:home-assistant-mdns.home-assistant.svc.cluster.local:5353"
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- |
|
||||
trap 'kill "$pid"; exit 0' TERM INT
|
||||
while :; do
|
||||
socat -u \
|
||||
"UDP6-RECV:5353,ipv6-join-group=[ff02::fb]:net1,so-bindtodevice=net1,ipv6-v6only=1,reuseaddr" \
|
||||
"UDP6-SENDTO:home-assistant-mdns.home-assistant.svc.cluster.local:5353" &
|
||||
pid=$!
|
||||
wait "$pid"
|
||||
echo "mdns-relay: socat-ipv6 exited ($?); restarting in 2s" >&2
|
||||
sleep 2
|
||||
done
|
||||
resources:
|
||||
requests:
|
||||
cpu: 10m
|
||||
|
||||
@@ -57,7 +57,14 @@ spec:
|
||||
# and leaves this one untouched (tags are not deleted), so this keeps
|
||||
# resolving — but the image then came from the branch build rather than
|
||||
# master. Re-point it at a master-built tag when convenient.
|
||||
image: gitea.renner.uno/gitea_admin/alpine-jq:2026-07-30
|
||||
#
|
||||
# konairius/, not gitea_admin/: this init container runs during a cold
|
||||
# rebuild, when Gitea does not exist because it runs inside the
|
||||
# cluster being rebuilt. The node then falls back to a ghcr.io copy
|
||||
# (machine.registries.mirrors, applied by bootstrap.sh), and containerd
|
||||
# keeps the repository path when it falls through — so the path has to
|
||||
# exist on both registries. Steady state still pulls from Gitea.
|
||||
image: gitea.renner.uno/konairius/alpine-jq:2026-09-04
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
|
||||
@@ -32,7 +32,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: immich-machine-learning
|
||||
image: ghcr.io/immich-app/immich-machine-learning:v3.1.0 # {"$imagepolicy": "flux-system:immich-machine-learning"}
|
||||
image: ghcr.io/immich-app/immich-machine-learning:v3.2.1 # {"$imagepolicy": "flux-system:immich-machine-learning"}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 3003
|
||||
|
||||
@@ -35,6 +35,9 @@ metadata:
|
||||
namespace: immich
|
||||
labels:
|
||||
app.kubernetes.io/name: immich-server
|
||||
annotations:
|
||||
# envFrom does not restart the pod when the ConfigMap changes; Reloader does.
|
||||
configmap.reloader.stakater.com/reload: immich-config
|
||||
spec:
|
||||
# Scaled to 0 alongside immich-machine-learning during single-node
|
||||
# maintenance; see the note there. The CNPG Postgres cluster stays running,
|
||||
@@ -84,7 +87,7 @@ spec:
|
||||
mountPath: /ca
|
||||
containers:
|
||||
- name: immich-server
|
||||
image: ghcr.io/immich-app/immich-server:v3.1.0 # {"$imagepolicy": "flux-system:immich-server"}
|
||||
image: ghcr.io/immich-app/immich-server:v3.2.1 # {"$imagepolicy": "flux-system:immich-server"}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 3001
|
||||
|
||||
@@ -25,6 +25,13 @@ metadata:
|
||||
app.kubernetes.io/name: llama-server
|
||||
spec:
|
||||
replicas: 1
|
||||
# The startupProbe below sanctions a 40 min cold download, but the default
|
||||
# progressDeadlineSeconds is 600. Left implicit the Deployment declares
|
||||
# ProgressDeadlineExceeded at 10 minutes while the probe is still legitimately
|
||||
# waiting, and KubeDeploymentRolloutStuck fires 15 min after that on a rollout
|
||||
# that is fine. Kept above the startup budget, with room for the image pull
|
||||
# and scheduling that happen before the first probe.
|
||||
progressDeadlineSeconds: 3000
|
||||
# A rolling update would load a second copy of the weights onto the same
|
||||
# iGPU and into the same node's RAM before releasing the first.
|
||||
strategy:
|
||||
|
||||
@@ -19,6 +19,11 @@ metadata:
|
||||
app.kubernetes.io/name: llama-embed
|
||||
spec:
|
||||
replicas: 1
|
||||
# Same reasoning as llama-server: the startupProbe budget (600s) must stay
|
||||
# below progressDeadlineSeconds, whose default is also 600 — exactly equal, so
|
||||
# any cold download that uses most of its budget trips
|
||||
# KubeDeploymentRolloutStuck on a rollout that is still progressing normally.
|
||||
progressDeadlineSeconds: 900
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# Cilium's agent ServiceMonitor, declared here rather than by the chart.
|
||||
#
|
||||
# Reproduces what cilium 1.20.1 renders with prometheus.serviceMonitor.enabled,
|
||||
# taken field for field off the live object so scraping is unchanged: same name,
|
||||
# same namespace, same selector, same 10s interval and node relabeling.
|
||||
#
|
||||
# It lives here because the chart's template is gated on monitoring.coreos.com/v1
|
||||
# existing at install time, and on an empty cluster it does not — those CRDs
|
||||
# arrive with kube-prometheus-stack, which reconciles after infrastructure.
|
||||
# Cilium is one of infrastructure's healthChecks, so the chart aborting on the
|
||||
# missing CRD held the whole cluster: CNI waiting on monitoring waiting on CNI.
|
||||
#
|
||||
# velero/helmrelease.yaml already does exactly this for its own SM/PM and says
|
||||
# why; this is the same move for the component where it mattered most.
|
||||
apiVersion: monitoring.coreos.com/v1
|
||||
kind: ServiceMonitor
|
||||
metadata:
|
||||
name: cilium-agent
|
||||
namespace: kube-system
|
||||
labels:
|
||||
app.kubernetes.io/name: cilium-agent
|
||||
app.kubernetes.io/part-of: cilium
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: cilium-agent
|
||||
namespaceSelector:
|
||||
matchNames:
|
||||
- kube-system
|
||||
# The chart's Service keeps rendering (prometheus.enabled), so the target and
|
||||
# its `metrics` port are there regardless of who declares the ServiceMonitor.
|
||||
targetLabels:
|
||||
- k8s-app
|
||||
endpoints:
|
||||
- port: metrics
|
||||
path: /metrics
|
||||
interval: 10s
|
||||
honorLabels: true
|
||||
relabelings:
|
||||
# Agent metrics are per-node; without this every series lands without a
|
||||
# node label and BPF map pressure cannot be attributed to a machine.
|
||||
- action: replace
|
||||
sourceLabels:
|
||||
- __meta_kubernetes_pod_node_name
|
||||
targetLabel: node
|
||||
replacement: ${1}
|
||||
@@ -0,0 +1,20 @@
|
||||
apiVersion: monitoring.coreos.com/v1
|
||||
kind: ServiceMonitor
|
||||
metadata:
|
||||
name: intel-gpu-exporter
|
||||
namespace: monitoring
|
||||
labels:
|
||||
app.kubernetes.io/name: intel-gpu-exporter
|
||||
release: kube-prometheus-stack
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: intel-gpu-exporter
|
||||
endpoints:
|
||||
- port: http-metrics
|
||||
path: /metrics
|
||||
interval: 30s
|
||||
relabelings:
|
||||
# The series would otherwise carry only the exporter pod's IP.
|
||||
- sourceLabels: [__meta_kubernetes_pod_node_name]
|
||||
targetLabel: node
|
||||
@@ -22,10 +22,22 @@ resources:
|
||||
# endpoint, which proves reachability, not that anything was written.
|
||||
- prometheusrule-cnpg-backup.yaml
|
||||
- alloy-servicemonitor.yaml
|
||||
- cilium-servicemonitor.yaml
|
||||
- cnpg-podmonitors.yaml
|
||||
- crowdsec-servicemonitor.yaml
|
||||
# Velero's own monitors: monitoring.coreos.com CRDs arrive with
|
||||
# kube-prometheus-stack, which reconciles after infrastructure, so these
|
||||
# cannot live beside the Velero HelmRelease.
|
||||
- velero-podmonitor.yaml
|
||||
- velero-servicemonitor.yaml
|
||||
- authentik-outpost-servicemonitors.yaml
|
||||
# Rook's exporter/mgr monitors: the operator aborts its node-daemon reconcile
|
||||
# when it cannot create these, which held the CephCluster in Progressing and
|
||||
# deadlocked the whole apps layer. See the file for the cycle.
|
||||
- rook-servicemonitors.yaml
|
||||
- home-assistant-servicemonitor.yaml
|
||||
- immich-servicemonitor.yaml
|
||||
- loki-servicemonitor.yaml
|
||||
- smartctl-exporter-servicemonitor.yaml
|
||||
- intel-gpu-exporter-servicemonitor.yaml
|
||||
- probes-blackbox.yaml
|
||||
|
||||
@@ -34,7 +34,9 @@ spec:
|
||||
# query parameter, and an exact match would break silently if the
|
||||
# instance label ever encodes it differently.
|
||||
expr: probe_success{job="blackbox-http", instance=~"https://openbao\\.renner\\.uno/v1/sys/health.*"} == 0
|
||||
for: 5m
|
||||
# Two consecutive failed probes: enough to skip a single lost probe
|
||||
# through Traefik, not enough to sit on a sealed store.
|
||||
for: 1m
|
||||
labels:
|
||||
severity: critical
|
||||
annotations:
|
||||
|
||||
@@ -28,24 +28,28 @@ spec:
|
||||
# scheduling would also fire this — but both want a human too, so the
|
||||
# imprecision costs nothing.
|
||||
- alert: OpenBaoReplicaNotReady
|
||||
# A replica that is sealed never recovers on its own, so the
|
||||
# duration is only there to ignore ordinary rescheduling: a healthy
|
||||
# pod moving between nodes is Ready again in well under a minute.
|
||||
expr: kube_statefulset_status_replicas_ready{namespace="openbao", statefulset="openbao"} < 3
|
||||
# A replica unseals itself from the static seal, so a restart is
|
||||
# Ready again in well under a minute; the duration only ignores that
|
||||
# ordinary rescheduling. A replica still sealed after it never
|
||||
# recovers on its own: the seal failed (openbao-seal-key Secret
|
||||
# missing or wrong).
|
||||
# max by: kube-state-metrics stamps instance/pod on the series, so a
|
||||
# rescheduled exporter starts a new series and resets the timer.
|
||||
expr: max by (namespace, statefulset) (kube_statefulset_status_replicas_ready{namespace="openbao", statefulset="openbao"}) < 3
|
||||
for: 10m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "OpenBao has {{ $value }} of 3 replicas ready"
|
||||
description: >-
|
||||
An OpenBao replica has been unavailable for 10 minutes. The usual
|
||||
cause is a sealed replica after a node drain — every restart
|
||||
seals it, and it stays out of the raft cluster until unsealed by
|
||||
hand. The service is still up on the remaining replicas, so
|
||||
nothing is broken yet, but redundancy is reduced and a second
|
||||
drain would cost quorum. Check `kubectl -n openbao get pods` and
|
||||
`bao status` in the not-ready pod; unseal with the key from
|
||||
`pass` (homelab/openbao/unseal-key).
|
||||
An OpenBao replica has been unavailable for 10 minutes. A restart
|
||||
unseals itself from the static seal, so this is not the ordinary
|
||||
drain case. The service is still up on the remaining replicas,
|
||||
so nothing is broken yet, but redundancy is reduced and a second
|
||||
failure would cost quorum. Check `kubectl -n openbao get pods`,
|
||||
the pod log and `bao status` in the not-ready pod. If it is
|
||||
sealed, the static seal failed: verify the openbao-seal-key
|
||||
Secret matches `pass show homelab/openbao/seal-key`.
|
||||
|
||||
- alert: OpenBaoQuorumLost
|
||||
# Raft quorum of a three-node cluster is two. At one ready replica
|
||||
@@ -53,11 +57,10 @@ spec:
|
||||
# the ClusterSecretStore goes InvalidProviderConfig and no
|
||||
# ExternalSecret can refresh.
|
||||
#
|
||||
# Shorter window than the warning above because this is an outage,
|
||||
# not a degradation — but not zero, so a rolling restart that
|
||||
# briefly dips does not page.
|
||||
expr: kube_statefulset_status_replicas_ready{namespace="openbao", statefulset="openbao"} < 2
|
||||
for: 5m
|
||||
# No window: below quorum the secret store is down and every
|
||||
# ExternalSecret stalls with it, so waiting only delays the page.
|
||||
expr: max by (namespace, statefulset) (kube_statefulset_status_replicas_ready{namespace="openbao", statefulset="openbao"}) < 2
|
||||
for: 0m
|
||||
labels:
|
||||
severity: critical
|
||||
annotations:
|
||||
@@ -67,9 +70,11 @@ spec:
|
||||
quorum and the secret store is unavailable. ExternalSecrets stop
|
||||
refreshing; existing Secrets keep their last values, so failures
|
||||
surface only when something rotates or a new secret is needed.
|
||||
Unseal the sealed replicas (key in `pass`,
|
||||
homelab/openbao/unseal-key) — each one individually, since a
|
||||
sealed server cannot be a standby.
|
||||
Replicas unseal themselves from the static seal; one that stays
|
||||
sealed could not. Check its log and the openbao-seal-key Secret
|
||||
against `pass show homelab/openbao/seal-key`, fix the Secret and
|
||||
restart the pod. The recovery key cannot unseal an auto-sealed
|
||||
server. Procedure: kubernetes/RUNBOOK-cold-rebuild.md, Step 4.
|
||||
|
||||
- alert: OpenBaoMetricsMissing
|
||||
# Guards the two alerts above. Both are threshold comparisons, so if
|
||||
|
||||
@@ -58,10 +58,30 @@ spec:
|
||||
# VeleroBackupFailed alert fires on the first failure regardless — so
|
||||
# nothing is hidden, only the escalation to critical is deferred
|
||||
# until a second cycle confirms it.
|
||||
#
|
||||
# The `and on(schedule) velero_backup_last_status` gate exists because
|
||||
# velero_backup_last_successful_timestamp is an in-process gauge that
|
||||
# velero does NOT retract when a Schedule is deleted: the series just
|
||||
# stops being updated, ages past 60h and pages critical forever, for a
|
||||
# schedule that no longer exists. That is what decommissioning
|
||||
# netbird-backup-daily did on 2026-09-01.
|
||||
#
|
||||
# velero_backup_last_status is the same shape but IS retracted on
|
||||
# Schedule deletion, so it works as a liveness set for "this schedule
|
||||
# still exists". It hides nothing: velero repopulates it for every
|
||||
# existing Schedule on its first scrape after a restart (verified
|
||||
# across the three pods of the 2026-08-27 rollout), and it is written
|
||||
# on failed runs too (0) as well as successful ones (1) — so a
|
||||
# schedule that exists and is failing, or that exists and has stopped
|
||||
# firing, still carries the series and still alerts.
|
||||
expr: |
|
||||
time() - velero_backup_last_successful_timestamp{schedule!=""} > 60 * 3600
|
||||
and
|
||||
velero_backup_last_successful_timestamp{schedule!=""} > 0
|
||||
(
|
||||
time() - velero_backup_last_successful_timestamp{schedule!=""} > 60 * 3600
|
||||
and
|
||||
velero_backup_last_successful_timestamp{schedule!=""} > 0
|
||||
)
|
||||
and on(schedule)
|
||||
velero_backup_last_status
|
||||
for: 10m
|
||||
labels:
|
||||
severity: critical
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# Rook's two ServiceMonitors, declared here rather than by the operator.
|
||||
#
|
||||
# Reproduced field for field off the live objects in the production cluster, so
|
||||
# scraping is unchanged: same names, same namespace, same selectors, same 10s
|
||||
# interval and `cluster` relabeling. The only thing dropped is the CephCluster
|
||||
# ownerReference, which Rook set and Flux cannot.
|
||||
#
|
||||
# They live here because CephCluster.spec.monitoring.enabled is now false, and
|
||||
# that is not a monitoring decision — it breaks a deadlock. Rook's node-daemon
|
||||
# reconcile creates this ServiceMonitor and, unlike the exporter DaemonSet next
|
||||
# to it, treats a failure as fatal (pkg/operator/ceph/cluster/nodedaemon/
|
||||
# reconcile.go, v1.20.7):
|
||||
#
|
||||
# if !cephCluster.Spec.Monitoring.MetricsDisabled {
|
||||
# // exporter reconciled; its errors are logged and reconcile continues
|
||||
# if cephCluster.Spec.Monitoring.Enabled {
|
||||
# if err := EnableCephExporterServiceMonitor(...); err != nil {
|
||||
# return errors.Wrap(err, "failed to enable service monitor")
|
||||
# }
|
||||
# }
|
||||
# }
|
||||
#
|
||||
# On an empty cluster monitoring.coreos.com/v1 does not exist yet, so that call
|
||||
# fails with "the server could not find the requested resource (post
|
||||
# servicemonitors.monitoring.coreos.com)", the whole node-daemon reconcile
|
||||
# aborts, and the CephCluster never leaves status.phase Progressing. That is
|
||||
# what infrastructure-ceph-cluster's healthCheckExpr waits on — and the chain
|
||||
# closes on itself:
|
||||
#
|
||||
# infrastructure-ceph-cluster → CephCluster phase Ready
|
||||
# → Rook reconcile settles
|
||||
# → monitoring.coreos.com CRDs exist
|
||||
# → kube-prometheus-stack (monitoring)
|
||||
# → grafana-db
|
||||
# → infrastructure-ceph-cluster
|
||||
#
|
||||
# Nothing outside that loop can break it, so a cold rebuild stopped here with
|
||||
# every one of the twenty app Kustomizations behind it.
|
||||
#
|
||||
# Same treatment velero/helmrelease.yaml and cilium/helmrelease.yaml already
|
||||
# get, and for the same reason: the metrics stay where they are, only the
|
||||
# scrape wiring moves to the layer where its CRDs exist.
|
||||
apiVersion: monitoring.coreos.com/v1
|
||||
kind: ServiceMonitor
|
||||
metadata:
|
||||
name: rook-ceph-exporter
|
||||
namespace: rook-ceph
|
||||
labels:
|
||||
team: rook
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app: rook-ceph-exporter
|
||||
rook_cluster: rook-ceph
|
||||
namespaceSelector:
|
||||
matchNames:
|
||||
- rook-ceph
|
||||
endpoints:
|
||||
- port: ceph-exporter-http-metrics
|
||||
path: /metrics
|
||||
interval: 10s
|
||||
honorLabels: true
|
||||
# Rook stamps the cluster name so series from several Ceph clusters stay
|
||||
# separable. One cluster here, but dropping it would rename every series.
|
||||
relabelings:
|
||||
- action: replace
|
||||
replacement: rook-ceph
|
||||
targetLabel: cluster
|
||||
---
|
||||
apiVersion: monitoring.coreos.com/v1
|
||||
kind: ServiceMonitor
|
||||
metadata:
|
||||
name: rook-ceph-mgr
|
||||
namespace: rook-ceph
|
||||
labels:
|
||||
team: rook
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app: rook-ceph-mgr
|
||||
rook_cluster: rook-ceph
|
||||
namespaceSelector:
|
||||
matchNames:
|
||||
- rook-ceph
|
||||
endpoints:
|
||||
- port: http-metrics
|
||||
path: /metrics
|
||||
interval: 10s
|
||||
honorLabels: true
|
||||
relabelings:
|
||||
- action: replace
|
||||
replacement: rook-ceph
|
||||
targetLabel: cluster
|
||||
@@ -0,0 +1,368 @@
|
||||
{
|
||||
"title": "Intel iGPU",
|
||||
"uid": "intel-gpu",
|
||||
"tags": ["gpu", "intel", "i915"],
|
||||
"schemaVersion": 39,
|
||||
"editable": true,
|
||||
"timezone": "",
|
||||
"refresh": "1m",
|
||||
"time": { "from": "now-6h", "to": "now" },
|
||||
"templating": {
|
||||
"list": [
|
||||
{
|
||||
"name": "node",
|
||||
"label": "Node",
|
||||
"type": "query",
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"query": {
|
||||
"query": "label_values(gpumon_power, node)",
|
||||
"refId": "PrometheusVariableQueryEditor-VariableQuery"
|
||||
},
|
||||
"definition": "label_values(gpumon_power, node)",
|
||||
"includeAll": true,
|
||||
"multi": true,
|
||||
"refresh": 2,
|
||||
"sort": 1,
|
||||
"current": { "selected": true, "text": ["All"], "value": ["$__all"] }
|
||||
}
|
||||
]
|
||||
},
|
||||
"panels": [
|
||||
{
|
||||
"type": "stat",
|
||||
"title": "Render/3D busy now",
|
||||
"gridPos": { "h": 4, "w": 8, "x": 0, "y": 0 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "percent",
|
||||
"decimals": 0,
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"color": { "mode": "fixed", "fixedColor": "text" }
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"colorMode": "none",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "center",
|
||||
"orientation": "horizontal",
|
||||
"textMode": "value_and_name",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"expr": "gpumon_engine_usage{attrib=\"busy\", engine=\"Render/3D\", node=~\"$node\"}",
|
||||
"legendFormat": "{{node}}",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "stat",
|
||||
"title": "GPU power now",
|
||||
"gridPos": { "h": 4, "w": 8, "x": 8, "y": 0 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "watt",
|
||||
"decimals": 1,
|
||||
"color": { "mode": "fixed", "fixedColor": "text" }
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"colorMode": "none",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "center",
|
||||
"orientation": "horizontal",
|
||||
"textMode": "value_and_name",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"expr": "gpumon_power{type=\"gpu\", node=~\"$node\"}",
|
||||
"legendFormat": "{{node}}",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "stat",
|
||||
"title": "GT frequency now",
|
||||
"gridPos": { "h": 4, "w": 8, "x": 16, "y": 0 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "suffix: MHz",
|
||||
"decimals": 0,
|
||||
"color": { "mode": "fixed", "fixedColor": "text" }
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"colorMode": "none",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "center",
|
||||
"orientation": "horizontal",
|
||||
"textMode": "value_and_name",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"expr": "gpumon_frequency{type=\"actual\", node=~\"$node\"}",
|
||||
"legendFormat": "{{node}}",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "timeseries",
|
||||
"title": "Render/3D busy",
|
||||
"description": "Compute and 3D. llama.cpp's Vulkan backend runs here; 0 % during an inference means the model is on the CPU.",
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 4 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "percent",
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 0, "showPoints": "never", "spanNulls": false }
|
||||
},
|
||||
"overrides": [
|
||||
{ "matcher": { "id": "byRegexp", "options": "/k8s-1/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#3987e5" } }] },
|
||||
{ "matcher": { "id": "byRegexp", "options": "/k8s-2/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#d95926" } }] },
|
||||
{ "matcher": { "id": "byRegexp", "options": "/k8s-3/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#199e70" } }] }
|
||||
]
|
||||
},
|
||||
"options": {
|
||||
"legend": { "calcs": ["mean", "max", "lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true },
|
||||
"tooltip": { "mode": "multi", "sort": "desc" }
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"expr": "gpumon_engine_usage{attrib=\"busy\", engine=\"Render/3D\", node=~\"$node\"}",
|
||||
"legendFormat": "{{node}}",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "timeseries",
|
||||
"title": "Video busy",
|
||||
"description": "Fixed-function decode/encode (QuickSync).",
|
||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 4 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "percent",
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 0, "showPoints": "never", "spanNulls": false }
|
||||
},
|
||||
"overrides": [
|
||||
{ "matcher": { "id": "byRegexp", "options": "/k8s-1/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#3987e5" } }] },
|
||||
{ "matcher": { "id": "byRegexp", "options": "/k8s-2/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#d95926" } }] },
|
||||
{ "matcher": { "id": "byRegexp", "options": "/k8s-3/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#199e70" } }] }
|
||||
]
|
||||
},
|
||||
"options": {
|
||||
"legend": { "calcs": ["mean", "max", "lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true },
|
||||
"tooltip": { "mode": "multi", "sort": "desc" }
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"expr": "gpumon_engine_usage{attrib=\"busy\", engine=\"Video\", node=~\"$node\"}",
|
||||
"legendFormat": "{{node}}",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "timeseries",
|
||||
"title": "VideoEnhance busy",
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 12 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "percent",
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 0, "showPoints": "never", "spanNulls": false }
|
||||
},
|
||||
"overrides": [
|
||||
{ "matcher": { "id": "byRegexp", "options": "/k8s-1/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#3987e5" } }] },
|
||||
{ "matcher": { "id": "byRegexp", "options": "/k8s-2/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#d95926" } }] },
|
||||
{ "matcher": { "id": "byRegexp", "options": "/k8s-3/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#199e70" } }] }
|
||||
]
|
||||
},
|
||||
"options": {
|
||||
"legend": { "calcs": ["mean", "max", "lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true },
|
||||
"tooltip": { "mode": "multi", "sort": "desc" }
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"expr": "gpumon_engine_usage{attrib=\"busy\", engine=\"VideoEnhance\", node=~\"$node\"}",
|
||||
"legendFormat": "{{node}}",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "timeseries",
|
||||
"title": "Blitter busy",
|
||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 12 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "percent",
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 0, "showPoints": "never", "spanNulls": false }
|
||||
},
|
||||
"overrides": [
|
||||
{ "matcher": { "id": "byRegexp", "options": "/k8s-1/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#3987e5" } }] },
|
||||
{ "matcher": { "id": "byRegexp", "options": "/k8s-2/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#d95926" } }] },
|
||||
{ "matcher": { "id": "byRegexp", "options": "/k8s-3/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#199e70" } }] }
|
||||
]
|
||||
},
|
||||
"options": {
|
||||
"legend": { "calcs": ["mean", "max", "lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true },
|
||||
"tooltip": { "mode": "multi", "sort": "desc" }
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"expr": "gpumon_engine_usage{attrib=\"busy\", engine=\"Blitter\", node=~\"$node\"}",
|
||||
"legendFormat": "{{node}}",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "timeseries",
|
||||
"title": "GT frequency (actual)",
|
||||
"description": "0 while the GT sits in RC6.",
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 20 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "suffix: MHz",
|
||||
"min": 0,
|
||||
"custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 0, "showPoints": "never", "spanNulls": false }
|
||||
},
|
||||
"overrides": [
|
||||
{ "matcher": { "id": "byRegexp", "options": "/k8s-1/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#3987e5" } }] },
|
||||
{ "matcher": { "id": "byRegexp", "options": "/k8s-2/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#d95926" } }] },
|
||||
{ "matcher": { "id": "byRegexp", "options": "/k8s-3/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#199e70" } }] }
|
||||
]
|
||||
},
|
||||
"options": {
|
||||
"legend": { "calcs": ["mean", "max", "lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true },
|
||||
"tooltip": { "mode": "multi", "sort": "desc" }
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"expr": "gpumon_frequency{type=\"actual\", node=~\"$node\"}",
|
||||
"legendFormat": "{{node}}",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "timeseries",
|
||||
"title": "GPU power",
|
||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 20 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "watt",
|
||||
"min": 0,
|
||||
"custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 0, "showPoints": "never", "spanNulls": false }
|
||||
},
|
||||
"overrides": [
|
||||
{ "matcher": { "id": "byRegexp", "options": "/k8s-1/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#3987e5" } }] },
|
||||
{ "matcher": { "id": "byRegexp", "options": "/k8s-2/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#d95926" } }] },
|
||||
{ "matcher": { "id": "byRegexp", "options": "/k8s-3/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#199e70" } }] }
|
||||
]
|
||||
},
|
||||
"options": {
|
||||
"legend": { "calcs": ["mean", "max", "lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true },
|
||||
"tooltip": { "mode": "multi", "sort": "desc" }
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"expr": "gpumon_power{type=\"gpu\", node=~\"$node\"}",
|
||||
"legendFormat": "{{node}}",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "timeseries",
|
||||
"title": "Package power",
|
||||
"description": "Whole SoC (CPU + GPU + uncore), from the same PMU.",
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 28 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "watt",
|
||||
"min": 0,
|
||||
"custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 0, "showPoints": "never", "spanNulls": false }
|
||||
},
|
||||
"overrides": [
|
||||
{ "matcher": { "id": "byRegexp", "options": "/k8s-1/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#3987e5" } }] },
|
||||
{ "matcher": { "id": "byRegexp", "options": "/k8s-2/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#d95926" } }] },
|
||||
{ "matcher": { "id": "byRegexp", "options": "/k8s-3/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#199e70" } }] }
|
||||
]
|
||||
},
|
||||
"options": {
|
||||
"legend": { "calcs": ["mean", "max", "lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true },
|
||||
"tooltip": { "mode": "multi", "sort": "desc" }
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"expr": "gpumon_power{type=\"pkg\", node=~\"$node\"}",
|
||||
"legendFormat": "{{node}}",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "timeseries",
|
||||
"title": "Memory controller bandwidth",
|
||||
"description": "Reads solid, writes dashed. The iGPU shares DDR5 with the CPU, so this is the whole package's traffic.",
|
||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 28 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "MiBs",
|
||||
"min": 0,
|
||||
"custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 0, "showPoints": "never", "spanNulls": false }
|
||||
},
|
||||
"overrides": [
|
||||
{ "matcher": { "id": "byRegexp", "options": "/k8s-1/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#3987e5" } }] },
|
||||
{ "matcher": { "id": "byRegexp", "options": "/k8s-2/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#d95926" } }] },
|
||||
{ "matcher": { "id": "byRegexp", "options": "/k8s-3/" }, "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#199e70" } }] },
|
||||
{ "matcher": { "id": "byRegexp", "options": "/write/" }, "properties": [{ "id": "custom.lineStyle", "value": { "fill": "dash", "dash": [10, 10] } }] }
|
||||
]
|
||||
},
|
||||
"options": {
|
||||
"legend": { "calcs": ["mean", "max", "lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true },
|
||||
"tooltip": { "mode": "multi", "sort": "desc" }
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"expr": "gpumon_imc_read{node=~\"$node\"}",
|
||||
"legendFormat": "{{node}} read",
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"expr": "gpumon_imc_write{node=~\"$node\"}",
|
||||
"legendFormat": "{{node}} write",
|
||||
"refId": "B"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -77,6 +77,9 @@ configMapGenerator:
|
||||
- name: dashboard-infrastructure-overview
|
||||
files:
|
||||
- infrastructure-overview.json
|
||||
- name: dashboard-intel-gpu
|
||||
files:
|
||||
- intel-gpu.json
|
||||
- name: dashboard-netbird-overview
|
||||
files:
|
||||
- netbird-overview.json
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# Intel iGPU utilisation, frequency and power for every node (#752).
|
||||
#
|
||||
# Not privileged: /dev/dri comes from the device plugin's monitoring resource
|
||||
# (every GPU on the node, no hostPath), and the i915 PMU that intel_gpu_top
|
||||
# reads needs CAP_PERFMON — the one capability granted. No hostPID, no
|
||||
# hostNetwork.
|
||||
apiVersion: apps/v1
|
||||
kind: DaemonSet
|
||||
metadata:
|
||||
name: intel-gpu-exporter
|
||||
namespace: monitoring
|
||||
labels:
|
||||
app.kubernetes.io/name: intel-gpu-exporter
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: intel-gpu-exporter
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: intel-gpu-exporter
|
||||
spec:
|
||||
# Every node is a control plane; without this the exporter covers nothing.
|
||||
tolerations:
|
||||
- key: node-role.kubernetes.io/control-plane
|
||||
operator: Exists
|
||||
effect: NoSchedule
|
||||
securityContext:
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: intel-gpu-exporter
|
||||
image: ghcr.io/clambin/intel-gpu-exporter:0.7.4@sha256:d78761fef018834c759db154fe9dcf3dd9db5974703fa50ca21f6abc35221be1
|
||||
args:
|
||||
# No -device: intel_gpu_top picks the only GPU the plugin injects.
|
||||
- -interval=5s
|
||||
ports:
|
||||
- name: http-metrics
|
||||
containerPort: 9100
|
||||
protocol: TCP
|
||||
securityContext:
|
||||
# Root so the PERFMON capability is effective without file
|
||||
# capabilities on the binary.
|
||||
runAsUser: 0
|
||||
runAsNonRoot: false
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
add: ["PERFMON"]
|
||||
resources:
|
||||
requests:
|
||||
cpu: 10m
|
||||
memory: 32Mi
|
||||
gpu.intel.com/monitoring: 1
|
||||
limits:
|
||||
memory: 128Mi
|
||||
gpu.intel.com/monitoring: 1
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /metrics
|
||||
port: http-metrics
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 30
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: intel-gpu-exporter
|
||||
namespace: monitoring
|
||||
labels:
|
||||
app.kubernetes.io/name: intel-gpu-exporter
|
||||
spec:
|
||||
clusterIP: None
|
||||
selector:
|
||||
app.kubernetes.io/name: intel-gpu-exporter
|
||||
ports:
|
||||
- name: http-metrics
|
||||
port: 9100
|
||||
targetPort: http-metrics
|
||||
protocol: TCP
|
||||
@@ -16,6 +16,7 @@ resources:
|
||||
- prometheus-gate-ingress.yaml
|
||||
- ci-diagnostics-rbac.yaml
|
||||
- smartctl-exporter.yaml
|
||||
- intel-gpu-exporter.yaml
|
||||
- dashboards/
|
||||
# prometheusrule-garage.yaml lives in ./monitoring-rules/ and is applied by a
|
||||
# separate Flux Kustomization that dependsOn `monitoring` — otherwise the
|
||||
|
||||
@@ -28,7 +28,7 @@ spec:
|
||||
# net1 instead. master: ens19 resolves on every k8s-{1,2,3}/edge
|
||||
# worker, though it is no longer the same kind of device everywhere:
|
||||
# on the bare-metal control-plane nodes it is a VLAN 30 sub-interface
|
||||
# of enp2s0f0np0 rather than a NIC, which is why the datapath needs
|
||||
# of uplink rather than a NIC, which is why the datapath needs
|
||||
# bpf.vlanBypass to carry it (issue #592).
|
||||
k8s.v1.cni.cncf.io/networks: music-assistant-iot
|
||||
spec:
|
||||
@@ -55,7 +55,7 @@ spec:
|
||||
# stdlib only; Flux keeps the tag in lockstep via the shared image
|
||||
# policy) — no extra dependency.
|
||||
- name: reconcile-publish-ip
|
||||
image: ghcr.io/music-assistant/server:2.10.1 # {"$imagepolicy": "flux-system:music-assistant"}
|
||||
image: ghcr.io/music-assistant/server:2.10.3 # {"$imagepolicy": "flux-system:music-assistant"}
|
||||
command: ["/app/venv/bin/python", "/scripts/reconcile.py"]
|
||||
volumeMounts:
|
||||
- name: data
|
||||
@@ -65,7 +65,7 @@ spec:
|
||||
readOnly: true
|
||||
containers:
|
||||
- name: music-assistant
|
||||
image: ghcr.io/music-assistant/server:2.10.1 # {"$imagepolicy": "flux-system:music-assistant"}
|
||||
image: ghcr.io/music-assistant/server:2.10.3 # {"$imagepolicy": "flux-system:music-assistant"}
|
||||
env:
|
||||
- name: LOG_LEVEL
|
||||
value: info
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
---
|
||||
# A read-only view of paperless's backup store, used as a recovery source and
|
||||
# nothing else.
|
||||
#
|
||||
# It points at the production prefix, which is the whole point — the eval
|
||||
# instance is a copy of the real archive's metadata — and that makes what is
|
||||
# ABSENT here the safety-critical part:
|
||||
#
|
||||
# no retentionPolicy The production store has "30d". Without one here
|
||||
# there is no policy to enforce, and enforcement is not
|
||||
# tied to taking a backup: the plugin runs it from the
|
||||
# instance sidecar on a timer
|
||||
# (instanceSidecarConfiguration.retentionPolicyIntervalSeconds,
|
||||
# default 1800). So "this cluster never backs up" would
|
||||
# not have been enough on its own.
|
||||
#
|
||||
# A second property makes it belt-and-braces. Retention runs
|
||||
# barman-cloud-backup-delete, which takes a server name and works inside
|
||||
# <destinationPath>/<serverName> — the store's own status keys its recovery
|
||||
# window by "pprl-pg", confirming that layout. This cluster is pprleval-pg, so
|
||||
# even an accidental retention policy would address a prefix that holds nothing
|
||||
# rather than production's backups.
|
||||
#
|
||||
# The cluster below completes the guarantee with isWALArchiver: false, so
|
||||
# nothing is written back either. It does load the plugin — that is required
|
||||
# for the restore to work at all — and read-only comes from the flag rather
|
||||
# than from leaving the plugin out.
|
||||
apiVersion: barmancloud.cnpg.io/v1
|
||||
kind: ObjectStore
|
||||
metadata:
|
||||
name: pprleval-pg-source
|
||||
namespace: paperless-eval
|
||||
spec:
|
||||
configuration:
|
||||
destinationPath: s3://renner-uno-k8s-cnpg/pprl-pg
|
||||
endpointURL: https://fsn1.your-objectstorage.com
|
||||
s3Credentials:
|
||||
accessKeyId:
|
||||
name: hetzner-s3-creds
|
||||
key: ACCESS_KEY_ID
|
||||
secretAccessKey:
|
||||
name: hetzner-s3-creds
|
||||
key: ACCESS_SECRET_KEY
|
||||
region:
|
||||
name: hetzner-s3-creds
|
||||
key: REGION
|
||||
wal:
|
||||
compression: gzip
|
||||
data:
|
||||
compression: gzip
|
||||
---
|
||||
# A disposable clone of the paperless database, for measuring how well the
|
||||
# local LLM classifies documents without running the experiment against the
|
||||
# live archive.
|
||||
#
|
||||
# Losing this cluster costs a re-restore and nothing else, which is the intent
|
||||
# — it is rebuilt whenever a test needs a clean starting point.
|
||||
#
|
||||
# One instance, not two. Replication buys availability, and an eval instance
|
||||
# that is down is simply an eval that waits.
|
||||
apiVersion: postgresql.cnpg.io/v1
|
||||
kind: Cluster
|
||||
metadata:
|
||||
name: pprleval-pg
|
||||
namespace: paperless-eval
|
||||
spec:
|
||||
instances: 1
|
||||
imageName: ghcr.io/cloudnative-pg/postgresql:16
|
||||
enableSuperuserAccess: true
|
||||
storage:
|
||||
# Delete-reclaim, unlike the Retain default: this volume is a copy that
|
||||
# gets rebuilt, so it should vanish with the cluster rather than leave a
|
||||
# Released PV and an orphaned RBD image behind (issue #262).
|
||||
storageClass: ceph-block-ephemeral
|
||||
size: 10Gi
|
||||
# Declared with isWALArchiver: false, which is what kubernetes/RECOVERY.md
|
||||
# arrived at after an earlier version of that runbook said to omit it: the
|
||||
# instance manager loads this plugin to replay WAL during the restore, and
|
||||
# without it the instance never starts ("Unknown plugin:
|
||||
# barman-cloud.cloudnative-pg.io").
|
||||
#
|
||||
# externalClusters below only says WHERE the backup is; it does not get the
|
||||
# plugin injected into the pod. So "no plugin" is not the way to keep this
|
||||
# read-only — "plugin, not archiving" is, and it is the combination verified
|
||||
# on im-pg on 2026-07-25.
|
||||
plugins:
|
||||
- name: barman-cloud.cloudnative-pg.io
|
||||
enabled: true
|
||||
isWALArchiver: false
|
||||
parameters:
|
||||
barmanObjectName: pprleval-pg-source
|
||||
bootstrap:
|
||||
recovery:
|
||||
source: pprl-pg
|
||||
database: paperless
|
||||
owner: paperless
|
||||
externalClusters:
|
||||
- name: pprl-pg
|
||||
plugin:
|
||||
name: barman-cloud.cloudnative-pg.io
|
||||
parameters:
|
||||
barmanObjectName: pprleval-pg-source
|
||||
serverName: pprl-pg
|
||||
postgresql:
|
||||
parameters:
|
||||
shared_buffers: "256MB"
|
||||
max_connections: "100"
|
||||
# The production tuning for write reduction is deliberately not copied.
|
||||
# It exists to spare the SSDs under a database that runs for years; this
|
||||
# one is read-only for hours at a time and then deleted.
|
||||
resources:
|
||||
requests:
|
||||
cpu: 200m
|
||||
memory: 512Mi
|
||||
limits:
|
||||
memory: 1Gi
|
||||
@@ -1,13 +0,0 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
namespace: paperless-eval
|
||||
# The instance only. The measurement harness lives in scripts/paperless-eval/
|
||||
# and is applied by hand — a Job is immutable, re-running one means deleting and
|
||||
# recreating it, and Flux would just put the old one back. Nothing here should
|
||||
# converge on a finished measurement.
|
||||
resources:
|
||||
- secrets.yaml
|
||||
- cluster.yaml
|
||||
- pvcs.yaml
|
||||
- redis.yaml
|
||||
- paperless.yaml
|
||||
@@ -1,203 +0,0 @@
|
||||
---
|
||||
# Deliberately much smaller than the production ConfigMap. Everything that
|
||||
# exists to serve people is gone: no remote-user auth, no CSRF origins, no
|
||||
# barcode or mail consumption, no Tika/Gotenberg. Nothing consumes documents
|
||||
# here, and nothing signs in.
|
||||
#
|
||||
# What remains is the database connection and the AI settings, because those
|
||||
# are what the evaluation measures.
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: paperless-eval-config
|
||||
namespace: paperless-eval
|
||||
data:
|
||||
PAPERLESS_URL: "http://paperless.paperless-eval.svc.cluster.local"
|
||||
PAPERLESS_ALLOWED_HOSTS: "*"
|
||||
PAPERLESS_TIME_ZONE: "Europe/Berlin"
|
||||
PAPERLESS_REDIS: "redis://redis:6379"
|
||||
PAPERLESS_DBENGINE: "postgresql"
|
||||
PAPERLESS_DBHOST: "pprleval-pg-rw"
|
||||
PAPERLESS_DBNAME: "paperless"
|
||||
# Polling off, so a pod that finds an empty consume volume does not spend a
|
||||
# thread looking at it every ten seconds for the length of a run.
|
||||
PAPERLESS_CONSUMER_POLLING_INTERVAL: "0"
|
||||
|
||||
# The AI settings under test. The endpoints are placeholders that each eval
|
||||
# job overrides for the model it is measuring — a job runs its own
|
||||
# llama-server beside paperless in the same pod, so the value that matters at
|
||||
# run time is the one in the job, not the one here.
|
||||
#
|
||||
# Same caveat as production, and sharper here: paperless reads these from an
|
||||
# ApplicationConfiguration row and falls back to the environment only when
|
||||
# that row is empty. The row comes across in the restored database, so
|
||||
# anything configured in the production admin UI arrives with it and silently
|
||||
# wins over this ConfigMap — an eval would then measure production's settings
|
||||
# while appearing to measure the job's.
|
||||
#
|
||||
# Check it before trusting any result, and after every fresh restore:
|
||||
#
|
||||
# select llm_backend, llm_model, llm_embedding_backend, llm_embedding_model
|
||||
# from paperless_applicationconfiguration;
|
||||
#
|
||||
# Every column must be null for the environment to apply.
|
||||
PAPERLESS_AI_ENABLED: "true"
|
||||
PAPERLESS_AI_LLM_BACKEND: "openai-like"
|
||||
PAPERLESS_AI_LLM_ENDPOINT: "http://127.0.0.1:8080/v1"
|
||||
PAPERLESS_AI_LLM_MODEL: "qwen3.6-35b-a3b"
|
||||
PAPERLESS_AI_LLM_ALLOW_INTERNAL_ENDPOINTS: "true"
|
||||
PAPERLESS_AI_LLM_CONTEXT_SIZE: "12288"
|
||||
PAPERLESS_AI_LLM_REQUEST_TIMEOUT: "900"
|
||||
PAPERLESS_AI_LLM_OUTPUT_LANGUAGE: "de-de"
|
||||
PAPERLESS_AI_LLM_EMBEDDING_BACKEND: "openai-like"
|
||||
PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT: "http://llama-embed.ai.svc.cluster.local:8080/v1"
|
||||
PAPERLESS_AI_LLM_EMBEDDING_MODEL: "bge-m3"
|
||||
PAPERLESS_AI_LLM_EMBEDDING_CHUNK_SIZE: "1024"
|
||||
---
|
||||
# A base instance that proves the restored database is usable and that the
|
||||
# search index and classifier build against it. The evaluation itself does not
|
||||
# run here — each job brings its own paperless container so it can point at its
|
||||
# own model — but this is what makes a broken clone visible before a job spends
|
||||
# hours on it.
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: paperless
|
||||
namespace: paperless-eval
|
||||
labels:
|
||||
app.kubernetes.io/name: paperless
|
||||
annotations:
|
||||
configmap.reloader.stakater.com/reload: paperless-eval-config
|
||||
# Both, like production: the secret refreshes on the same 10m cycle, and
|
||||
# without this the pod would keep a rotated key until something else
|
||||
# restarted it — which defeats the point of matching the interval.
|
||||
secret.reloader.stakater.com/reload: paperless-eval-secrets
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: paperless
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: paperless
|
||||
spec:
|
||||
enableServiceLinks: false
|
||||
securityContext:
|
||||
fsGroup: 1000
|
||||
fsGroupChangePolicy: OnRootMismatch
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: paperless
|
||||
# No $imagepolicy marker, unlike apps/paperless: a measurement is
|
||||
# only comparable against a fixed version, so this tag moves when
|
||||
# somebody decides to re-measure, not when a release lands.
|
||||
image: ghcr.io/paperless-ngx/paperless-ngx:3.1.2
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8000
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: paperless-eval-config
|
||||
env:
|
||||
- name: PAPERLESS_SECRET_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: paperless-eval-secrets
|
||||
key: secret-key
|
||||
- name: PAPERLESS_DBUSER
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: pprleval-pg-app
|
||||
key: username
|
||||
- name: PAPERLESS_DBPASS
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: pprleval-pg-app
|
||||
key: password
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /usr/src/paperless/data
|
||||
- name: media
|
||||
mountPath: /usr/src/paperless/media
|
||||
- name: consume
|
||||
mountPath: /usr/src/paperless/consume
|
||||
# 60 minutes, against production's 10, and matching this app's Flux
|
||||
# Kustomization timeout.
|
||||
#
|
||||
# Production's budget was sized for a from-scratch boot on this same
|
||||
# corpus and held, so 10 would probably do — but there the volume is
|
||||
# Retain and the index survives, making that a one-off. Here the
|
||||
# volume is ephemeral and the index is rebuilt on every re-test, so
|
||||
# the expensive path is the normal one. A killed pod starts the work
|
||||
# over rather than resuming, which turns a slow boot into a
|
||||
# crash-loop that stalls Flux behind wait: true.
|
||||
#
|
||||
# The LLM index is not in this budget: it is a celery task, and boot
|
||||
# only runs init-llmindex-migrate, a migration check.
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /accounts/login/
|
||||
port: http
|
||||
periodSeconds: 20
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 180
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /accounts/login/
|
||||
port: http
|
||||
initialDelaySeconds: 120
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 10
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /accounts/login/
|
||||
port: http
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
resources:
|
||||
requests:
|
||||
cpu: 200m
|
||||
memory: 512Mi
|
||||
limits:
|
||||
memory: 2Gi
|
||||
# Production's context verbatim, and it has to be: the paperless-ngx
|
||||
# entrypoint starts as root, chowns the mounted data/media/consume
|
||||
# volumes and then drops to uid 1000 via setgid/setuid. Without these
|
||||
# the same image fails that sequence with EPERM.
|
||||
#
|
||||
# It bites harder here than in production, where those volumes were
|
||||
# chowned once years ago and keep their ownership. These are fresh on
|
||||
# every rebuild, so the chown is not a no-op.
|
||||
securityContext:
|
||||
runAsNonRoot: false
|
||||
allowPrivilegeEscalation: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
add: ["CHOWN", "DAC_OVERRIDE", "FOWNER", "SETGID", "SETUID"]
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: paperless-eval-golden
|
||||
- name: media
|
||||
persistentVolumeClaim:
|
||||
claimName: paperless-eval-media
|
||||
- name: consume
|
||||
persistentVolumeClaim:
|
||||
claimName: paperless-eval-consume
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: paperless
|
||||
namespace: paperless-eval
|
||||
spec:
|
||||
selector:
|
||||
app.kubernetes.io/name: paperless
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: http
|
||||
@@ -1,129 +0,0 @@
|
||||
---
|
||||
# All four use ceph-block-ephemeral rather than the Retain default. Everything
|
||||
# here is derived — a restored database, an index built from it, a cache — so
|
||||
# the volumes should go when the namespace does instead of leaving Released PVs
|
||||
# and orphaned RBD images to reclaim by hand (issue #262).
|
||||
#
|
||||
# /data holds the Tantivy search index and the classifier pickle, both rebuilt
|
||||
# from the database, and the LLM index — which is the expensive one at roughly
|
||||
# three hours for the full corpus.
|
||||
#
|
||||
# Measured on production for the same ~3900 documents: 36 MiB search index,
|
||||
# 125 MiB LLM index. 10Gi is far more than that needs, and deliberately so —
|
||||
# an embedding model with wider vectors than bge-m3's 1024 dimensions is
|
||||
# exactly what phase two varies, and running out of space mid-run would cost
|
||||
# the whole rebuild.
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
# Named for its role rather than its mount point: the smoke-test deployment
|
||||
# builds /data here, and every eval job copies it out. Replaces the earlier
|
||||
# RWO paperless-eval-data, whose access mode could not be widened in place —
|
||||
# a bound claim's spec is immutable.
|
||||
name: paperless-eval-golden
|
||||
namespace: paperless-eval
|
||||
labels:
|
||||
velero.io/exclude-from-backup: "true"
|
||||
spec:
|
||||
# RWX, because the eval jobs read it too — each copies it into its own
|
||||
# emptyDir rather than mounting it live. The vector store is SQLite, and
|
||||
# SQLite locking across pods over CephFS is not something to rely on.
|
||||
accessModes:
|
||||
- ReadWriteMany
|
||||
storageClassName: ceph-filesystem-ephemeral
|
||||
resources:
|
||||
requests:
|
||||
storage: 10Gi
|
||||
---
|
||||
# Empty, and stays that way. Paperless refuses to start without the path, but
|
||||
# the AI path reads title, content and filename out of the database and never
|
||||
# opens a file — so the documents themselves are not copied here. That is what
|
||||
# keeps this instance cheap and keeps a second copy of the archive off the
|
||||
# cluster.
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: paperless-eval-media
|
||||
namespace: paperless-eval
|
||||
labels:
|
||||
velero.io/exclude-from-backup: "true"
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
storageClassName: ceph-block-ephemeral
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
---
|
||||
# Also empty: nothing is consumed here, but the path has to exist.
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: paperless-eval-consume
|
||||
namespace: paperless-eval
|
||||
labels:
|
||||
velero.io/exclude-from-backup: "true"
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
storageClassName: ceph-block-ephemeral
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
---
|
||||
# The eval jobs' model cache, RWX so several jobs can read the same weights at
|
||||
# once — that is the whole point of measuring two candidates in parallel.
|
||||
#
|
||||
# Separate from the ai namespace's llama-server-models despite holding some of
|
||||
# the same files: PVCs do not cross namespaces. A model therefore downloads
|
||||
# once here and is then shared by every job, which is the cost a new candidate
|
||||
# carries anyway.
|
||||
#
|
||||
# NOT ephemeral, unlike everything else in this namespace: re-downloading tens
|
||||
# of gigabytes on a slow uplink is a worse outcome than an orphaned volume.
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: llama-models
|
||||
namespace: paperless-eval
|
||||
labels:
|
||||
velero.io/exclude-from-backup: "true"
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteMany
|
||||
storageClassName: ceph-filesystem
|
||||
resources:
|
||||
requests:
|
||||
storage: 120Gi
|
||||
---
|
||||
# Results outlive the jobs that produce them: a Job is deleted and recreated to
|
||||
# re-run a configuration, and comparing configurations is the entire exercise.
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: paperless-eval-results
|
||||
namespace: paperless-eval
|
||||
labels:
|
||||
velero.io/exclude-from-backup: "true"
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteMany
|
||||
storageClassName: ceph-filesystem
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: paperless-eval-redis
|
||||
namespace: paperless-eval
|
||||
labels:
|
||||
velero.io/exclude-from-backup: "true"
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
storageClassName: ceph-block-ephemeral
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
@@ -1,81 +0,0 @@
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: redis
|
||||
namespace: paperless-eval
|
||||
labels:
|
||||
app.kubernetes.io/name: redis
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: redis
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: redis
|
||||
spec:
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 999
|
||||
fsGroup: 999
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: redis
|
||||
# No $imagepolicy marker; see the note on the paperless image.
|
||||
image: redis:8.10.1-alpine
|
||||
# No --save: this holds the celery queue and the suggestion cache,
|
||||
# both of which are regenerated. Persisting them across a restart of
|
||||
# a throwaway instance would only preserve stale suggestions.
|
||||
args:
|
||||
- redis-server
|
||||
- --save
|
||||
- ""
|
||||
- --loglevel
|
||||
- warning
|
||||
ports:
|
||||
- name: redis
|
||||
containerPort: 6379
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /data
|
||||
livenessProbe:
|
||||
tcpSocket:
|
||||
port: redis
|
||||
initialDelaySeconds: 10
|
||||
readinessProbe:
|
||||
exec:
|
||||
command: ["redis-cli", "ping"]
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
resources:
|
||||
requests:
|
||||
cpu: 20m
|
||||
memory: 64Mi
|
||||
limits:
|
||||
memory: 256Mi
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: paperless-eval-redis
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: redis
|
||||
namespace: paperless-eval
|
||||
spec:
|
||||
selector:
|
||||
app.kubernetes.io/name: redis
|
||||
ports:
|
||||
- port: 6379
|
||||
targetPort: redis
|
||||
@@ -1,45 +0,0 @@
|
||||
---
|
||||
# The same Hetzner S3 key every CNPG-using namespace pulls, needed here only so
|
||||
# the recovery source can be read. See apps/paperless for why one shared key
|
||||
# rather than per-app paths.
|
||||
apiVersion: external-secrets.io/v1
|
||||
kind: ExternalSecret
|
||||
metadata:
|
||||
name: hetzner-s3-creds
|
||||
namespace: paperless-eval
|
||||
spec:
|
||||
refreshInterval: 1h
|
||||
secretStoreRef:
|
||||
kind: ClusterSecretStore
|
||||
name: openbao
|
||||
target:
|
||||
name: hetzner-s3-creds
|
||||
dataFrom:
|
||||
- extract:
|
||||
key: infrastructure/hetzner-s3
|
||||
---
|
||||
# Django's secret key, shared with the production instance rather than given
|
||||
# its own vault entry.
|
||||
#
|
||||
# It signs sessions and CSRF tokens, and this instance has no ingress and no
|
||||
# users — nothing signs in, so nothing is at stake in sharing it. A separate
|
||||
# entry would mean a vault write before the namespace can start, for a
|
||||
# throwaway instance that gets rebuilt.
|
||||
apiVersion: external-secrets.io/v1
|
||||
kind: ExternalSecret
|
||||
metadata:
|
||||
name: paperless-eval-secrets
|
||||
namespace: paperless-eval
|
||||
spec:
|
||||
# 10m, matching apps/paperless rather than the 1h default: the point of
|
||||
# sharing the key is that the two hold the same value, and a slower refresh
|
||||
# here would let them diverge for up to an hour after a rotation.
|
||||
refreshInterval: 10m
|
||||
secretStoreRef:
|
||||
kind: ClusterSecretStore
|
||||
name: openbao
|
||||
target:
|
||||
name: paperless-eval-secrets
|
||||
dataFrom:
|
||||
- extract:
|
||||
key: apps/paperless
|
||||
@@ -22,7 +22,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: gotenberg
|
||||
image: gotenberg/gotenberg:8.36.0 # {"$imagepolicy": "flux-system:gotenberg"}
|
||||
image: gotenberg/gotenberg:8.37.0 # {"$imagepolicy": "flux-system:gotenberg"}
|
||||
args:
|
||||
- "gotenberg"
|
||||
- "--chromium-disable-javascript=true"
|
||||
|
||||
@@ -143,7 +143,20 @@ spec:
|
||||
# on every paperless pod start: tools belong in the image, not
|
||||
# installed at runtime. Dated tag so a change is a reviewable diff
|
||||
# here, matching the ci-ansible / alpine-jq convention.
|
||||
image: gitea.renner.uno/gitea_admin/alpine-openssl:2026-07-30
|
||||
#
|
||||
# konairius/, not gitea_admin/, and that is not cosmetic. This is an
|
||||
# INIT container, so paperless does not start without it — and during
|
||||
# a cold rebuild gitea.renner.uno does not exist, because Gitea runs
|
||||
# inside the cluster being rebuilt. A rehearsal left this pod in
|
||||
# Init:ErrImagePull on exactly this image.
|
||||
#
|
||||
# The node falls back to a ghcr.io copy in that window
|
||||
# (machine.registries.mirrors, applied by bootstrap.sh). Containerd
|
||||
# keeps the repository path when it falls through, so the path has to
|
||||
# exist on both registries — hence konairius/, which matches the ghcr
|
||||
# namespace. Steady state still pulls from Gitea and never leaves the
|
||||
# network.
|
||||
image: gitea.renner.uno/konairius/alpine-openssl:2026-09-04
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
@@ -170,7 +183,7 @@ spec:
|
||||
drop: ["ALL"]
|
||||
containers:
|
||||
- name: paperless
|
||||
image: ghcr.io/paperless-ngx/paperless-ngx:3.1.2 # {"$imagepolicy": "flux-system:paperless-ngx"}
|
||||
image: ghcr.io/paperless-ngx/paperless-ngx:3.1.3 # {"$imagepolicy": "flux-system:paperless-ngx"}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8000
|
||||
@@ -325,7 +338,6 @@ spec:
|
||||
- port: 80
|
||||
targetPort: http
|
||||
---
|
||||
---
|
||||
# ExternalName mirror so the paperless-namespace Ingress can backend to
|
||||
# the gate outpost in the authentik namespace (cross-namespace Service
|
||||
# refs aren't supported in standard Ingress).
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# trmnl
|
||||
|
||||
Terminus (self-hosted TRMNL BYOS server) plus the headless renderer that
|
||||
screenshots the Home Assistant entrance dashboard and pushes it in. One
|
||||
device: a TRMNL X at the entrance, 1872x1404, 16 greys.
|
||||
|
||||
## Device firmware
|
||||
|
||||
Terminus mirrors one model-agnostic binary and it is the **ESP32-C3** (OG)
|
||||
build. This panel is a TRMNL X, i.e. ESP32-S3, so that file is not the
|
||||
artefact for a manual flash here. Use
|
||||
`https://trmnl-fw.s3.us-east-2.amazonaws.com/trmnl_x/flash/FW<version>.bin`
|
||||
— a merged image at offset `0x0` — and run `esptool image-info` on
|
||||
anything before writing it.
|
||||
|
||||
The panel runs 1.8.16, flashed over USB on 2026-09-05. It shipped on
|
||||
1.8.6, where turning on `firmware_update` froze the screen for as long as
|
||||
an update was offered (fixed upstream in 1.8.7, commit `9b7a0f7`).
|
||||
|
||||
## Image automation
|
||||
|
||||
Both images are enrolled (`infrastructure/image-automation/terminus.yaml`,
|
||||
`trmnl-renderer.yaml`) and track the latest `0.x`. A bad bump on either
|
||||
surfaces as a **blank or corrupted panel**, not as a failed rollout — no
|
||||
readiness probe can tell that a rendered screen became unreadable. Look at
|
||||
the panel after a bump lands; revert the tag if it is wrong.
|
||||
|
||||
## Device endpoint
|
||||
|
||||
`trmnl-device.renner.uno` is the device-facing name, TLS via Traefik
|
||||
(`device-ingress.yaml`). `API_URI` must carry the same scheme the device
|
||||
polls with, because Terminus bakes it into the absolute `image_url` it
|
||||
returns. The LoadBalancer in `device-service.yaml` keeps `10.2.99.89`
|
||||
without a hostname as a cleartext fallback that depends on neither
|
||||
Traefik nor certificate renewal — the captive portal accepts a bare
|
||||
address.
|
||||
@@ -0,0 +1,52 @@
|
||||
# Device-facing endpoint, over TLS.
|
||||
#
|
||||
# This replaces the reason the cleartext LoadBalancer in device-service.yaml
|
||||
# was given a hostname at all. That file claimed the firmware "would reject
|
||||
# the internal CA" — but nothing here is signed by an internal CA:
|
||||
# letsencrypt-cluster solves DNS-01 against the renner.uno Cloudflare zone,
|
||||
# so trmnl.renner.uno already serves a public Let's Encrypt certificate
|
||||
# (issuer CN=YR2) that any client with a normal CA bundle trusts. A
|
||||
# LAN-only name gets the same treatment — DNS-01 never needs the host to be
|
||||
# publicly reachable — so the device can speak HTTPS like everything else.
|
||||
#
|
||||
# The hostname moves here from the Service's external-dns annotation rather
|
||||
# than being duplicated: two sources claiming trmnl-device.renner.uno would
|
||||
# have external-dns flapping the record between the Traefik VIP and the
|
||||
# device LB. The LoadBalancer keeps its address and stays reachable at
|
||||
# http://10.2.99.89 as a nameless fallback if TLS ever turns out to be the
|
||||
# thing that breaks.
|
||||
#
|
||||
# CONSEQUENCE, and the reason API_URI in terminus.yaml changes with this:
|
||||
# Traefik's `web` entrypoint carries a global http -> websecure redirect
|
||||
# (helmrelease.yaml: `to: websecure, scheme: https, permanent: true`), so
|
||||
# once this name points at Traefik, http://trmnl-device.renner.uno answers
|
||||
# 308 rather than serving. Whether the firmware follows that is not
|
||||
# documented, so the device is provisioned with the https URL and Terminus
|
||||
# hands back https image_urls to match. No mixed-scheme fetch.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: terminus-device
|
||||
namespace: trmnl
|
||||
# No external-dns hostname annotation: external-dns-unifi runs with
|
||||
# --source=ingress, so it registers the hosts in spec.rules on its own.
|
||||
# Same as the terminus Ingress next door.
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-cluster
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
tls:
|
||||
- hosts:
|
||||
- trmnl-device.renner.uno
|
||||
secretName: trmnl-device-tls
|
||||
rules:
|
||||
- host: trmnl-device.renner.uno
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: terminus
|
||||
port:
|
||||
number: 80
|
||||
@@ -1,15 +1,24 @@
|
||||
# Device-facing endpoint. Cleartext HTTP, deliberately.
|
||||
# Cleartext fallback for the device, reachable by address only.
|
||||
#
|
||||
# The TRMNL firmware initiates every request and cannot be handed the
|
||||
# internal CA, so terminating TLS here would simply break polling. Scope of
|
||||
# the exposure: this Service is LAN-only (no public DNS label, no Cloudflare
|
||||
# record), it carries no admin session — the device authenticates with its
|
||||
# MAC in the `ID` header plus a per-device api_key — and the Terminus admin
|
||||
# UI stays on the TLS route at trmnl.renner.uno.
|
||||
# This used to be THE device endpoint, on the premise that the firmware
|
||||
# "cannot be handed the internal CA". The premise was wrong: nothing here
|
||||
# hands out an internal CA for these names — letsencrypt-cluster solves
|
||||
# DNS-01 against the renner.uno Cloudflare zone and issues publicly-trusted
|
||||
# certificates for LAN-only hosts, which is why trmnl.renner.uno has always
|
||||
# served an issuer CN=YR2 certificate. The device speaks HTTPS via
|
||||
# device-ingress.yaml, which now owns the trmnl-device.renner.uno name.
|
||||
#
|
||||
# Kept, without a hostname, because it is the one path to Terminus that
|
||||
# depends on neither Traefik nor certificate renewal: http://10.2.99.89
|
||||
# still serves /api/setup and /api/display. If TLS is ever what breaks the
|
||||
# panel, the recovery is to type that address into the captive portal.
|
||||
#
|
||||
# Scope of the exposure is unchanged: LAN-only (no public DNS label, no
|
||||
# Cloudflare record), no admin session — the device authenticates with its
|
||||
# MAC in the `ID` header plus a per-device api_key.
|
||||
#
|
||||
# Same shape as home-assistant-bgp: a dedicated address on the Cilium BGP
|
||||
# pool rather than a TLS-less Ingress, which would be a new pattern for this
|
||||
# repo. The device is provisioned with the HOSTNAME, not the address.
|
||||
# pool.
|
||||
#
|
||||
# The address is requested explicitly, matching every other LB service here.
|
||||
# Left to allocate freely, Cilium IPAM handed out the first free address in
|
||||
@@ -30,8 +39,10 @@ metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: terminus
|
||||
lb-pool: bgp
|
||||
# No external-dns hostname: device-ingress.yaml claims that name. Two
|
||||
# sources claiming trmnl-device.renner.uno would flap the record between
|
||||
# the Traefik VIP and this address (external-dns runs --policy=sync).
|
||||
annotations:
|
||||
external-dns.alpha.kubernetes.io/hostname: trmnl-device.renner.uno
|
||||
lbipam.cilium.io/ips: "10.2.99.89,fd99::89"
|
||||
spec:
|
||||
type: LoadBalancer
|
||||
|
||||
@@ -6,6 +6,9 @@ resources:
|
||||
- valkey.yaml
|
||||
- terminus.yaml
|
||||
- ingress.yaml
|
||||
# TLS device endpoint (owns trmnl-device.renner.uno) plus the nameless
|
||||
# cleartext LoadBalancer it falls back to.
|
||||
- device-ingress.yaml
|
||||
- device-service.yaml
|
||||
# Headless HA screenshotter. Lives in this namespace because Terminus
|
||||
# FETCHES rendered screens back from it (uri delivery mode), so the
|
||||
|
||||
@@ -177,7 +177,14 @@ spec:
|
||||
# webhook start failing with "expired JWT access token" about
|
||||
# thirty minutes after seeding.
|
||||
- name: render-schedule
|
||||
image: gitea.renner.uno/gitea_admin/alpine-jq:2026-07-30
|
||||
# konairius/, not gitea_admin/: this container starts during a cold
|
||||
# rebuild, when Gitea does not exist because it runs inside the
|
||||
# cluster being rebuilt — a rehearsal left it in Init:ImagePullBackOff.
|
||||
# The node falls back to a ghcr.io copy then
|
||||
# (machine.registries.mirrors, applied by bootstrap.sh), and containerd
|
||||
# keeps the repository path, so it has to exist on both registries.
|
||||
# Steady state still pulls from Gitea.
|
||||
image: gitea.renner.uno/konairius/alpine-jq:2026-09-04
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
@@ -227,16 +234,12 @@ spec:
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
containers:
|
||||
# Pinned, not enrolled in image automation — same reasoning as
|
||||
# Terminus itself. This drives a wall-mounted panel and upstream
|
||||
# is pre-1.0; an unattended bump that changes the dithering
|
||||
# pipeline or the webhook payload shows up as a corrupted
|
||||
# screen, not as a failed rollout.
|
||||
#
|
||||
# Note the image is ARCH-SUFFIXED: upstream publishes no
|
||||
# multi-arch manifest, only trmnl-ha-amd64 and trmnl-ha-aarch64.
|
||||
# Enrolled in image automation (flux-system:trmnl-renderer). A bad
|
||||
# bump shows up as a corrupted screen, not a failed rollout — check
|
||||
# the panel after one lands. ARCH-SUFFIXED image: upstream publishes
|
||||
# no multi-arch manifest.
|
||||
- name: renderer
|
||||
image: ghcr.io/usetrmnl/trmnl-ha-amd64:0.9.5
|
||||
image: ghcr.io/usetrmnl/trmnl-ha-amd64:0.10.3 # {"$imagepolicy": "flux-system:trmnl-renderer"}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 10000
|
||||
|
||||
@@ -7,11 +7,17 @@ data:
|
||||
TZ: "Europe/Berlin"
|
||||
HANAMI_PORT: "2345"
|
||||
# What the device is told to fetch from. Terminus bakes this into the
|
||||
# absolute `image_url` it hands back on /api/display, so it MUST be the
|
||||
# cleartext LAN name, not trmnl.renner.uno — the firmware speaks plain
|
||||
# HTTP and would reject the internal CA. No trailing slash: the same
|
||||
# string is typed into the captive portal at provisioning time.
|
||||
API_URI: "http://trmnl-device.renner.uno"
|
||||
# absolute `image_url` it hands back on /api/display, so it has to be a
|
||||
# name the DEVICE can resolve and fetch — and it has to carry the same
|
||||
# scheme the device polls with, or every screen fetch is a second,
|
||||
# cleartext connection. No trailing slash: the same string is typed into
|
||||
# the captive portal at provisioning time.
|
||||
#
|
||||
# https since device-ingress.yaml took over trmnl-device.renner.uno. The
|
||||
# earlier value was http on the theory that the firmware would reject an
|
||||
# internal CA; there is no internal CA here — letsencrypt-cluster issues
|
||||
# publicly-trusted certs for LAN-only names via DNS-01.
|
||||
API_URI: "https://trmnl-device.renner.uno"
|
||||
KEYVALUE_URL: "redis://terminus-valkey:6379"
|
||||
# Rack::Attack rate-limit bypass. Beyond upstream's RFC1918 defaults this
|
||||
# adds fc00::/7, which covers the pod CIDR (fd10:244::/48), the Cilium LB
|
||||
@@ -128,7 +134,7 @@ spec:
|
||||
# is a device-bricking class of risk. Bumps are a reviewed, manual
|
||||
# act — see kubernetes/infrastructure/image-automation/README.md.
|
||||
- name: terminus
|
||||
image: ghcr.io/usetrmnl/terminus:0.68.0
|
||||
image: ghcr.io/usetrmnl/terminus:0.73.0 # {"$imagepolicy": "flux-system:terminus"}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 2345
|
||||
@@ -191,7 +197,7 @@ spec:
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
- name: sidekiq
|
||||
image: ghcr.io/usetrmnl/terminus:0.68.0
|
||||
image: ghcr.io/usetrmnl/terminus:0.73.0 # {"$imagepolicy": "flux-system:terminus"}
|
||||
command: ["bundle", "exec", "sidekiq", "-r", "./config/sidekiq.rb"]
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
|
||||
@@ -18,7 +18,7 @@ spec:
|
||||
spec:
|
||||
containers:
|
||||
- name: whisper
|
||||
image: rhasspy/wyoming-whisper:3.7.0 # {"$imagepolicy": "flux-system:wyoming-whisper"}
|
||||
image: rhasspy/wyoming-whisper:3.8.1 # {"$imagepolicy": "flux-system:wyoming-whisper"}
|
||||
args:
|
||||
- --model=base-int8
|
||||
- --language=de
|
||||
|
||||
@@ -34,7 +34,7 @@ spec:
|
||||
# Image automation, range 3.x (see image-automation/woodpecker.yaml).
|
||||
# MUST stay version-matched with woodpecker-server (gRPC parity) — both
|
||||
# track the same range/filter so they bump together on each release.
|
||||
image: woodpeckerci/woodpecker-agent:v3.18.0 # {"$imagepolicy": "flux-system:woodpecker-agent"}
|
||||
image: woodpeckerci/woodpecker-agent:v3.18.1 # {"$imagepolicy": "flux-system:woodpecker-agent"}
|
||||
env:
|
||||
- name: WOODPECKER_SERVER
|
||||
value: woodpecker.woodpecker.svc.cluster.local:9000 # in-cluster gRPC, replaces LXC :9000
|
||||
|
||||
@@ -1,18 +1,3 @@
|
||||
# Dedicated namespace where the kubernetes backend spawns pipeline step pods,
|
||||
# isolated from the apps namespace.
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: woodpecker-ci
|
||||
labels:
|
||||
app.kubernetes.io/name: woodpecker
|
||||
# Image-build steps run a privileged docker/buildx plugin pod (see the
|
||||
# server's WOODPECKER_PLUGINS_PRIVILEGED). Talos enforces Pod Security;
|
||||
# without this the privileged step pods are rejected at admission. The
|
||||
# namespace is dedicated to ephemeral CI step pods, so scoping the
|
||||
# exemption here keeps it off apps/.
|
||||
pod-security.kubernetes.io/enforce: privileged
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
|
||||
@@ -40,7 +40,7 @@ spec:
|
||||
# minor + patch auto-update within 3.x; only the 4.0 major is held for
|
||||
# a reviewed bump. Safe now the DB is postgres (the old sqlite-surprise
|
||||
# -bump hazard is gone). Stays in lockstep with the agent.
|
||||
image: woodpeckerci/woodpecker-server:v3.18.0 # {"$imagepolicy": "flux-system:woodpecker-server"}
|
||||
image: woodpeckerci/woodpecker-server:v3.18.1 # {"$imagepolicy": "flux-system:woodpecker-server"}
|
||||
env:
|
||||
- name: WOODPECKER_HOST
|
||||
value: https://ci.renner.uno
|
||||
|
||||
+1403
-60
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -28,6 +28,19 @@ spec:
|
||||
- name: infrastructure
|
||||
- name: infrastructure-crds
|
||||
- name: infrastructure-ceph-cluster
|
||||
# ha-pg-objectstore.yaml declares an ObjectStore, whose CRD comes from the
|
||||
# CNPG Barman plugin in infrastructure-cnpg-barman. Without this edge the
|
||||
# kind is unknown when this path is first applied, and a server-side
|
||||
# dry-run rejects the ENTIRE set over the one object it cannot recognise:
|
||||
#
|
||||
# ObjectStore/home-assistant/ha-pg-store dry-run failed:
|
||||
# no matches for kind "ObjectStore" in version ...
|
||||
#
|
||||
# Every other ObjectStore user — authentik, immich, paperless, trmnl,
|
||||
# woodpecker — already declares it. This was the only one that did not, and
|
||||
# it took speech-to-phrase, zone-configurator and matter-server down with
|
||||
# it, since all three wait on home-assistant.
|
||||
- name: infrastructure-cnpg-barman
|
||||
---
|
||||
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||
kind: Kustomization
|
||||
|
||||
@@ -122,8 +122,20 @@ spec:
|
||||
path: ./kubernetes/infrastructure/rook-ceph-cluster
|
||||
prune: true
|
||||
wait: true
|
||||
dependsOn:
|
||||
- name: infrastructure
|
||||
# Deliberately NOT dependsOn: infrastructure. That edge closed a cycle no
|
||||
# existing cluster could ever hit and no new one could ever escape:
|
||||
# infrastructure is Ready only once its openbao healthCheck passes, openbao-0
|
||||
# only runs once its PVC binds, and that PVC wants the ceph-block StorageClass
|
||||
# defined in this very Kustomization. A cold rebuild stopped here for good,
|
||||
# with openbao-0 Pending on "unbound immediate PersistentVolumeClaims".
|
||||
#
|
||||
# The edge was never needed. infrastructure runs wait: false, so it applies
|
||||
# the rook-ceph namespace and the operator immediately and only its readiness
|
||||
# waits on the healthChecks — everything this path needs exists long before
|
||||
# infrastructure reports Ready. On an empty API server this Kustomization
|
||||
# retries on retryInterval until the operator installs its CRDs, the same
|
||||
# self-healing shape post-crds already relies on; on a live cluster the CRDs
|
||||
# are always present and nothing changes.
|
||||
# kstatus doesn't know how to read Rook CR readiness, so without these
|
||||
# this Kustomization reports Ready as soon as the manifests apply — long
|
||||
# before OSDs/mons/MDS are actually serving I/O. Apps depending on this
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||
kind: Kustomization
|
||||
metadata:
|
||||
name: paperless-eval
|
||||
namespace: flux-system
|
||||
spec:
|
||||
# A throwaway copy of paperless for measuring the local LLM against the real
|
||||
# corpus. Its Cluster restores from paperless's backup store and archives
|
||||
# nothing back; see kubernetes/apps/paperless-eval/cluster.yaml.
|
||||
#
|
||||
# prune: true and no prune-disabled annotations anywhere underneath — the
|
||||
# data here is a copy, and being able to delete the whole namespace by
|
||||
# removing this file is the point.
|
||||
interval: 10m
|
||||
retryInterval: 1m
|
||||
# 30m is not enough: the first start restores the database and then builds
|
||||
# the search index over ~3900 documents before the webserver answers.
|
||||
timeout: 60m
|
||||
sourceRef:
|
||||
kind: GitRepository
|
||||
name: flux-system
|
||||
path: ./kubernetes/apps/paperless-eval
|
||||
prune: true
|
||||
wait: true
|
||||
dependsOn:
|
||||
- name: infrastructure
|
||||
- name: infrastructure-crds
|
||||
- name: infrastructure-ceph-cluster
|
||||
- name: infrastructure-cnpg-barman
|
||||
healthCheckExprs:
|
||||
- apiVersion: postgresql.cnpg.io/v1
|
||||
kind: Cluster
|
||||
failed: "has(status.phase) && status.phase.startsWith('Failed')"
|
||||
current: "has(status.phase) && (status.phase == 'Cluster in healthy state' || status.readyInstances > 0)"
|
||||
@@ -70,9 +70,23 @@ spec:
|
||||
# map only shows up as "no space left on device" in the log while pod
|
||||
# sandbox creation fails (#452). cilium_bpf_map_pressure makes it
|
||||
# alertable beforehand.
|
||||
#
|
||||
# That reasoning is about the metrics, which this line provides. The
|
||||
# ServiceMonitor is only the scrape wiring and is declared standalone in
|
||||
# apps/monitoring-rules instead — the same treatment velero/helmrelease
|
||||
# already gives its own, and for the same reason: the chart's template is
|
||||
# gated on monitoring.coreos.com/v1 existing at install time.
|
||||
#
|
||||
# Here it is worse than for velero. Cilium's chart does not skip the
|
||||
# template, it aborts rendering — "Service Monitor requires
|
||||
# monitoring.coreos.com/v1 CRDs" — and cilium is one of the seven
|
||||
# healthChecks the infrastructure Kustomization waits on. So on an empty
|
||||
# cluster the CNI blocked infrastructure, which blocked grafana-db, which
|
||||
# blocked monitoring, which is where those CRDs come from. The lowest
|
||||
# layer in the cluster was waiting on the highest.
|
||||
enabled: true
|
||||
serviceMonitor:
|
||||
enabled: true
|
||||
enabled: false
|
||||
bpf:
|
||||
# Per-endpoint policy map size: one entry per (identity, direction,
|
||||
# protocol, port) allowed. When it fills, endpoint regeneration fails
|
||||
@@ -166,3 +180,17 @@ spec:
|
||||
# the supernets here cover the LAN as well, which is the point.
|
||||
ipv4NativeRoutingCIDR: 10.0.0.0/8
|
||||
ipv6NativeRoutingCIDR: fd00::/8
|
||||
# Pod east-west leaves the node on pod0 (VLAN 48) rather than the
|
||||
# management VLAN. Native routing hands forwarding to the kernel;
|
||||
# autoDirectNodeRoutes installs the remote PodCIDR routes, which is
|
||||
# allowed because all three nodes share pod0's L2 segment.
|
||||
#
|
||||
# directRoutingDevice is explicit on purpose: with several addressed
|
||||
# devices Cilium falls back to the one carrying the Kubernetes InternalIP,
|
||||
# which is mgmt0.
|
||||
#
|
||||
# Encapsulation is gone, so the datapath MTU goes 1450 -> 1500.
|
||||
routingMode: native
|
||||
autoDirectNodeRoutes: true
|
||||
nodePort:
|
||||
directRoutingDevice: pod0
|
||||
|
||||
@@ -937,7 +937,7 @@ subjects:
|
||||
# with no marker. Re-apply this block, or the skew silently returns.
|
||||
apiVersion: v1
|
||||
stringData:
|
||||
SIDECAR_IMAGE: ghcr.io/cloudnative-pg/plugin-barman-cloud-sidecar:v0.14.0 # {"$imagepolicy": "flux-system:cnpg-barman-plugin-sidecar"}
|
||||
SIDECAR_IMAGE: ghcr.io/cloudnative-pg/plugin-barman-cloud-sidecar:v0.15.0 # {"$imagepolicy": "flux-system:cnpg-barman-plugin-sidecar"}
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: plugin-barman-cloud-kg5c2795f7
|
||||
@@ -998,7 +998,7 @@ spec:
|
||||
secretKeyRef:
|
||||
key: SIDECAR_IMAGE
|
||||
name: plugin-barman-cloud-kg5c2795f7
|
||||
image: ghcr.io/cloudnative-pg/plugin-barman-cloud:v0.14.0 # {"$imagepolicy": "flux-system:cnpg-barman-plugin"}
|
||||
image: ghcr.io/cloudnative-pg/plugin-barman-cloud:v0.15.0 # {"$imagepolicy": "flux-system:cnpg-barman-plugin"}
|
||||
name: barman-cloud
|
||||
ports:
|
||||
- containerPort: 9090
|
||||
|
||||
@@ -99,7 +99,7 @@ spec:
|
||||
# required.
|
||||
#
|
||||
# NOTE: enabling this makes the LAPI serve HTTPS-only. The Traefik ingress
|
||||
# bouncer is currently INERT (detached, see traefik/crowdsec-middleware.yaml)
|
||||
# bouncer is currently INERT (detached, see post-crds/traefik-crowdsec-middleware.yaml)
|
||||
# so there is no live impact, but re-enabling it will require pointing the
|
||||
# plugin at https + trusting this CA (or giving it a bouncer-ou client cert).
|
||||
tls:
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- bouncer-key-externalsecret.yaml
|
||||
- edge-bouncer-key-externalsecret.yaml
|
||||
- lapi-ingress.yaml
|
||||
- helmrelease.yaml
|
||||
|
||||
@@ -31,22 +31,12 @@
|
||||
# serves read-only decision streams to a valid bouncer key and nothing else.
|
||||
# The admin surface is cscli, in-cluster. If the source address ever becomes
|
||||
# deterministic, an ipAllowList can be added on top.
|
||||
---
|
||||
#
|
||||
# The LAPI serves HTTPS only once tls.enabled is set — there is no plaintext
|
||||
# fallback and no second port; 8080 is the TLS port. Its certificate comes
|
||||
# from the chart's own self-signed CA, so Traefik needs that CA to verify the
|
||||
# backend. SANs include crowdsec-service.crowdsec.svc.cluster.local, which is
|
||||
# what the Service resolves to.
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: ServersTransport
|
||||
metadata:
|
||||
name: crowdsec-lapi
|
||||
namespace: crowdsec
|
||||
spec:
|
||||
serverName: crowdsec-service.crowdsec.svc.cluster.local
|
||||
rootCAs:
|
||||
- secret: crowdsec-root-secret
|
||||
---
|
||||
# backend; the ServersTransport carrying it is a traefik.io CRD consumer and
|
||||
# lives in post-crds/crowdsec-lapi-serverstransport.yaml.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- externalsecret.yaml
|
||||
- helmrelease.yaml
|
||||
|
||||
@@ -96,28 +96,13 @@ The security-scan agent will continue to surface vuln findings on
|
||||
these as "Skipped / Flux image-automation owns this" even without
|
||||
a policy.
|
||||
|
||||
- `ghcr.io/usetrmnl/terminus` — publishes a usable `0.x.0` tag stream, so
|
||||
unlike the three above this is **not** a tag-format problem. It is left
|
||||
unenrolled because the blast radius is hardware: Terminus is pre-1.0,
|
||||
cuts no GitHub Releases and ships no changelog, and it serves the
|
||||
firmware and `/api/display` contract that the TRMNL X panel polls. An
|
||||
unattended bump that changes either can leave a wall-mounted device
|
||||
dark, with no rollback path that doesn't involve the captive portal.
|
||||
Bumps are a reviewed, manual act: read the commit range, then edit the
|
||||
tag in `kubernetes/apps/trmnl/terminus.yaml` (both containers).
|
||||
|
||||
The Valkey sidecar alongside it *is* enrolled (`valkey.yaml`) — it holds
|
||||
only the Sidekiq queue and touches no device-facing contract.
|
||||
|
||||
- `ghcr.io/usetrmnl/trmnl-ha-amd64` — the TRMNL screenshot renderer, for
|
||||
the same reason and one more. It owns the dithering pipeline and the
|
||||
webhook payload shape, so a bad bump surfaces as a *corrupted or blank
|
||||
panel*, not as a failed rollout — there is no readiness signal that
|
||||
catches "the image is now unreadable". Separately, upstream publishes
|
||||
**no multi-arch manifest**: only the arch-suffixed `trmnl-ha-amd64` and
|
||||
`trmnl-ha-aarch64` repositories exist, so an ImagePolicy here would also
|
||||
be pinning us to one architecture in a way the tag does not make
|
||||
obvious. Bump manually in `kubernetes/apps/trmnl/renderer.yaml`.
|
||||
Only a tag-format problem belongs on that list. The policy is to run the
|
||||
latest version and let image-automation hold us there wherever the tags
|
||||
allow it, so an image with a usable semver stream gets enrolled even when
|
||||
a bump carries risk. Where a bad bump is hard to detect, the answer is a
|
||||
note about what to check afterwards — see `apps/trmnl/README.md` for the
|
||||
panel, whose failure mode is a blank screen rather than a failed rollout
|
||||
— not an exclusion.
|
||||
|
||||
## Re-import hazard: cnpg-barman-plugin
|
||||
|
||||
|
||||
@@ -9,9 +9,26 @@ spec:
|
||||
---
|
||||
# v-prefix tags (v19.2.3). Upstream also publishes date-suffixed builds
|
||||
# (v19.2.3-20250717) and moving major/minor tags (v19, v19.2); the
|
||||
# filter pins us to the bare three-component release. Range stays at
|
||||
# 19.x — the 19 → 20 (Tentacle) cutover should be a deliberate
|
||||
# upgrade tied to the Rook operator version.
|
||||
# filter pins us to the bare three-component release.
|
||||
#
|
||||
# The range was 19.x, to keep the 19 -> 20 (Tentacle) cutover a deliberate
|
||||
# upgrade tied to the Rook operator version. That coupling had already come
|
||||
# apart without anyone noticing: Rook's floating constraint pulled v1.20 in
|
||||
# June, and with it a ceph-csi image whose client libraries are Ceph 20.2.1.
|
||||
# The cluster stayed on 19.2.6, so every CSI pod has since been talking to
|
||||
# the mons with a client a whole major ahead of them.
|
||||
#
|
||||
# That is not academic. The 19.2.6 mons mint auth keys as aes256k, and the
|
||||
# 20.2.1 client cannot decode them — "auth: failed to decode key 'AgB...'".
|
||||
# Nothing broke only because the four CSI users predate the cipher change and
|
||||
# still carry legacy aes keys, which Ceph itself flags as insecure. The moment
|
||||
# one is recreated — ceph auth rotate, a deleted Secret, Rook resyncing — the
|
||||
# provisioner stops and no PVC binds. A rebuild rehearsal hits it immediately,
|
||||
# because a fresh cluster has no legacy keys at all.
|
||||
#
|
||||
# So the cutover happens now, with the version the client already is. Rook
|
||||
# v1.20 supports Tentacle from v20.2.1; v20.2.0 is excluded deliberately —
|
||||
# upstream reports data corruption there when read affinity is enabled.
|
||||
apiVersion: image.toolkit.fluxcd.io/v1
|
||||
kind: ImagePolicy
|
||||
metadata:
|
||||
@@ -25,4 +42,4 @@ spec:
|
||||
extract: '$ver'
|
||||
policy:
|
||||
semver:
|
||||
range: "19.x"
|
||||
range: ">=20.2.1 <21.0.0"
|
||||
|
||||
@@ -18,7 +18,9 @@ resources:
|
||||
- mosquitto.yaml
|
||||
- redis.yaml
|
||||
- shelly-manager.yaml
|
||||
- terminus.yaml
|
||||
- tika.yaml
|
||||
- trmnl-renderer.yaml
|
||||
- valkey.yaml
|
||||
- velero-plugin-for-aws.yaml
|
||||
- vsftpd.yaml
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
apiVersion: image.toolkit.fluxcd.io/v1
|
||||
kind: ImageRepository
|
||||
metadata:
|
||||
name: terminus
|
||||
namespace: flux-system
|
||||
spec:
|
||||
image: ghcr.io/usetrmnl/terminus
|
||||
interval: 1h
|
||||
---
|
||||
# Pre-1.0, so the range is 0.x rather than a major pin: upstream's minors are
|
||||
# its release stream, and holding at a single minor is how this drifted four
|
||||
# releases behind while it was excluded from automation.
|
||||
#
|
||||
# A bad bump here surfaces as a blank or corrupted panel rather than a failed
|
||||
# rollout — no readiness probe can tell that a rendered screen became
|
||||
# unreadable. Check the panel after one lands; revert the tag if it is wrong.
|
||||
apiVersion: image.toolkit.fluxcd.io/v1
|
||||
kind: ImagePolicy
|
||||
metadata:
|
||||
name: terminus
|
||||
namespace: flux-system
|
||||
spec:
|
||||
imageRepositoryRef:
|
||||
name: terminus
|
||||
filterTags:
|
||||
pattern: '^(?P<ver>\d+\.\d+\.\d+)$'
|
||||
extract: '$ver'
|
||||
policy:
|
||||
semver:
|
||||
range: "0.x"
|
||||
@@ -0,0 +1,29 @@
|
||||
apiVersion: image.toolkit.fluxcd.io/v1
|
||||
kind: ImageRepository
|
||||
metadata:
|
||||
name: trmnl-renderer
|
||||
namespace: flux-system
|
||||
spec:
|
||||
image: ghcr.io/usetrmnl/trmnl-ha-amd64
|
||||
interval: 1h
|
||||
---
|
||||
# Upstream ships no multi-arch manifest, only the arch-suffixed repositories.
|
||||
# That does not obstruct a policy: the image reference already names the
|
||||
# architecture, so this pins nothing the tag did not.
|
||||
#
|
||||
# The floating `0.9` / `0.10` series tags are filtered out; only fully
|
||||
# qualified patch versions are candidates. Same panel caveat as terminus.yaml.
|
||||
apiVersion: image.toolkit.fluxcd.io/v1
|
||||
kind: ImagePolicy
|
||||
metadata:
|
||||
name: trmnl-renderer
|
||||
namespace: flux-system
|
||||
spec:
|
||||
imageRepositoryRef:
|
||||
name: trmnl-renderer
|
||||
filterTags:
|
||||
pattern: '^(?P<ver>\d+\.\d+\.\d+)$'
|
||||
extract: '$ver'
|
||||
policy:
|
||||
semver:
|
||||
range: "0.x"
|
||||
@@ -72,6 +72,9 @@ spec:
|
||||
# they contend for it. Sized for an inference server plus room for
|
||||
# a transcoder and a benchmark pod alongside it.
|
||||
- -shared-dev-num=4
|
||||
# Advertises gpu.intel.com/monitoring: one resource that grants a
|
||||
# pod every GPU on the node instead of a slot. The exporter needs it.
|
||||
- -enable-monitoring
|
||||
securityContext:
|
||||
seLinuxOptions:
|
||||
type: "container_device_plugin_t"
|
||||
|
||||
@@ -18,6 +18,7 @@ resources:
|
||||
- cloudnative-pg/
|
||||
- mariadb-operator/
|
||||
- netbird-ingress/
|
||||
- netbird-proxy-services/
|
||||
- coredns-config/
|
||||
- k8s-gateway/
|
||||
- external-dns-unifi/
|
||||
|
||||
@@ -30,6 +30,11 @@ spec:
|
||||
labels:
|
||||
app: cni-plugins-installer
|
||||
spec:
|
||||
# Same reasoning as multus-daemonset.yaml. This one installs the macvlan
|
||||
# binary the ceph-san attachment is made of, so a node that cannot
|
||||
# schedule it has no plugin to delegate to — the failure the init
|
||||
# container comment below already describes, reached by a different road.
|
||||
priorityClassName: system-node-critical
|
||||
hostNetwork: true
|
||||
tolerations:
|
||||
- operator: Exists
|
||||
@@ -44,7 +49,32 @@ spec:
|
||||
# at all and put github.com on the critical path of every node rebuild.
|
||||
# Dated tag so a version change is a reviewable diff here, matching the
|
||||
# ci-ansible / alpine-jq convention.
|
||||
image: gitea.renner.uno/gitea_admin/cni-plugins:2026-07-30
|
||||
#
|
||||
# konairius/, not gitea_admin/, and the difference is what makes a
|
||||
# rebuild possible. Gitea runs inside this cluster, so during a rebuild
|
||||
# it does not exist: a rehearsal sat here in ImagePullBackOff with "no
|
||||
# route to host", and because the plugins never installed there was no
|
||||
# macvlan, so Multus could not attach ceph-san, so Rook's network
|
||||
# canary never started, so the CephCluster hung at "Configuring Ceph
|
||||
# Mons" and OpenBao waited forever on a PVC that could not bind. The
|
||||
# failure surfaced four layers from its cause.
|
||||
#
|
||||
# This used to point straight at ghcr.io, which fixed the rebuild and
|
||||
# broke the normal case: every steady-state pull then left the network
|
||||
# for an image the local registry already holds. The fallback belongs
|
||||
# to the node, not to the manifest — machine.registries.mirrors names
|
||||
# Gitea first and ghcr.io second (bootstrap.sh), so nothing here has to
|
||||
# know about the recovery path.
|
||||
#
|
||||
# Containerd keeps the repository path when it falls through, so the
|
||||
# path must resolve on both registries; the build pushes one digest to
|
||||
# konairius/ on each, verified equal. Bumping the tag means building
|
||||
# first — an unpublished tag is unpullable on both.
|
||||
#
|
||||
# No imagePullSecret: the ghcr namespace is private and its credential
|
||||
# is in machine.registries too, because a Secret would have to come
|
||||
# from OpenBao, which needs Ceph, which needs these binaries.
|
||||
image: gitea.renner.uno/konairius/cni-plugins:2026-09-04
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
|
||||
@@ -154,6 +154,40 @@ spec:
|
||||
app: multus
|
||||
name: multus
|
||||
spec:
|
||||
# Upstream ships this DaemonSet with no priority class, so it runs at 0
|
||||
# and can preempt nothing. Any event that makes the pod need a fresh
|
||||
# scheduling decision — a rollout, a node restart, an eviction — then has
|
||||
# to fit it onto a node that may since have filled up, and if it does not
|
||||
# fit it simply stays Pending. The node has no multus after that, and
|
||||
# nothing reports it: pods still start, they just never get their
|
||||
# secondary attachment, and the annotation everything waits on never
|
||||
# appears.
|
||||
#
|
||||
# Seen on a cold rebuild: two of four nodes sat at 99% memory requests
|
||||
# when the DaemonSet rolled, both multus pods went Pending, and Rook's
|
||||
# network canary hung in Init:0/1 waiting for a network-status annotation
|
||||
# that could never come. The CephCluster stayed in Progressing, which is
|
||||
# what infrastructure-ceph-cluster gates on, so the whole apps layer
|
||||
# stopped behind a scheduling decision three layers away.
|
||||
#
|
||||
# node-critical, the same class cilium and cni-dhcp-daemon carry, because
|
||||
# that is what this is: without multus a node cannot attach a secondary
|
||||
# network, and only that node is affected. Nothing cluster-wide fails.
|
||||
#
|
||||
# This does outrank kube-apiserver, which Talos runs as a static pod at
|
||||
# system-cluster-critical (2000000000, against 2000001000 here), and on a
|
||||
# control plane that has run out of memory the kubelet will pick the API
|
||||
# server as the pod to evict. That was observed twice, at 6GiB and at
|
||||
# 8GiB.
|
||||
#
|
||||
# It is not a reason to demote this DaemonSet. Kubernetes puts the CNI
|
||||
# above the control plane deliberately — a node without its CNI cannot run
|
||||
# anything at all — and a class name is a statement about what the pod is,
|
||||
# not a dial for picking a number. The eviction says the control plane is
|
||||
# starved, and that is where it has to be fixed: enough allocatable
|
||||
# memory, or kubeReserved/systemReserved so the static pods keep what they
|
||||
# need. Demoting this to cluster-critical would only move the victim.
|
||||
priorityClassName: system-node-critical
|
||||
hostNetwork: true
|
||||
hostPID: true
|
||||
tolerations:
|
||||
|
||||
@@ -146,6 +146,11 @@ spec:
|
||||
app: whereabouts
|
||||
name: whereabouts
|
||||
spec:
|
||||
# Same reasoning as multus-daemonset.yaml: this is the IPAM half of the
|
||||
# secondary-network path, so a node without it cannot hand out an address
|
||||
# on ceph-san and the attachment fails just as completely as if multus
|
||||
# itself were missing. Upstream leaves the priority class unset.
|
||||
priorityClassName: system-node-critical
|
||||
hostNetwork: true
|
||||
serviceAccountName: whereabouts
|
||||
# Every node here is a control plane node, so the NoSchedule toleration
|
||||
|
||||
@@ -73,11 +73,6 @@ metadata:
|
||||
# classifies documents without experimenting on the live archive.
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: paperless-eval
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: audiobookshelf
|
||||
---
|
||||
@@ -195,3 +190,31 @@ apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: ai
|
||||
---
|
||||
# Where the Woodpecker kubernetes backend spawns pipeline step pods, isolated
|
||||
# from `apps`. Distinct from `woodpecker`, which holds the service itself.
|
||||
#
|
||||
# Here rather than in apps/woodpecker for the reason spelled out above that
|
||||
# namespace: post-crds ships this namespace's CiliumNetworkPolicies via
|
||||
# ../network-policies/, and infrastructure-crds runs before every app KS. With
|
||||
# the namespace declared in the workload KS the order inverted, and a rebuild
|
||||
# stopped with
|
||||
#
|
||||
# CiliumNetworkPolicy/woodpecker-ci/default-deny-ingress not found:
|
||||
# namespaces "woodpecker-ci" not found
|
||||
#
|
||||
# which held infrastructure-crds, and with it all twenty app Kustomizations
|
||||
# that depend on it. The same reasoning was already written down for
|
||||
# `woodpecker` and simply not carried across to its sibling.
|
||||
#
|
||||
# Image-build steps run a privileged docker/buildx plugin pod (see the server's
|
||||
# WOODPECKER_PLUGINS_PRIVILEGED). Talos enforces Pod Security; without the
|
||||
# label the privileged step pods are rejected at admission. The namespace holds
|
||||
# nothing but ephemeral CI step pods, so the exemption stays off apps/.
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: woodpecker-ci
|
||||
labels:
|
||||
app.kubernetes.io/name: woodpecker
|
||||
pod-security.kubernetes.io/enforce: privileged
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- externalsecret.yaml
|
||||
- daemonset.yaml
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
# Reconciles NetBird reverse-proxy services from Ingresses that carry
|
||||
# dns.renner.uno/public: one TLS-passthrough service per host on :443,
|
||||
# targeting Traefik's ClusterIP through a subnet resource over the service
|
||||
# CIDR (ci/images/netbird-proxy-services/controller.py).
|
||||
#
|
||||
# openbao namespace, like the rotators: its default-deny already allows egress
|
||||
# to OpenBao, DNS, the apiserver and `world` (netbird.renner.uno), and the
|
||||
# NetBird PAT is read from OpenBao on every pass through the
|
||||
# netbird-proxy-services role — policies/netbird-proxy-services.hcl, applied
|
||||
# once by hand like the rotator roles. Until that role exists the pod logs an
|
||||
# auth error each minute and does nothing.
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: netbird-proxy-services
|
||||
namespace: openbao
|
||||
labels:
|
||||
app.kubernetes.io/name: netbird-proxy-services
|
||||
---
|
||||
# Read-only, cluster-wide: public Ingresses live in many namespaces, the
|
||||
# Traefik Service in ingress/.
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: netbird-proxy-services-read
|
||||
rules:
|
||||
- apiGroups: ["networking.k8s.io"]
|
||||
resources: ["ingresses"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
- apiGroups: [""]
|
||||
resources: ["services"]
|
||||
verbs: ["get"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: netbird-proxy-services-read
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: ClusterRole
|
||||
name: netbird-proxy-services-read
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: netbird-proxy-services
|
||||
namespace: openbao
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: netbird-proxy-services
|
||||
namespace: openbao
|
||||
labels:
|
||||
app.kubernetes.io/name: netbird-proxy-services
|
||||
spec:
|
||||
replicas: 1
|
||||
# One writer against the management API at a time.
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: netbird-proxy-services
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: netbird-proxy-services
|
||||
spec:
|
||||
serviceAccountName: netbird-proxy-services
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
# Numeric, or the kubelet refuses the image's named user with
|
||||
# "cannot verify user is non-root". The script needs no home and no
|
||||
# writable path, so nobody/nogroup is enough.
|
||||
runAsUser: 65534
|
||||
runAsGroup: 65534
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: controller
|
||||
# Pinned by digest: the date tag is rebuilt on every push of the
|
||||
# image directory (branch and master alike) and the registry keeps
|
||||
# only the newest manifest per tag, so the digest must come from
|
||||
# the MASTER build of the code change.
|
||||
image: gitea.renner.uno/gitea_admin/netbird-proxy-services:2026-09-07@sha256:caffa308d802c6fcc6aea919ac94657c08b993651baa6b8d18de395bcc40523e
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
env:
|
||||
- name: NB_MANAGEMENT_URL
|
||||
value: https://netbird.renner.uno
|
||||
- name: BAO_ADDR
|
||||
value: http://openbao.openbao.svc.cluster.local:8200
|
||||
- name: BAO_ROLE
|
||||
value: netbird-proxy-services
|
||||
# Must match cluster.network.serviceSubnets in talos/patches/cluster.yaml.
|
||||
- name: SERVICE_CIDR
|
||||
value: "10.96.0.0/12"
|
||||
- name: TRAEFIK_SERVICE
|
||||
value: ingress/traefik
|
||||
- name: INTERVAL_SECONDS
|
||||
value: "60"
|
||||
resources:
|
||||
requests:
|
||||
cpu: 10m
|
||||
memory: 32Mi
|
||||
limits:
|
||||
memory: 64Mi
|
||||
@@ -0,0 +1,4 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- deployment.yaml
|
||||
@@ -34,22 +34,6 @@ spec:
|
||||
- ports:
|
||||
- port: "8080"
|
||||
protocol: TCP
|
||||
# The paperless-eval namespace reaches the embedding endpoint for the same
|
||||
# reason paperless does; its chat model runs inside each eval job's own pod
|
||||
# and needs no grant here.
|
||||
#
|
||||
# Scoped to the namespace rather than to an app label, unlike the grants
|
||||
# around it: that namespace exists only to run evaluations, and its
|
||||
# workload is jobs created per measurement whose labels vary with what is
|
||||
# being measured. A label match would be a trap that presents as an
|
||||
# embedding call silently dropped.
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": paperless-eval
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "8080"
|
||||
protocol: TCP
|
||||
# Home Assistant's llama.cpp conversation agent calls the chat model only —
|
||||
# it has no use for the embedding endpoint, so unlike paperless above this
|
||||
# grant does not extend to llama-embed.
|
||||
|
||||
@@ -6,7 +6,6 @@ resources:
|
||||
- ingress-isolation.yaml
|
||||
- authentik-ns.yaml
|
||||
- paperless-ns.yaml
|
||||
- paperless-eval-ns.yaml
|
||||
- audiobookshelf-ns.yaml
|
||||
- home-assistant-ns.yaml
|
||||
- spegel-ns.yaml
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
# Narrower than paperless-ns.yaml, because this namespace serves nobody.
|
||||
#
|
||||
# There is no ingress, no gate outpost and no hostname: the only things that
|
||||
# talk to it are its own pods. Monitoring is admitted so a run can be watched,
|
||||
# and host/remote-node for the kubelet probes.
|
||||
#
|
||||
# Egress reaches the ai namespace for the embedding endpoint, which is the one
|
||||
# dependency an eval job has outside its own pod, and world:443 so the CNPG
|
||||
# recovery can read the backup store on Hetzner.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: default-deny-ingress
|
||||
namespace: paperless-eval
|
||||
spec:
|
||||
endpointSelector: {}
|
||||
ingress:
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": paperless-eval
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": monitoring
|
||||
- fromEntities:
|
||||
- host
|
||||
- remote-node
|
||||
# ICMP — all standard ICMPv4 + ICMPv6 from any cluster pod. See
|
||||
# home-assistant-ns.yaml for the rationale.
|
||||
- fromEndpoints:
|
||||
- matchExpressions:
|
||||
- key: io.kubernetes.pod.namespace
|
||||
operator: Exists
|
||||
icmps:
|
||||
- fields:
|
||||
- { type: 0, family: IPv4 }
|
||||
- { type: 3, family: IPv4 }
|
||||
- { type: 8, family: IPv4 }
|
||||
- { type: 11, family: IPv4 }
|
||||
- { type: 12, family: IPv4 }
|
||||
- { type: 1, family: IPv6 }
|
||||
- { type: 2, family: IPv6 }
|
||||
- { type: 3, family: IPv6 }
|
||||
- { type: 4, family: IPv6 }
|
||||
- { type: 128, family: IPv6 }
|
||||
- { type: 129, family: IPv6 }
|
||||
- { type: 133, family: IPv6 }
|
||||
- { type: 134, family: IPv6 }
|
||||
- { type: 135, family: IPv6 }
|
||||
- { type: 136, family: IPv6 }
|
||||
- { type: 143, family: IPv6 }
|
||||
egress:
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": paperless-eval
|
||||
# The embedding endpoint. The chat model runs inside each job's own pod, so
|
||||
# it needs no grant; the embedder is shared with production because it is
|
||||
# read-only and cheap, and swapping it is a phase-two concern.
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": ai
|
||||
app.kubernetes.io/name: llama-embed
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "8080"
|
||||
protocol: TCP
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": rook-ceph
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": kube-system
|
||||
k8s-app: kube-dns
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "53"
|
||||
protocol: UDP
|
||||
- toEntities:
|
||||
- host
|
||||
- remote-node
|
||||
# world:443 — the CNPG recovery reads the backup store on Hetzner Object
|
||||
# Storage, and image pulls.
|
||||
- toEntities:
|
||||
- world
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "443"
|
||||
protocol: TCP
|
||||
# ICMP — egress mirror; see ingress block above.
|
||||
- toEndpoints:
|
||||
- matchExpressions:
|
||||
- key: io.kubernetes.pod.namespace
|
||||
operator: Exists
|
||||
icmps:
|
||||
- fields:
|
||||
- { type: 0, family: IPv4 }
|
||||
- { type: 3, family: IPv4 }
|
||||
- { type: 8, family: IPv4 }
|
||||
- { type: 11, family: IPv4 }
|
||||
- { type: 12, family: IPv4 }
|
||||
- { type: 1, family: IPv6 }
|
||||
- { type: 2, family: IPv6 }
|
||||
- { type: 3, family: IPv6 }
|
||||
- { type: 4, family: IPv6 }
|
||||
- { type: 128, family: IPv6 }
|
||||
- { type: 129, family: IPv6 }
|
||||
- { type: 133, family: IPv6 }
|
||||
- { type: 134, family: IPv6 }
|
||||
- { type: 135, family: IPv6 }
|
||||
- { type: 136, family: IPv6 }
|
||||
- { type: 143, family: IPv6 }
|
||||
---
|
||||
# The CNPG operator polls each instance's status endpoint over HTTPS
|
||||
# (https://<pod-ip>:8000/pg/status) to populate Cluster status. Without it the
|
||||
# Cluster reports "Instance Status Extraction Error: HTTP communication issue"
|
||||
# while Postgres serves normally — issue #433, where the operator's dial timed
|
||||
# out because the apps baseline never allowed the database namespace.
|
||||
#
|
||||
# It matters here even for a throwaway: the Flux Kustomization waits on
|
||||
# Cluster status, and a smoke test that cannot report its own health is not one.
|
||||
#
|
||||
# Scoped to the pprleval-pg endpoints and that one port, like the equivalent
|
||||
# rules for wp-pg and ha-pg: the barman and mariadb operators sharing the
|
||||
# database namespace have no reason to reach anything here.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: pprleval-pg-allow-cnpg-operator
|
||||
namespace: paperless-eval
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
cnpg.io/cluster: pprleval-pg
|
||||
ingress:
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": database
|
||||
app.kubernetes.io/name: cloudnative-pg
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "8000"
|
||||
protocol: TCP
|
||||
@@ -25,10 +25,10 @@ spec:
|
||||
name: openbao
|
||||
namespace: openbao
|
||||
install:
|
||||
# A fresh OpenBao pod is 0/1 Ready until `bao operator init` + unseal
|
||||
# (readiness probe reports sealed). helm --wait would time out and
|
||||
# remediation would uninstall the release, so don't wait here; the
|
||||
# init/unseal procedure is manual (see kubernetes/MIGRATION-sops-openbao.md).
|
||||
# A fresh OpenBao pod is 0/1 Ready until `bao operator init` (readiness
|
||||
# reports sealed). helm --wait would time out and remediation would
|
||||
# uninstall the release, so don't wait here; init is manual (see
|
||||
# kubernetes/MIGRATION-sops-openbao.md). Unseal is automatic (static seal).
|
||||
disableWait: true
|
||||
remediation:
|
||||
retries: 3
|
||||
@@ -42,6 +42,11 @@ spec:
|
||||
# the agent-injector mutating webhook is unused surface.
|
||||
enabled: false
|
||||
server:
|
||||
# The static seal key, from the openbao-seal-key Secret (bootstrap.sh).
|
||||
extraSecretEnvironmentVars:
|
||||
- envName: BAO_SEAL_KEY
|
||||
secretName: openbao-seal-key
|
||||
secretKey: key
|
||||
# Raft (not standalone/file) for two reasons: `bao operator raft
|
||||
# snapshot` as a consistent backup primitive, and real HA across three
|
||||
# nodes. TLS terminates at the Traefik ingress; in-cluster traffic is
|
||||
@@ -53,16 +58,8 @@ spec:
|
||||
# the moment one pod is sealed or rescheduled, which is worse than
|
||||
# the single replica it replaced. Three tolerates one.
|
||||
#
|
||||
# What this buys, and what it does not: a node drain seals the replica
|
||||
# on it, and raft elects a new leader from the remaining standbys
|
||||
# within seconds, so the service does not go down. It does NOT remove
|
||||
# the manual unseal — a sealed server can neither serve requests nor
|
||||
# act as standby, so that replica stays out until someone unseals it.
|
||||
#
|
||||
# The consequence for rolling node maintenance is worth stating: a
|
||||
# Talos upgrade drains all three nodes in turn, sealing each replica
|
||||
# as it goes. Unseal each one as it comes back, or the third drain
|
||||
# takes the last unsealed replica with it and quorum is lost anyway.
|
||||
# A restarted replica unseals itself from the static seal and rejoins
|
||||
# raft, so a rolling reboot only ever costs one replica at a time.
|
||||
replicas: 3
|
||||
raft:
|
||||
enabled: true
|
||||
@@ -111,6 +108,14 @@ spec:
|
||||
|
||||
service_registration "kubernetes" {}
|
||||
|
||||
# Auto-unseal. The key is a 32-byte AES key in the openbao-seal-key
|
||||
# Secret, created from pass at bootstrap before this release. The
|
||||
# former shamir key is the recovery key (#805).
|
||||
seal "static" {
|
||||
current_key_id = "1"
|
||||
current_key = "env://BAO_SEAL_KEY"
|
||||
}
|
||||
|
||||
audit "file" "stdout" {
|
||||
description = "audit trail to pod stdout -> Loki"
|
||||
options {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# OpenBao policy: netbird-proxy-services
|
||||
#
|
||||
# The reverse-proxy-services controller (Deployment netbird-proxy-services in
|
||||
# the openbao namespace) reads the NetBird PAT on every reconcile pass and
|
||||
# calls the management API with it. Read-only on that one path; it shares the
|
||||
# bootstrap credential with rotator-netbird but not that role's PATCH rights.
|
||||
#
|
||||
# NOT applied by Flux. Apply ONCE with an admin token:
|
||||
#
|
||||
# bao policy write netbird-proxy-services kubernetes/infrastructure/openbao/policies/netbird-proxy-services.hcl
|
||||
# bao write auth/kubernetes/role/netbird-proxy-services \
|
||||
# bound_service_account_names=netbird-proxy-services \
|
||||
# bound_service_account_namespaces=openbao \
|
||||
# policies=netbird-proxy-services ttl=15m
|
||||
#
|
||||
# The PAT itself is the one rotator-netbird already uses
|
||||
# (homelab/infrastructure/secret-rotators-bootstrap/netbird, key `pat`).
|
||||
#
|
||||
# Do NOT set token_no_default_policy=true (kv-v2 preflight needs the default policy).
|
||||
|
||||
path "homelab/data/infrastructure/secret-rotators-bootstrap/netbird" {
|
||||
capabilities = ["read"]
|
||||
}
|
||||
@@ -10,33 +10,8 @@
|
||||
# name hairpins off the ingress LB from a pod. Needs BOTH netpol halves
|
||||
# (gitea-allow-flux-rotator + rotator-gitea-flux-egress).
|
||||
#
|
||||
# The ExternalSecret MERGES into Flux's existing flux-system secret (does not
|
||||
# own it) so a sync failure leaves the last-good value in place; Flux's
|
||||
# source-controller reads it fresh each fetch, no restart. OpenBao policy/role
|
||||
# rotator-gitea-flux + the gitea-flux bootstrap (flux account password) are
|
||||
# provisioned by hand (see policies/rotator-gitea-flux.hcl).
|
||||
apiVersion: external-secrets.io/v1
|
||||
kind: ExternalSecret
|
||||
metadata:
|
||||
name: flux-system-git-auth
|
||||
namespace: flux-system
|
||||
spec:
|
||||
refreshInterval: 10m
|
||||
secretStoreRef:
|
||||
kind: ClusterSecretStore
|
||||
name: openbao
|
||||
target:
|
||||
name: flux-system
|
||||
creationPolicy: Merge
|
||||
data:
|
||||
- secretKey: username
|
||||
remoteRef:
|
||||
key: infrastructure/flux-git
|
||||
property: username
|
||||
- secretKey: password
|
||||
remoteRef:
|
||||
key: infrastructure/flux-git
|
||||
property: token
|
||||
# The ExternalSecret that publishes the rotated token to Flux lives in
|
||||
# ../../post-crds/flux-git-externalsecret.yaml (needs ESO's CRDs).
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
# secret is stored anywhere; each run logs in fresh.
|
||||
#
|
||||
# A SEALED OpenBao fails the login step and the Job goes Failed — that is
|
||||
# deliberate signal, not noise: it means unseal was forgotten after a pod
|
||||
# restart and backups have stopped.
|
||||
# deliberate signal, not noise: it means the static seal could not unseal a
|
||||
# restarted pod and backups have stopped.
|
||||
#
|
||||
# Retention: last 7 snapshots on the PVC; velero adds 30 d offsite.
|
||||
apiVersion: batch/v1
|
||||
|
||||
@@ -25,13 +25,13 @@ spec:
|
||||
stop: "fd99::ff"
|
||||
---
|
||||
# Cilium BGPv2 control-plane config peering each node with the UDM over the
|
||||
# management LAN — never over v30iot, which is the untrusted IoT segment.
|
||||
# ingress VLAN 47 — never over v30iot, which is the untrusted IoT segment.
|
||||
# The source address comes from the kernel routing decision, so no explicit
|
||||
# localAddress override is needed.
|
||||
#
|
||||
# Two parallel sessions, one per address family:
|
||||
# - v4 session: peer 10.2.40.1, family IPv4 unicast only
|
||||
# - v6 session: peer fd40::1, family IPv6 unicast only
|
||||
# - v4 session: peer 10.2.47.1, family IPv4 unicast only
|
||||
# - v6 session: peer fd02:47::1, family IPv6 unicast only
|
||||
# This avoids the "v6 NLRIs over v4 transport" path, which requires
|
||||
# bilateral extended-nexthop capability negotiation (RFC 5549/8950).
|
||||
# Cilium 1.19 BGPv2 advertises the capability only for the IPv4 Unicast
|
||||
@@ -74,14 +74,14 @@ spec:
|
||||
peers:
|
||||
- name: udm-v6
|
||||
peerASN: 64512
|
||||
# ULA — stable across ISP renumber events. UDM's br40 has
|
||||
# fd40::1/64; nodes get fd40::<eui-64> via SLAAC on eth0.
|
||||
peerAddress: fd40::1
|
||||
# ULA — stable across ISP renumber events. UDM's br47 has
|
||||
# fd02:47::1/64; nodes get fd02:47::<eui-64> via SLAAC on ingress0.
|
||||
peerAddress: fd02:47::1
|
||||
peerConfigRef:
|
||||
name: bgp-peer-udm-v6
|
||||
- name: udm-v4
|
||||
peerASN: 64512
|
||||
peerAddress: 10.2.40.1
|
||||
peerAddress: 10.2.47.1
|
||||
peerConfigRef:
|
||||
name: bgp-peer-udm-v4
|
||||
---
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# traefik.io CRD consumer for the public LAPI endpoint (crowdsec/lapi-ingress.yaml
|
||||
# holds the Ingress). The CA it names is the chart's self-signed LAPI CA.
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: ServersTransport
|
||||
metadata:
|
||||
name: crowdsec-lapi
|
||||
namespace: crowdsec
|
||||
spec:
|
||||
serverName: crowdsec-service.crowdsec.svc.cluster.local
|
||||
rootCAs:
|
||||
- secret: crowdsec-root-secret
|
||||
@@ -0,0 +1,60 @@
|
||||
# Keeps Flux's flux-system secret supplied with the rotated Gitea credential
|
||||
# WITHOUT owning it, so a sync failure leaves the last-good value in place;
|
||||
# Flux's source-controller reads it fresh each fetch, no restart. OpenBao
|
||||
# policy/role rotator-gitea-flux + the gitea-flux bootstrap (flux account
|
||||
# password) are provisioned by hand (see ../openbao/policies/rotator-gitea-flux.hcl).
|
||||
#
|
||||
# See creationPolicy below for why that is Orphan and not Merge — the
|
||||
# distinction is what makes a recovery from the break-glass source possible.
|
||||
#
|
||||
# Split out of ../openbao/rotation/flux-git.yaml, which still holds the
|
||||
# rotation CronJob: external-secrets.io CRDs are installed by the ESO
|
||||
# HelmRelease in the infrastructure Kustomization, so this manifest cannot
|
||||
# pass server-side dry-run there on a fresh cluster.
|
||||
apiVersion: external-secrets.io/v1
|
||||
kind: ExternalSecret
|
||||
metadata:
|
||||
name: flux-system-git-auth
|
||||
namespace: flux-system
|
||||
spec:
|
||||
refreshInterval: 10m
|
||||
secretStoreRef:
|
||||
kind: ClusterSecretStore
|
||||
name: openbao
|
||||
target:
|
||||
name: flux-system
|
||||
# Orphan, not Merge: create the Secret if it is absent, but never own it.
|
||||
#
|
||||
# Merge cannot create — it only writes into a Secret that already exists,
|
||||
# on the assumption that `flux bootstrap` made one. That assumption breaks
|
||||
# in exactly the situation this cluster is rebuilt from. The break-glass
|
||||
# bootstrap pulls from the GitHub mirror and keeps its token in
|
||||
# flux-system-breakglass (bootstrap.sh), so `flux-system` is never created,
|
||||
# so this ExternalSecret has nothing to merge into — and reports Ready while
|
||||
# producing no Secret at all. The operator then performs the documented
|
||||
# handover back to Gitea and finds the GitRepository pointing at a Secret
|
||||
# that does not exist.
|
||||
#
|
||||
# Owner would create it but attach an ownerReference, so deleting this
|
||||
# object would take Flux's credential with it. Orphan creates and updates
|
||||
# without ownership, which is what the paragraph at the top of this file
|
||||
# actually wanted: a sync failure, or the loss of this object, leaves the
|
||||
# last-good value in place.
|
||||
#
|
||||
# Safe on a live cluster: the Secret holds exactly the two keys written
|
||||
# below (verified against production), so writing it whole is the same
|
||||
# result as merging into it.
|
||||
creationPolicy: Orphan
|
||||
# Explicit rather than relying on the default, because it is the half that
|
||||
# matters here: if the OpenBao entry ever disappears, keep serving the
|
||||
# credential Flux already has rather than leaving it with none.
|
||||
deletionPolicy: Retain
|
||||
data:
|
||||
- secretKey: username
|
||||
remoteRef:
|
||||
key: infrastructure/flux-git
|
||||
property: username
|
||||
- secretKey: password
|
||||
remoteRef:
|
||||
key: infrastructure/flux-git
|
||||
property: token
|
||||
@@ -10,4 +10,26 @@ resources:
|
||||
# configured (2026-07-13): a store that can't turn Ready would block this
|
||||
# KS's wait gate and freeze the dependsOn chain.
|
||||
- openbao-clustersecretstore.yaml
|
||||
# ExternalSecrets. external-secrets.io CRDs come from the ESO HelmRelease in
|
||||
# the infrastructure Kustomization, so these cannot pass its server-side
|
||||
# dry-run on a fresh cluster — Flux rejects the whole apply set, including
|
||||
# the ESO HelmRelease that would have installed the CRDs. They sat next to
|
||||
# their consumers until a cold-rebuild test hit that deadlock (2026-09-01);
|
||||
# nothing had ever applied this layer against an empty API server before.
|
||||
- crowdsec-bouncer-key-externalsecret.yaml
|
||||
- crowdsec-edge-bouncer-key-externalsecret.yaml
|
||||
- external-dns-unifi-externalsecret.yaml
|
||||
- flux-git-externalsecret.yaml
|
||||
- netbird-ingress-externalsecret.yaml
|
||||
- velero-externalsecret.yaml
|
||||
- velero-hetzner-creds-externalsecret.yaml
|
||||
# traefik.io CRDs, same reasoning — installed by the Traefik HelmRelease in
|
||||
# infrastructure. Traefik is deliberately absent from that Kustomization's
|
||||
# healthChecks, so this KS may dry-run before the chart lands; it retries on
|
||||
# retryInterval rather than deadlocking, because nothing here installs the
|
||||
# CRDs it is waiting for.
|
||||
- traefik-crowdsec-middleware.yaml
|
||||
- traefik-rate-limit-middleware.yaml
|
||||
- traefik-security-headers-middleware.yaml
|
||||
- crowdsec-lapi-serverstransport.yaml
|
||||
- ../network-policies/
|
||||
|
||||
@@ -27,7 +27,6 @@ spec:
|
||||
- ingress
|
||||
- monitoring
|
||||
- paperless
|
||||
- paperless-eval # same apps/paperless key plus the shared barman S3 creds
|
||||
- trmnl
|
||||
- velero
|
||||
- woodpecker # wp-pg barman creds; woodpecker moving out of `apps` (#433)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user