Compare commits
150
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54cdb0a2a5 | ||
|
|
1a1e095f76 | ||
|
|
11df090913 | ||
|
|
cfb1406201 | ||
|
|
4190c634ec | ||
|
|
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 | ||
|
|
8123021ea4 | ||
|
|
1ec6825b66 | ||
|
|
3f50fa4e60 | ||
|
|
f54b1dc7fa | ||
|
|
63ae57ce6e | ||
|
|
73db641d47 | ||
|
|
220bf6bfc3 | ||
|
|
85768cd7e4 | ||
|
|
5f2955cdde | ||
|
|
7136a39f3a | ||
|
|
4ba68b91aa | ||
|
|
47ed4383f1 | ||
|
|
ec849f2e29 | ||
|
|
43634e46c9 | ||
|
|
7658cf8bb6 | ||
|
|
1ae0e59ea6 | ||
|
|
faebeb1594 | ||
|
|
baf3b3e1b7 | ||
|
|
a358e8cfd5 | ||
|
|
319968c0f1 | ||
|
|
043d1af4d4 | ||
|
|
11b0def055 | ||
|
|
71a4c1b3a7 | ||
|
|
0c46a219e3 | ||
|
|
17e23202d1 | ||
|
|
b8116a1cf0 | ||
|
|
3563a9e67e | ||
|
|
088fce90a4 | ||
|
|
851e1b9ef6 | ||
|
|
f6b0789c01 | ||
|
|
9ee8cfb4bc | ||
|
|
325baf7047 | ||
|
|
44636a8268 | ||
|
|
440cbaec90 | ||
|
|
b64f971373 | ||
|
|
9ff85f7d1f | ||
|
|
22d92d158d | ||
|
|
a667920bb0 | ||
|
|
53fe8efa02 | ||
|
|
b00ca34783 | ||
|
|
c2f47e58bc | ||
|
|
a7bba7acbb | ||
|
|
e0a00f6b05 | ||
|
|
1ab901d746 | ||
|
|
18d3f3c850 |
@@ -42,7 +42,7 @@ when:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: fetch-alerts
|
- 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:
|
environment:
|
||||||
SOPS_AGE_KEY:
|
SOPS_AGE_KEY:
|
||||||
from_secret: sops_age_key
|
from_secret: sops_age_key
|
||||||
|
|||||||
+131
-12
@@ -19,12 +19,13 @@
|
|||||||
# the admin account could (#330).
|
# the admin account could (#330).
|
||||||
#
|
#
|
||||||
# The token is still OWNED by gitea_admin, because Gitea scopes packages to
|
# 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
|
# a user or org namespace and most of these images live under `gitea_admin/*`.
|
||||||
# different account cannot publish there. Moving them to a dedicated org is
|
# The rebuild-critical ones additionally publish to `konairius/` — an org
|
||||||
# the remaining cleanup; it is a 38-reference migration across pipelines and
|
# created for the ghcr mirror path, with gitea_admin as a member so this same
|
||||||
# Flux-deployed CronJobs, so it is tracked separately in #330 rather than
|
# token can push there. The full gitea_admin/* -> org migration is a
|
||||||
# bundled here. Note this pipeline runs no LLM agent, so the exfil path that
|
# 38-reference change tracked separately in #330 rather than bundled here.
|
||||||
# motivated the other cutovers does not apply to it.
|
# Note this pipeline runs no LLM agent, so the exfil path that motivated the
|
||||||
|
# other cutovers does not apply to it.
|
||||||
when:
|
when:
|
||||||
- event: push
|
- event: push
|
||||||
path:
|
path:
|
||||||
@@ -87,6 +88,30 @@ steps:
|
|||||||
--destination gitea.renner.uno/gitea_admin/ci-k8s:$TAG \
|
--destination gitea.renner.uno/gitea_admin/ci-k8s:$TAG \
|
||||||
--destination gitea.renner.uno/gitea_admin/ci-k8s:latest
|
--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
|
- name: build-tofu
|
||||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
|
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
|
||||||
directory: ci/images/tofu
|
directory: ci/images/tofu
|
||||||
@@ -147,6 +172,28 @@ steps:
|
|||||||
# not for who uses it — its first consumer is the zigbee2mqtt seed-data init
|
# 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
|
# container, but nothing about it is zigbee-specific. Exists so tools come from
|
||||||
# the image rather than an `apk add` on every container start.
|
# 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
|
- name: build-alpine-jq
|
||||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
|
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
|
||||||
directory: ci/images/alpine-jq
|
directory: ci/images/alpine-jq
|
||||||
@@ -159,23 +206,47 @@ steps:
|
|||||||
GITEA_USER: gitea_admin
|
GITEA_USER: gitea_admin
|
||||||
GITEA_TOKEN:
|
GITEA_TOKEN:
|
||||||
from_secret: ci_images_token
|
from_secret: ci_images_token
|
||||||
|
GHCR_USER: konairius
|
||||||
|
GHCR_TOKEN:
|
||||||
|
from_secret: ghcr_mirror_token
|
||||||
commands:
|
commands:
|
||||||
- mkdir -p /kaniko/.docker
|
- mkdir -p /kaniko/.docker
|
||||||
- AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n')
|
- 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)
|
- TAG=$(date +%Y-%m-%d)
|
||||||
- |
|
- |
|
||||||
/kaniko/executor \
|
/kaniko/executor \
|
||||||
--context . \
|
--context . \
|
||||||
--dockerfile Containerfile \
|
--dockerfile Containerfile \
|
||||||
--destination gitea.renner.uno/gitea_admin/alpine-jq:$TAG \
|
--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
|
# Runtime image, not CI tooling: upstream containernetworking/plugins binaries
|
||||||
# baked in, consumed by the multus cni-plugins-installer DaemonSet. Replaces a
|
# 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
|
# 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,
|
# critical path of every node rebuild. The download now happens here, once,
|
||||||
# where the sha256 is checked — see the Containerfile for the full rationale.
|
# 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
|
- name: build-cni-plugins
|
||||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
|
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
|
||||||
directory: ci/images/cni-plugins
|
directory: ci/images/cni-plugins
|
||||||
@@ -188,23 +259,36 @@ steps:
|
|||||||
GITEA_USER: gitea_admin
|
GITEA_USER: gitea_admin
|
||||||
GITEA_TOKEN:
|
GITEA_TOKEN:
|
||||||
from_secret: ci_images_token
|
from_secret: ci_images_token
|
||||||
|
GHCR_USER: konairius
|
||||||
|
GHCR_TOKEN:
|
||||||
|
from_secret: ghcr_mirror_token
|
||||||
commands:
|
commands:
|
||||||
- mkdir -p /kaniko/.docker
|
- mkdir -p /kaniko/.docker
|
||||||
- AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n')
|
- 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)
|
- TAG=$(date +%Y-%m-%d)
|
||||||
- |
|
- |
|
||||||
/kaniko/executor \
|
/kaniko/executor \
|
||||||
--context . \
|
--context . \
|
||||||
--dockerfile Containerfile \
|
--dockerfile Containerfile \
|
||||||
--destination gitea.renner.uno/gitea_admin/cni-plugins:$TAG \
|
--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
|
# 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
|
# 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
|
# 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
|
# 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.
|
# 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
|
- name: build-alpine-openssl
|
||||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
|
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
|
||||||
directory: ci/images/alpine-openssl
|
directory: ci/images/alpine-openssl
|
||||||
@@ -217,17 +301,25 @@ steps:
|
|||||||
GITEA_USER: gitea_admin
|
GITEA_USER: gitea_admin
|
||||||
GITEA_TOKEN:
|
GITEA_TOKEN:
|
||||||
from_secret: ci_images_token
|
from_secret: ci_images_token
|
||||||
|
GHCR_USER: konairius
|
||||||
|
GHCR_TOKEN:
|
||||||
|
from_secret: ghcr_mirror_token
|
||||||
commands:
|
commands:
|
||||||
- mkdir -p /kaniko/.docker
|
- mkdir -p /kaniko/.docker
|
||||||
- AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n')
|
- 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)
|
- TAG=$(date +%Y-%m-%d)
|
||||||
- |
|
- |
|
||||||
/kaniko/executor \
|
/kaniko/executor \
|
||||||
--context . \
|
--context . \
|
||||||
--dockerfile Containerfile \
|
--dockerfile Containerfile \
|
||||||
--destination gitea.renner.uno/gitea_admin/alpine-openssl:$TAG \
|
--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
|
# 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
|
# run as two services on edge-1. Replaces pip-into-venv on the host, where an
|
||||||
@@ -257,3 +349,30 @@ steps:
|
|||||||
--dockerfile Containerfile \
|
--dockerfile Containerfile \
|
||||||
--destination gitea.renner.uno/gitea_admin/heating-can:$TAG \
|
--destination gitea.renner.uno/gitea_admin/heating-can:$TAG \
|
||||||
--destination gitea.renner.uno/gitea_admin/heating-can:latest
|
--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.
|
# run that never touched them. Tracked in #461.
|
||||||
- name: kustomize-build
|
- name: kustomize-build
|
||||||
depends_on: [] # see note above `steps:`
|
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:
|
commands:
|
||||||
- |
|
- |
|
||||||
set -eu
|
set -eu
|
||||||
@@ -158,7 +158,7 @@ steps:
|
|||||||
# whenever it trails the deployed server. If you bump the server, bump here.
|
# whenever it trails the deployed server. If you bump the server, bump here.
|
||||||
- name: woodpecker-lint
|
- name: woodpecker-lint
|
||||||
depends_on: [] # see note above `steps:`
|
depends_on: [] # see note above `steps:`
|
||||||
image: woodpeckerci/woodpecker-cli:v3.17.0-alpine
|
image: woodpeckerci/woodpecker-cli:v3.18.0-alpine
|
||||||
commands:
|
commands:
|
||||||
- |
|
- |
|
||||||
set -eu
|
set -eu
|
||||||
@@ -246,7 +246,7 @@ steps:
|
|||||||
# this step fails permanently on its own documentation.
|
# this step fails permanently on its own documentation.
|
||||||
- name: no-digest-pins
|
- name: no-digest-pins
|
||||||
depends_on: [] # see note above `steps:`
|
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:
|
commands:
|
||||||
- |
|
- |
|
||||||
set -eu
|
set -eu
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ when:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: e2e
|
- 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:
|
environment:
|
||||||
SOPS_AGE_KEY:
|
SOPS_AGE_KEY:
|
||||||
from_secret: sops_age_key
|
from_secret: sops_age_key
|
||||||
|
|||||||
+1
-1
@@ -31,7 +31,7 @@ steps:
|
|||||||
- sh scripts/ci/test-model-selection.sh
|
- sh scripts/ci/test-model-selection.sh
|
||||||
|
|
||||||
- name: e2e-test
|
- 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:
|
environment:
|
||||||
SOPS_AGE_KEY:
|
SOPS_AGE_KEY:
|
||||||
from_secret: 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);
|
# (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
|
# minting a 1h read-only token per run is cheap. Degrades to
|
||||||
# snapshot-only if the ci-diagnostics ServiceAccount isn't reconciled.
|
# 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:
|
environment:
|
||||||
SOPS_AGE_KEY:
|
SOPS_AGE_KEY:
|
||||||
from_secret: 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
|
# 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
|
# backend can't put the proxy into a restart loop. Shared by both the URL-probe
|
||||||
# watchdog and the flood watchdog below.
|
# watchdog and the flood watchdog below. One minute: the route-manager wedge
|
||||||
netbird_watchdog_cooldown_seconds: 600
|
# 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
|
# 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
|
# 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
|
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 VELERO_VERSION=v1.16.0
|
||||||
ARG KUBECTL_VERSION=v1.36.2
|
ARG KUBECTL_VERSION=v1.36.3
|
||||||
ARG FLUX_VERSION=2.9.2
|
ARG FLUX_VERSION=2.9.5
|
||||||
|
|
||||||
RUN apk add --no-cache \
|
RUN apk add --no-cache \
|
||||||
aws-cli \
|
aws-cli \
|
||||||
@@ -18,7 +18,7 @@ RUN apk add --no-cache \
|
|||||||
|
|
||||||
RUN curl -sfL -o /usr/local/bin/talosctl \
|
RUN curl -sfL -o /usr/local/bin/talosctl \
|
||||||
"https://github.com/siderolabs/talos/releases/download/${TALOS_VERSION}/talosctl-linux-amd64" \
|
"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
|
&& chmod +x /usr/local/bin/talosctl
|
||||||
|
|
||||||
RUN curl -sfL -o /usr/local/bin/kubectl \
|
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 \
|
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" \
|
"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 \
|
&& tar -xzf /tmp/flux.tar.gz -C /usr/local/bin flux \
|
||||||
&& chmod +x /usr/local/bin/flux \
|
&& chmod +x /usr/local/bin/flux \
|
||||||
&& rm -f /tmp/flux.tar.gz
|
&& 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
|
kubectl -n openbao exec -it openbao-0 -- bao operator unseal # paste key
|
||||||
```
|
```
|
||||||
|
|
||||||
**Manual unseal is required after every pod restart** (node drain, upgrade,
|
**Unseal is automatic since 2026-09-08** (static seal, PR #825, issue #805):
|
||||||
crash), **per replica** — the server runs three raft peers (see
|
the seal key lives in the `openbao-seal-key` Secret (created by `bootstrap.sh`
|
||||||
`infrastructure/openbao/helmrelease.yaml`), and a sealed one is neither a
|
from `pass show homelab/openbao/seal-key`) and every restarted replica unseals
|
||||||
voter nor a standby, so it stays out of the cluster until unsealed:
|
itself. The shamir key above became the **recovery key** in the migration; it
|
||||||
`kubectl -n openbao exec -it openbao-<N> -- bao operator unseal`, same key for
|
is kept in `pass` under its old name, `homelab/openbao/unseal-key`. The server
|
||||||
all three. Three peers mean one sealed replica is survivable; two are not.
|
runs three raft peers (see `infrastructure/openbao/helmrelease.yaml`); a sealed
|
||||||
Accepted for now; auto-unseal needs a second trust root (transit/KMS) and is
|
one is neither a voter nor a standby, so a replica that stays sealed after a
|
||||||
explicitly out of scope. Symptom of sealed: ESO stores go NotReady, synced
|
restart — wrong or missing seal-key Secret — costs redundancy, two cost
|
||||||
Secrets keep their last value (they are real k8s Secrets — pods keep running).
|
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)
|
## 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
|
- **Backups:** ✅ nightly raft-snapshot CronJob (03:00, keeps 7 on a
|
||||||
dedicated PVC; see infrastructure/openbao/snapshot-cronjob.yaml) + velero
|
dedicated PVC; see infrastructure/openbao/snapshot-cronjob.yaml) + velero
|
||||||
`openbao-daily` Schedule (06:15) ships that PVC offsite, 30 d retention.
|
`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).
|
`pass`, outside the cluster (GPG key backed up externally 2026-07-14).
|
||||||
A failed snapshot Job is the sealed-after-restart alarm. Still owed:
|
A failed snapshot Job is the sealed-after-restart alarm. Still owed:
|
||||||
one tested restore before phase 4.
|
one tested restore before phase 4.
|
||||||
- **Cluster rebuild order:** Flux brings up openbao (sealed, empty) → restore
|
- **Cluster rebuild order:** seal-key Secret from `pass` → Flux brings up
|
||||||
raft snapshot → unseal (key from `pass`) → ClusterSecretStore Ready → ESO
|
openbao (empty) → init → restore raft snapshot → replicas unseal themselves
|
||||||
materializes Secrets → apps start. Until unseal, secret-consuming apps
|
→ ClusterSecretStore Ready → ESO materializes Secrets → apps start
|
||||||
simply stay Pending on missing Secrets — same as any missing dependency.
|
(`bootstrap.sh flux` does all of it; RUNBOOK-cold-rebuild.md Step 4 by
|
||||||
- **Break-glass:** `pass` holds unseal key + root token (GPG, offline-capable).
|
hand). Until then, secret-consuming apps simply stay Pending on missing
|
||||||
With those two, everything is recoverable from a raft snapshot alone.
|
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:** `ansible/vault-password.sh` reads
|
||||||
`ANSIBLE_VAULT_PASSWORD` from the environment with no offline backup as of
|
`ANSIBLE_VAULT_PASSWORD` from the environment with no offline backup as of
|
||||||
2026-07-14. It gates decryption of several Ansible-Vault files (PBS
|
2026-07-14. It gates decryption of several Ansible-Vault files (PBS
|
||||||
|
|||||||
@@ -432,8 +432,8 @@ is why it belongs here.
|
|||||||
among its devices but not `ens19`, because `ens19` is deliberately
|
among its devices but not `ens19`, because `ens19` is deliberately
|
||||||
address-less. Cilium therefore owns `mgmt0` and sees VLAN 40 frames already
|
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
|
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
|
on the shared parent `uplink`, still tagged. Any future address-less VLAN
|
||||||
VLAN carrying pod traffic will need the same treatment.
|
carrying pod traffic will need the same treatment.
|
||||||
|
|
||||||
The symptom does not look like a VLAN problem. A tc-layer drop reaches an
|
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
|
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
|
`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
|
files decrypt with it. (Post-OpenBao-phase-4 this may be retired — see
|
||||||
`MIGRATION-sops-openbao.md`.)
|
`MIGRATION-sops-openbao.md`.)
|
||||||
- [ ] **OpenBao unseal key + root token** — in the GPG `pass` store
|
- [ ] **OpenBao seal key, recovery key + root token** — in the GPG `pass`
|
||||||
(`homelab/openbao/unseal-key`, `homelab/openbao/root-token`), and the GPG
|
store (`homelab/openbao/seal-key`, `homelab/openbao/unseal-key` — the
|
||||||
private key backed up off-machine. Without the unseal key, no OpenBao
|
former shamir key, now the recovery key — and `homelab/openbao/root-token`),
|
||||||
snapshot can be restored and no secret-consuming app can start.
|
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
|
- [ ] **OpenBao raft snapshot** reachable — nightly PVC snapshots
|
||||||
(`infrastructure/openbao/snapshot-cronjob.yaml`) plus the Velero
|
(`infrastructure/openbao/snapshot-cronjob.yaml`) plus the Velero
|
||||||
`openbao-daily` offsite copy. In a total-loss rebuild the offsite 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`
|
> Steady-state (non-rebuild) bootstrap is just `kubernetes/bootstrap.sh flux`
|
||||||
> with `GITEA_TOKEN` set; `FLUX_SOURCE` defaults to `gitea`.
|
> 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
|
`bootstrap.sh flux` does this step itself (`restore_openbao`): it creates the
|
||||||
unsealed, the `ClusterSecretStore` stays NotReady, ESO cannot materialize
|
`openbao-seal-key` Secret from `pass` before Flux brings OpenBao up, initialises
|
||||||
Secrets, and every secret-consuming app stays Pending. This step is **manual**
|
the empty server, restores the newest raft snapshot straight out of the Velero
|
||||||
(the unseal key lives in `pass`, off-cluster by design).
|
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
|
```sh
|
||||||
# 1. Wait for the OpenBao pods to exist (all three sealed, 0/1 Ready is expected).
|
# 1. The seal key, before the pods start (a pod without it never starts).
|
||||||
kubectl -n openbao get pods
|
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,
|
# (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 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.
|
# 4. The server seals against the snapshot's keyring and unseals itself from
|
||||||
# A sealed server is neither a raft voter nor a standby, so unsealing only
|
# the static seal; openbao-1 and openbao-2 follow (OrderedReady). Nothing
|
||||||
# openbao-0 leaves the three-peer cluster one node short of quorum.
|
# to type. Afterwards the tokens and recovery key from pass apply again.
|
||||||
kubectl -n openbao exec -it openbao-0 -- bao operator unseal # paste homelab/openbao/unseal-key
|
kubectl -n openbao get pods -w
|
||||||
kubectl -n openbao exec -it openbao-1 -- bao operator unseal # same key
|
|
||||||
kubectl -n openbao exec -it openbao-2 -- bao operator unseal # same key
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Verify the store recovers before moving on:
|
Verify the store recovers before moving on:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
kubectl get clustersecretstore openbao # want: Valid / Ready
|
kubectl -n openbao exec openbao-0 -- bao status # want: Seal Type static, Sealed false
|
||||||
kubectl -n <ns> get externalsecret # want: SecretSynced
|
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
|
> A replica that stays **sealed** after a restart could not use the static
|
||||||
> during rebuilds, and for *each* replica that restarts — a rolling node
|
> seal — the Secret is missing or does not match the key the data was
|
||||||
> drain seals all three in turn. Details + rationale:
|
> written under. Fix the Secret and restart the pod; `bao operator unseal`
|
||||||
> `MIGRATION-sops-openbao.md`.
|
> 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
|
## 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
|
- **`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
|
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.
|
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
|
Its `flux` phase carries the OpenBao restore (Step 4); the seal key and the
|
||||||
unseal key must not live in CI; see issue #187 on whether to wire a
|
other secrets are read from `pass` or streamed in at run time and never live
|
||||||
semi-automated OpenBao restore.
|
in CI.
|
||||||
- **OpenBao seals on every restart and each replica must be unsealed
|
- **OpenBao unseals itself, but only under the seal key the snapshot was
|
||||||
individually.** Three raft replicas mean the service survives losing one, but
|
written with.** Three raft replicas mean the service survives losing one; a
|
||||||
a sealed server is neither a voter nor a standby — unsealing only one leaves
|
sealed server is neither a voter nor a standby. A pod that stays sealed after
|
||||||
the cluster short of quorum. Step 4 covers this; it is repeated here because
|
a restart has the wrong `openbao-seal-key` Secret — Step 4 covers this; it is
|
||||||
it is the step most likely to be half-done under pressure.
|
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
|
- 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
|
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
|
it — the nodes hold no address on that segment — but a rebuilt cluster will
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ metadata:
|
|||||||
namespace: audiobookshelf
|
namespace: audiobookshelf
|
||||||
labels:
|
labels:
|
||||||
app.kubernetes.io/name: audiobookshelf
|
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:
|
spec:
|
||||||
replicas: 1
|
replicas: 1
|
||||||
strategy:
|
strategy:
|
||||||
|
|||||||
@@ -120,7 +120,14 @@ spec:
|
|||||||
server:
|
server:
|
||||||
resources:
|
resources:
|
||||||
requests:
|
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
|
memory: 1Gi
|
||||||
limits:
|
limits:
|
||||||
memory: 2Gi
|
memory: 2Gi
|
||||||
@@ -154,7 +161,11 @@ spec:
|
|||||||
worker:
|
worker:
|
||||||
resources:
|
resources:
|
||||||
requests:
|
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
|
memory: 1Gi
|
||||||
limits:
|
limits:
|
||||||
memory: 2Gi
|
memory: 2Gi
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ resources:
|
|||||||
- prometheus-gate-egress.yaml
|
- prometheus-gate-egress.yaml
|
||||||
- wordpress-gate-egress.yaml
|
- wordpress-gate-egress.yaml
|
||||||
- llama-gate-egress.yaml
|
- llama-gate-egress.yaml
|
||||||
- outpost-servicemonitors.yaml
|
|
||||||
- ldap-outpost-cert.yaml
|
- ldap-outpost-cert.yaml
|
||||||
- ldap-outpost-lb.yaml
|
- ldap-outpost-lb.yaml
|
||||||
- ldap-outpost-cnp.yaml
|
- ldap-outpost-cnp.yaml
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ spec:
|
|||||||
memory: 32Mi
|
memory: 32Mi
|
||||||
containers:
|
containers:
|
||||||
- name: home-assistant
|
- 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:
|
env:
|
||||||
- name: TZ
|
- name: TZ
|
||||||
value: Europe/Berlin
|
value: Europe/Berlin
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ spec:
|
|||||||
# Two processes rather than one: socat carries a single address pair per
|
# Two processes rather than one: socat carries a single address pair per
|
||||||
# invocation, and the families need different receive and send addresses.
|
# invocation, and the families need different receive and send addresses.
|
||||||
# They are independent — one crashing does not blind the other — and each
|
# 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:
|
containers:
|
||||||
# -u is load-bearing on both: unidirectional, left to right. Without it
|
# -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
|
# 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
|
# UDP4-SENDTO takes its A record and UDP6-SENDTO its AAAA. See
|
||||||
# mdns-relay-service.yaml for why that indirection is required and why
|
# mdns-relay-service.yaml for why that indirection is required and why
|
||||||
# the Service is dual-stack.
|
# 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
|
- name: socat-ipv4
|
||||||
image: alpine/socat:1.8.0.3@sha256:beb4a68d9e4fe6b0f21ea774a0fde6c31f580dde6368939ed70100c5385b015e
|
image: alpine/socat:1.8.0.3@sha256:beb4a68d9e4fe6b0f21ea774a0fde6c31f580dde6368939ed70100c5385b015e
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
args:
|
command:
|
||||||
- -u
|
- /bin/sh
|
||||||
- "UDP4-RECV:5353,ip-add-membership=224.0.0.251:net1,so-bindtodevice=net1,reuseaddr"
|
- -c
|
||||||
- "UDP4-SENDTO:home-assistant-mdns.home-assistant.svc.cluster.local:5353"
|
- |
|
||||||
# No probes. socat is a single process with no health surface: if it
|
trap 'kill "$pid"; exit 0' TERM INT
|
||||||
# exits, the container exits and the kubelet restarts it, which is the
|
while :; do
|
||||||
# entire liveness story. A probe here could only re-assert that PID 1
|
socat -u \
|
||||||
# is running.
|
"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:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
cpu: 10m
|
cpu: 10m
|
||||||
@@ -114,10 +126,20 @@ spec:
|
|||||||
- name: socat-ipv6
|
- name: socat-ipv6
|
||||||
image: alpine/socat:1.8.0.3@sha256:beb4a68d9e4fe6b0f21ea774a0fde6c31f580dde6368939ed70100c5385b015e
|
image: alpine/socat:1.8.0.3@sha256:beb4a68d9e4fe6b0f21ea774a0fde6c31f580dde6368939ed70100c5385b015e
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
args:
|
command:
|
||||||
- -u
|
- /bin/sh
|
||||||
- "UDP6-RECV:5353,ipv6-join-group=[ff02::fb]:net1,so-bindtodevice=net1,ipv6-v6only=1,reuseaddr"
|
- -c
|
||||||
- "UDP6-SENDTO:home-assistant-mdns.home-assistant.svc.cluster.local:5353"
|
- |
|
||||||
|
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:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
cpu: 10m
|
cpu: 10m
|
||||||
|
|||||||
@@ -57,7 +57,14 @@ spec:
|
|||||||
# and leaves this one untouched (tags are not deleted), so this keeps
|
# and leaves this one untouched (tags are not deleted), so this keeps
|
||||||
# resolving — but the image then came from the branch build rather than
|
# resolving — but the image then came from the branch build rather than
|
||||||
# master. Re-point it at a master-built tag when convenient.
|
# 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:
|
command:
|
||||||
- sh
|
- sh
|
||||||
- -c
|
- -c
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ spec:
|
|||||||
type: RuntimeDefault
|
type: RuntimeDefault
|
||||||
containers:
|
containers:
|
||||||
- name: immich-machine-learning
|
- 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.2 # {"$imagepolicy": "flux-system:immich-machine-learning"}
|
||||||
ports:
|
ports:
|
||||||
- name: http
|
- name: http
|
||||||
containerPort: 3003
|
containerPort: 3003
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ metadata:
|
|||||||
namespace: immich
|
namespace: immich
|
||||||
labels:
|
labels:
|
||||||
app.kubernetes.io/name: immich-server
|
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:
|
spec:
|
||||||
# Scaled to 0 alongside immich-machine-learning during single-node
|
# Scaled to 0 alongside immich-machine-learning during single-node
|
||||||
# maintenance; see the note there. The CNPG Postgres cluster stays running,
|
# maintenance; see the note there. The CNPG Postgres cluster stays running,
|
||||||
@@ -84,7 +87,7 @@ spec:
|
|||||||
mountPath: /ca
|
mountPath: /ca
|
||||||
containers:
|
containers:
|
||||||
- name: immich-server
|
- 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.2 # {"$imagepolicy": "flux-system:immich-server"}
|
||||||
ports:
|
ports:
|
||||||
- name: http
|
- name: http
|
||||||
containerPort: 3001
|
containerPort: 3001
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ metadata:
|
|||||||
app.kubernetes.io/name: llama-server
|
app.kubernetes.io/name: llama-server
|
||||||
spec:
|
spec:
|
||||||
replicas: 1
|
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
|
# 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.
|
# iGPU and into the same node's RAM before releasing the first.
|
||||||
strategy:
|
strategy:
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ metadata:
|
|||||||
app.kubernetes.io/name: llama-embed
|
app.kubernetes.io/name: llama-embed
|
||||||
spec:
|
spec:
|
||||||
replicas: 1
|
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:
|
strategy:
|
||||||
type: Recreate
|
type: Recreate
|
||||||
selector:
|
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.
|
# endpoint, which proves reachability, not that anything was written.
|
||||||
- prometheusrule-cnpg-backup.yaml
|
- prometheusrule-cnpg-backup.yaml
|
||||||
- alloy-servicemonitor.yaml
|
- alloy-servicemonitor.yaml
|
||||||
|
- cilium-servicemonitor.yaml
|
||||||
- cnpg-podmonitors.yaml
|
- cnpg-podmonitors.yaml
|
||||||
- crowdsec-servicemonitor.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
|
- home-assistant-servicemonitor.yaml
|
||||||
- immich-servicemonitor.yaml
|
- immich-servicemonitor.yaml
|
||||||
- loki-servicemonitor.yaml
|
- loki-servicemonitor.yaml
|
||||||
- smartctl-exporter-servicemonitor.yaml
|
- smartctl-exporter-servicemonitor.yaml
|
||||||
|
- intel-gpu-exporter-servicemonitor.yaml
|
||||||
- probes-blackbox.yaml
|
- probes-blackbox.yaml
|
||||||
|
|||||||
@@ -34,7 +34,9 @@ spec:
|
|||||||
# query parameter, and an exact match would break silently if the
|
# query parameter, and an exact match would break silently if the
|
||||||
# instance label ever encodes it differently.
|
# instance label ever encodes it differently.
|
||||||
expr: probe_success{job="blackbox-http", instance=~"https://openbao\\.renner\\.uno/v1/sys/health.*"} == 0
|
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:
|
labels:
|
||||||
severity: critical
|
severity: critical
|
||||||
annotations:
|
annotations:
|
||||||
|
|||||||
@@ -28,24 +28,28 @@ spec:
|
|||||||
# scheduling would also fire this — but both want a human too, so the
|
# scheduling would also fire this — but both want a human too, so the
|
||||||
# imprecision costs nothing.
|
# imprecision costs nothing.
|
||||||
- alert: OpenBaoReplicaNotReady
|
- alert: OpenBaoReplicaNotReady
|
||||||
# A replica that is sealed never recovers on its own, so the
|
# A replica unseals itself from the static seal, so a restart is
|
||||||
# duration is only there to ignore ordinary rescheduling: a healthy
|
# Ready again in well under a minute; the duration only ignores that
|
||||||
# pod moving between nodes is Ready again in well under a minute.
|
# ordinary rescheduling. A replica still sealed after it never
|
||||||
expr: kube_statefulset_status_replicas_ready{namespace="openbao", statefulset="openbao"} < 3
|
# 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
|
for: 10m
|
||||||
labels:
|
labels:
|
||||||
severity: warning
|
severity: warning
|
||||||
annotations:
|
annotations:
|
||||||
summary: "OpenBao has {{ $value }} of 3 replicas ready"
|
summary: "OpenBao has {{ $value }} of 3 replicas ready"
|
||||||
description: >-
|
description: >-
|
||||||
An OpenBao replica has been unavailable for 10 minutes. The usual
|
An OpenBao replica has been unavailable for 10 minutes. A restart
|
||||||
cause is a sealed replica after a node drain — every restart
|
unseals itself from the static seal, so this is not the ordinary
|
||||||
seals it, and it stays out of the raft cluster until unsealed by
|
drain case. The service is still up on the remaining replicas,
|
||||||
hand. The service is still up on the remaining replicas, so
|
so nothing is broken yet, but redundancy is reduced and a second
|
||||||
nothing is broken yet, but redundancy is reduced and a second
|
failure would cost quorum. Check `kubectl -n openbao get pods`,
|
||||||
drain would cost quorum. Check `kubectl -n openbao get pods` and
|
the pod log and `bao status` in the not-ready pod. If it is
|
||||||
`bao status` in the not-ready pod; unseal with the key from
|
sealed, the static seal failed: verify the openbao-seal-key
|
||||||
`pass` (homelab/openbao/unseal-key).
|
Secret matches `pass show homelab/openbao/seal-key`.
|
||||||
|
|
||||||
- alert: OpenBaoQuorumLost
|
- alert: OpenBaoQuorumLost
|
||||||
# Raft quorum of a three-node cluster is two. At one ready replica
|
# Raft quorum of a three-node cluster is two. At one ready replica
|
||||||
@@ -53,11 +57,10 @@ spec:
|
|||||||
# the ClusterSecretStore goes InvalidProviderConfig and no
|
# the ClusterSecretStore goes InvalidProviderConfig and no
|
||||||
# ExternalSecret can refresh.
|
# ExternalSecret can refresh.
|
||||||
#
|
#
|
||||||
# Shorter window than the warning above because this is an outage,
|
# No window: below quorum the secret store is down and every
|
||||||
# not a degradation — but not zero, so a rolling restart that
|
# ExternalSecret stalls with it, so waiting only delays the page.
|
||||||
# briefly dips does not page.
|
expr: max by (namespace, statefulset) (kube_statefulset_status_replicas_ready{namespace="openbao", statefulset="openbao"}) < 2
|
||||||
expr: kube_statefulset_status_replicas_ready{namespace="openbao", statefulset="openbao"} < 2
|
for: 0m
|
||||||
for: 5m
|
|
||||||
labels:
|
labels:
|
||||||
severity: critical
|
severity: critical
|
||||||
annotations:
|
annotations:
|
||||||
@@ -67,9 +70,11 @@ spec:
|
|||||||
quorum and the secret store is unavailable. ExternalSecrets stop
|
quorum and the secret store is unavailable. ExternalSecrets stop
|
||||||
refreshing; existing Secrets keep their last values, so failures
|
refreshing; existing Secrets keep their last values, so failures
|
||||||
surface only when something rotates or a new secret is needed.
|
surface only when something rotates or a new secret is needed.
|
||||||
Unseal the sealed replicas (key in `pass`,
|
Replicas unseal themselves from the static seal; one that stays
|
||||||
homelab/openbao/unseal-key) — each one individually, since a
|
sealed could not. Check its log and the openbao-seal-key Secret
|
||||||
sealed server cannot be a standby.
|
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
|
- alert: OpenBaoMetricsMissing
|
||||||
# Guards the two alerts above. Both are threshold comparisons, so if
|
# 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
|
# VeleroBackupFailed alert fires on the first failure regardless — so
|
||||||
# nothing is hidden, only the escalation to critical is deferred
|
# nothing is hidden, only the escalation to critical is deferred
|
||||||
# until a second cycle confirms it.
|
# 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: |
|
expr: |
|
||||||
time() - velero_backup_last_successful_timestamp{schedule!=""} > 60 * 3600
|
(
|
||||||
and
|
time() - velero_backup_last_successful_timestamp{schedule!=""} > 60 * 3600
|
||||||
velero_backup_last_successful_timestamp{schedule!=""} > 0
|
and
|
||||||
|
velero_backup_last_successful_timestamp{schedule!=""} > 0
|
||||||
|
)
|
||||||
|
and on(schedule)
|
||||||
|
velero_backup_last_status
|
||||||
for: 10m
|
for: 10m
|
||||||
labels:
|
labels:
|
||||||
severity: critical
|
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
|
- name: dashboard-infrastructure-overview
|
||||||
files:
|
files:
|
||||||
- infrastructure-overview.json
|
- infrastructure-overview.json
|
||||||
|
- name: dashboard-intel-gpu
|
||||||
|
files:
|
||||||
|
- intel-gpu.json
|
||||||
- name: dashboard-netbird-overview
|
- name: dashboard-netbird-overview
|
||||||
files:
|
files:
|
||||||
- netbird-overview.json
|
- 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
|
- prometheus-gate-ingress.yaml
|
||||||
- ci-diagnostics-rbac.yaml
|
- ci-diagnostics-rbac.yaml
|
||||||
- smartctl-exporter.yaml
|
- smartctl-exporter.yaml
|
||||||
|
- intel-gpu-exporter.yaml
|
||||||
- dashboards/
|
- dashboards/
|
||||||
# prometheusrule-garage.yaml lives in ./monitoring-rules/ and is applied by a
|
# prometheusrule-garage.yaml lives in ./monitoring-rules/ and is applied by a
|
||||||
# separate Flux Kustomization that dependsOn `monitoring` — otherwise the
|
# 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
|
# net1 instead. master: ens19 resolves on every k8s-{1,2,3}/edge
|
||||||
# worker, though it is no longer the same kind of device everywhere:
|
# 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
|
# 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).
|
# bpf.vlanBypass to carry it (issue #592).
|
||||||
k8s.v1.cni.cncf.io/networks: music-assistant-iot
|
k8s.v1.cni.cncf.io/networks: music-assistant-iot
|
||||||
spec:
|
spec:
|
||||||
@@ -55,7 +55,7 @@ spec:
|
|||||||
# stdlib only; Flux keeps the tag in lockstep via the shared image
|
# stdlib only; Flux keeps the tag in lockstep via the shared image
|
||||||
# policy) — no extra dependency.
|
# policy) — no extra dependency.
|
||||||
- name: reconcile-publish-ip
|
- 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"]
|
command: ["/app/venv/bin/python", "/scripts/reconcile.py"]
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: data
|
- name: data
|
||||||
@@ -65,7 +65,7 @@ spec:
|
|||||||
readOnly: true
|
readOnly: true
|
||||||
containers:
|
containers:
|
||||||
- name: music-assistant
|
- 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:
|
env:
|
||||||
- name: LOG_LEVEL
|
- name: LOG_LEVEL
|
||||||
value: info
|
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
|
type: RuntimeDefault
|
||||||
containers:
|
containers:
|
||||||
- name: gotenberg
|
- name: gotenberg
|
||||||
image: gotenberg/gotenberg:8.36.0 # {"$imagepolicy": "flux-system:gotenberg"}
|
image: gotenberg/gotenberg:8.37.0 # {"$imagepolicy": "flux-system:gotenberg"}
|
||||||
args:
|
args:
|
||||||
- "gotenberg"
|
- "gotenberg"
|
||||||
- "--chromium-disable-javascript=true"
|
- "--chromium-disable-javascript=true"
|
||||||
|
|||||||
@@ -143,7 +143,20 @@ spec:
|
|||||||
# on every paperless pod start: tools belong in the image, not
|
# on every paperless pod start: tools belong in the image, not
|
||||||
# installed at runtime. Dated tag so a change is a reviewable diff
|
# installed at runtime. Dated tag so a change is a reviewable diff
|
||||||
# here, matching the ci-ansible / alpine-jq convention.
|
# 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:
|
command:
|
||||||
- sh
|
- sh
|
||||||
- -c
|
- -c
|
||||||
@@ -170,7 +183,7 @@ spec:
|
|||||||
drop: ["ALL"]
|
drop: ["ALL"]
|
||||||
containers:
|
containers:
|
||||||
- name: paperless
|
- 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:
|
ports:
|
||||||
- name: http
|
- name: http
|
||||||
containerPort: 8000
|
containerPort: 8000
|
||||||
@@ -325,7 +338,6 @@ spec:
|
|||||||
- port: 80
|
- port: 80
|
||||||
targetPort: http
|
targetPort: http
|
||||||
---
|
---
|
||||||
---
|
|
||||||
# ExternalName mirror so the paperless-namespace Ingress can backend to
|
# ExternalName mirror so the paperless-namespace Ingress can backend to
|
||||||
# the gate outpost in the authentik namespace (cross-namespace Service
|
# the gate outpost in the authentik namespace (cross-namespace Service
|
||||||
# refs aren't supported in standard Ingress).
|
# 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
|
# This used to be THE device endpoint, on the premise that the firmware
|
||||||
# internal CA, so terminating TLS here would simply break polling. Scope of
|
# "cannot be handed the internal CA". The premise was wrong: nothing here
|
||||||
# the exposure: this Service is LAN-only (no public DNS label, no Cloudflare
|
# hands out an internal CA for these names — letsencrypt-cluster solves
|
||||||
# record), it carries no admin session — the device authenticates with its
|
# DNS-01 against the renner.uno Cloudflare zone and issues publicly-trusted
|
||||||
# MAC in the `ID` header plus a per-device api_key — and the Terminus admin
|
# certificates for LAN-only hosts, which is why trmnl.renner.uno has always
|
||||||
# UI stays on the TLS route at trmnl.renner.uno.
|
# 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
|
# 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
|
# pool.
|
||||||
# repo. The device is provisioned with the HOSTNAME, not the address.
|
|
||||||
#
|
#
|
||||||
# The address is requested explicitly, matching every other LB service here.
|
# The address is requested explicitly, matching every other LB service here.
|
||||||
# Left to allocate freely, Cilium IPAM handed out the first free address in
|
# Left to allocate freely, Cilium IPAM handed out the first free address in
|
||||||
@@ -30,8 +39,10 @@ metadata:
|
|||||||
labels:
|
labels:
|
||||||
app.kubernetes.io/name: terminus
|
app.kubernetes.io/name: terminus
|
||||||
lb-pool: bgp
|
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:
|
annotations:
|
||||||
external-dns.alpha.kubernetes.io/hostname: trmnl-device.renner.uno
|
|
||||||
lbipam.cilium.io/ips: "10.2.99.89,fd99::89"
|
lbipam.cilium.io/ips: "10.2.99.89,fd99::89"
|
||||||
spec:
|
spec:
|
||||||
type: LoadBalancer
|
type: LoadBalancer
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ resources:
|
|||||||
- valkey.yaml
|
- valkey.yaml
|
||||||
- terminus.yaml
|
- terminus.yaml
|
||||||
- ingress.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
|
- device-service.yaml
|
||||||
# Headless HA screenshotter. Lives in this namespace because Terminus
|
# Headless HA screenshotter. Lives in this namespace because Terminus
|
||||||
# FETCHES rendered screens back from it (uri delivery mode), so the
|
# 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
|
# webhook start failing with "expired JWT access token" about
|
||||||
# thirty minutes after seeding.
|
# thirty minutes after seeding.
|
||||||
- name: render-schedule
|
- 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:
|
command:
|
||||||
- sh
|
- sh
|
||||||
- -c
|
- -c
|
||||||
@@ -227,16 +234,12 @@ spec:
|
|||||||
capabilities:
|
capabilities:
|
||||||
drop: ["ALL"]
|
drop: ["ALL"]
|
||||||
containers:
|
containers:
|
||||||
# Pinned, not enrolled in image automation — same reasoning as
|
# Enrolled in image automation (flux-system:trmnl-renderer). A bad
|
||||||
# Terminus itself. This drives a wall-mounted panel and upstream
|
# bump shows up as a corrupted screen, not a failed rollout — check
|
||||||
# is pre-1.0; an unattended bump that changes the dithering
|
# the panel after one lands. ARCH-SUFFIXED image: upstream publishes
|
||||||
# pipeline or the webhook payload shows up as a corrupted
|
# no multi-arch manifest.
|
||||||
# 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.
|
|
||||||
- name: renderer
|
- 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:
|
ports:
|
||||||
- name: http
|
- name: http
|
||||||
containerPort: 10000
|
containerPort: 10000
|
||||||
|
|||||||
@@ -7,11 +7,17 @@ data:
|
|||||||
TZ: "Europe/Berlin"
|
TZ: "Europe/Berlin"
|
||||||
HANAMI_PORT: "2345"
|
HANAMI_PORT: "2345"
|
||||||
# What the device is told to fetch from. Terminus bakes this into the
|
# 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
|
# absolute `image_url` it hands back on /api/display, so it has to be a
|
||||||
# cleartext LAN name, not trmnl.renner.uno — the firmware speaks plain
|
# name the DEVICE can resolve and fetch — and it has to carry the same
|
||||||
# HTTP and would reject the internal CA. No trailing slash: the same
|
# scheme the device polls with, or every screen fetch is a second,
|
||||||
# string is typed into the captive portal at provisioning time.
|
# cleartext connection. No trailing slash: the same string is typed into
|
||||||
API_URI: "http://trmnl-device.renner.uno"
|
# 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"
|
KEYVALUE_URL: "redis://terminus-valkey:6379"
|
||||||
# Rack::Attack rate-limit bypass. Beyond upstream's RFC1918 defaults this
|
# 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
|
# 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
|
# is a device-bricking class of risk. Bumps are a reviewed, manual
|
||||||
# act — see kubernetes/infrastructure/image-automation/README.md.
|
# act — see kubernetes/infrastructure/image-automation/README.md.
|
||||||
- name: terminus
|
- name: terminus
|
||||||
image: ghcr.io/usetrmnl/terminus:0.68.0
|
image: ghcr.io/usetrmnl/terminus:0.73.0 # {"$imagepolicy": "flux-system:terminus"}
|
||||||
ports:
|
ports:
|
||||||
- name: http
|
- name: http
|
||||||
containerPort: 2345
|
containerPort: 2345
|
||||||
@@ -191,7 +197,7 @@ spec:
|
|||||||
capabilities:
|
capabilities:
|
||||||
drop: ["ALL"]
|
drop: ["ALL"]
|
||||||
- name: sidekiq
|
- 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"]
|
command: ["bundle", "exec", "sidekiq", "-r", "./config/sidekiq.rb"]
|
||||||
envFrom:
|
envFrom:
|
||||||
- configMapRef:
|
- configMapRef:
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ spec:
|
|||||||
spec:
|
spec:
|
||||||
containers:
|
containers:
|
||||||
- name: whisper
|
- 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:
|
args:
|
||||||
- --model=base-int8
|
- --model=base-int8
|
||||||
- --language=de
|
- --language=de
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ spec:
|
|||||||
# Image automation, range 3.x (see image-automation/woodpecker.yaml).
|
# Image automation, range 3.x (see image-automation/woodpecker.yaml).
|
||||||
# MUST stay version-matched with woodpecker-server (gRPC parity) — both
|
# MUST stay version-matched with woodpecker-server (gRPC parity) — both
|
||||||
# track the same range/filter so they bump together on each release.
|
# 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:
|
env:
|
||||||
- name: WOODPECKER_SERVER
|
- name: WOODPECKER_SERVER
|
||||||
value: woodpecker.woodpecker.svc.cluster.local:9000 # in-cluster gRPC, replaces LXC :9000
|
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
|
apiVersion: v1
|
||||||
kind: ServiceAccount
|
kind: ServiceAccount
|
||||||
metadata:
|
metadata:
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ spec:
|
|||||||
# minor + patch auto-update within 3.x; only the 4.0 major is held for
|
# 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
|
# a reviewed bump. Safe now the DB is postgres (the old sqlite-surprise
|
||||||
# -bump hazard is gone). Stays in lockstep with the agent.
|
# -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:
|
env:
|
||||||
- name: WOODPECKER_HOST
|
- name: WOODPECKER_HOST
|
||||||
value: https://ci.renner.uno
|
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
|
||||||
- name: infrastructure-crds
|
- name: infrastructure-crds
|
||||||
- name: infrastructure-ceph-cluster
|
- 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
|
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||||
kind: Kustomization
|
kind: Kustomization
|
||||||
|
|||||||
@@ -122,8 +122,20 @@ spec:
|
|||||||
path: ./kubernetes/infrastructure/rook-ceph-cluster
|
path: ./kubernetes/infrastructure/rook-ceph-cluster
|
||||||
prune: true
|
prune: true
|
||||||
wait: true
|
wait: true
|
||||||
dependsOn:
|
# Deliberately NOT dependsOn: infrastructure. That edge closed a cycle no
|
||||||
- name: infrastructure
|
# 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
|
# kstatus doesn't know how to read Rook CR readiness, so without these
|
||||||
# this Kustomization reports Ready as soon as the manifests apply — long
|
# this Kustomization reports Ready as soon as the manifests apply — long
|
||||||
# before OSDs/mons/MDS are actually serving I/O. Apps depending on this
|
# 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
|
# 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
|
# sandbox creation fails (#452). cilium_bpf_map_pressure makes it
|
||||||
# alertable beforehand.
|
# 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
|
enabled: true
|
||||||
serviceMonitor:
|
serviceMonitor:
|
||||||
enabled: true
|
enabled: false
|
||||||
bpf:
|
bpf:
|
||||||
# Per-endpoint policy map size: one entry per (identity, direction,
|
# Per-endpoint policy map size: one entry per (identity, direction,
|
||||||
# protocol, port) allowed. When it fills, endpoint regeneration fails
|
# 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.
|
# the supernets here cover the LAN as well, which is the point.
|
||||||
ipv4NativeRoutingCIDR: 10.0.0.0/8
|
ipv4NativeRoutingCIDR: 10.0.0.0/8
|
||||||
ipv6NativeRoutingCIDR: fd00::/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.
|
# with no marker. Re-apply this block, or the skew silently returns.
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
stringData:
|
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
|
kind: Secret
|
||||||
metadata:
|
metadata:
|
||||||
name: plugin-barman-cloud-kg5c2795f7
|
name: plugin-barman-cloud-kg5c2795f7
|
||||||
@@ -998,7 +998,7 @@ spec:
|
|||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
key: SIDECAR_IMAGE
|
key: SIDECAR_IMAGE
|
||||||
name: plugin-barman-cloud-kg5c2795f7
|
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
|
name: barman-cloud
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 9090
|
- containerPort: 9090
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ spec:
|
|||||||
# required.
|
# required.
|
||||||
#
|
#
|
||||||
# NOTE: enabling this makes the LAPI serve HTTPS-only. The Traefik ingress
|
# 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
|
# 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).
|
# plugin at https + trusting this CA (or giving it a bouncer-ou client cert).
|
||||||
tls:
|
tls:
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
kind: Kustomization
|
kind: Kustomization
|
||||||
resources:
|
resources:
|
||||||
- bouncer-key-externalsecret.yaml
|
|
||||||
- edge-bouncer-key-externalsecret.yaml
|
|
||||||
- lapi-ingress.yaml
|
- lapi-ingress.yaml
|
||||||
- helmrelease.yaml
|
- helmrelease.yaml
|
||||||
|
|||||||
@@ -31,22 +31,12 @@
|
|||||||
# serves read-only decision streams to a valid bouncer key and nothing else.
|
# 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
|
# The admin surface is cscli, in-cluster. If the source address ever becomes
|
||||||
# deterministic, an ipAllowList can be added on top.
|
# deterministic, an ipAllowList can be added on top.
|
||||||
---
|
#
|
||||||
# The LAPI serves HTTPS only once tls.enabled is set — there is no plaintext
|
# 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
|
# 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
|
# 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
|
# backend; the ServersTransport carrying it is a traefik.io CRD consumer and
|
||||||
# what the Service resolves to.
|
# lives in post-crds/crowdsec-lapi-serverstransport.yaml.
|
||||||
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
|
|
||||||
---
|
|
||||||
apiVersion: networking.k8s.io/v1
|
apiVersion: networking.k8s.io/v1
|
||||||
kind: Ingress
|
kind: Ingress
|
||||||
metadata:
|
metadata:
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
kind: Kustomization
|
kind: Kustomization
|
||||||
resources:
|
resources:
|
||||||
- externalsecret.yaml
|
|
||||||
- helmrelease.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
|
these as "Skipped / Flux image-automation owns this" even without
|
||||||
a policy.
|
a policy.
|
||||||
|
|
||||||
- `ghcr.io/usetrmnl/terminus` — publishes a usable `0.x.0` tag stream, so
|
Only a tag-format problem belongs on that list. The policy is to run the
|
||||||
unlike the three above this is **not** a tag-format problem. It is left
|
latest version and let image-automation hold us there wherever the tags
|
||||||
unenrolled because the blast radius is hardware: Terminus is pre-1.0,
|
allow it, so an image with a usable semver stream gets enrolled even when
|
||||||
cuts no GitHub Releases and ships no changelog, and it serves the
|
a bump carries risk. Where a bad bump is hard to detect, the answer is a
|
||||||
firmware and `/api/display` contract that the TRMNL X panel polls. An
|
note about what to check afterwards — see `apps/trmnl/README.md` for the
|
||||||
unattended bump that changes either can leave a wall-mounted device
|
panel, whose failure mode is a blank screen rather than a failed rollout
|
||||||
dark, with no rollback path that doesn't involve the captive portal.
|
— not an exclusion.
|
||||||
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`.
|
|
||||||
|
|
||||||
## Re-import hazard: cnpg-barman-plugin
|
## Re-import hazard: cnpg-barman-plugin
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,26 @@ spec:
|
|||||||
---
|
---
|
||||||
# v-prefix tags (v19.2.3). Upstream also publishes date-suffixed builds
|
# 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
|
# (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
|
# filter pins us to the bare three-component release.
|
||||||
# 19.x — the 19 → 20 (Tentacle) cutover should be a deliberate
|
#
|
||||||
# upgrade tied to the Rook operator version.
|
# 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
|
apiVersion: image.toolkit.fluxcd.io/v1
|
||||||
kind: ImagePolicy
|
kind: ImagePolicy
|
||||||
metadata:
|
metadata:
|
||||||
@@ -25,4 +42,4 @@ spec:
|
|||||||
extract: '$ver'
|
extract: '$ver'
|
||||||
policy:
|
policy:
|
||||||
semver:
|
semver:
|
||||||
range: "19.x"
|
range: ">=20.2.1 <21.0.0"
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ resources:
|
|||||||
- mosquitto.yaml
|
- mosquitto.yaml
|
||||||
- redis.yaml
|
- redis.yaml
|
||||||
- shelly-manager.yaml
|
- shelly-manager.yaml
|
||||||
|
- terminus.yaml
|
||||||
- tika.yaml
|
- tika.yaml
|
||||||
|
- trmnl-renderer.yaml
|
||||||
- valkey.yaml
|
- valkey.yaml
|
||||||
- velero-plugin-for-aws.yaml
|
- velero-plugin-for-aws.yaml
|
||||||
- vsftpd.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
|
# they contend for it. Sized for an inference server plus room for
|
||||||
# a transcoder and a benchmark pod alongside it.
|
# a transcoder and a benchmark pod alongside it.
|
||||||
- -shared-dev-num=4
|
- -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:
|
securityContext:
|
||||||
seLinuxOptions:
|
seLinuxOptions:
|
||||||
type: "container_device_plugin_t"
|
type: "container_device_plugin_t"
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ resources:
|
|||||||
- cloudnative-pg/
|
- cloudnative-pg/
|
||||||
- mariadb-operator/
|
- mariadb-operator/
|
||||||
- netbird-ingress/
|
- netbird-ingress/
|
||||||
|
- netbird-proxy-services/
|
||||||
- coredns-config/
|
- coredns-config/
|
||||||
- k8s-gateway/
|
- k8s-gateway/
|
||||||
- external-dns-unifi/
|
- external-dns-unifi/
|
||||||
|
|||||||
@@ -37,3 +37,16 @@ spec:
|
|||||||
args:
|
args:
|
||||||
- --kubelet-insecure-tls
|
- --kubelet-insecure-tls
|
||||||
- --kubelet-preferred-address-types=InternalIP
|
- --kubelet-preferred-address-types=InternalIP
|
||||||
|
# v1beta1.metrics.k8s.io is a cluster-scoped API: with a single replica,
|
||||||
|
# any single-node disruption takes the aggregated API to 0 % availability
|
||||||
|
# (KubeAggregatedAPIDown) and breaks every HPA. Run two replicas pinned to
|
||||||
|
# different nodes, matching upstream's high-availability manifest.
|
||||||
|
replicas: 2
|
||||||
|
affinity:
|
||||||
|
podAntiAffinity:
|
||||||
|
requiredDuringSchedulingIgnoredDuringExecution:
|
||||||
|
- topologyKey: kubernetes.io/hostname
|
||||||
|
labelSelector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: metrics-server
|
||||||
|
app.kubernetes.io/instance: metrics-server
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ spec:
|
|||||||
labels:
|
labels:
|
||||||
app: cni-plugins-installer
|
app: cni-plugins-installer
|
||||||
spec:
|
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
|
hostNetwork: true
|
||||||
tolerations:
|
tolerations:
|
||||||
- operator: Exists
|
- operator: Exists
|
||||||
@@ -44,7 +49,32 @@ spec:
|
|||||||
# at all and put github.com on the critical path of every node rebuild.
|
# 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
|
# Dated tag so a version change is a reviewable diff here, matching the
|
||||||
# ci-ansible / alpine-jq convention.
|
# 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:
|
command:
|
||||||
- sh
|
- sh
|
||||||
- -c
|
- -c
|
||||||
|
|||||||
@@ -154,6 +154,40 @@ spec:
|
|||||||
app: multus
|
app: multus
|
||||||
name: multus
|
name: multus
|
||||||
spec:
|
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
|
hostNetwork: true
|
||||||
hostPID: true
|
hostPID: true
|
||||||
tolerations:
|
tolerations:
|
||||||
|
|||||||
@@ -146,6 +146,11 @@ spec:
|
|||||||
app: whereabouts
|
app: whereabouts
|
||||||
name: whereabouts
|
name: whereabouts
|
||||||
spec:
|
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
|
hostNetwork: true
|
||||||
serviceAccountName: whereabouts
|
serviceAccountName: whereabouts
|
||||||
# Every node here is a control plane node, so the NoSchedule toleration
|
# 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.
|
# classifies documents without experimenting on the live archive.
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Namespace
|
kind: Namespace
|
||||||
metadata:
|
|
||||||
name: paperless-eval
|
|
||||||
---
|
|
||||||
apiVersion: v1
|
|
||||||
kind: Namespace
|
|
||||||
metadata:
|
metadata:
|
||||||
name: audiobookshelf
|
name: audiobookshelf
|
||||||
---
|
---
|
||||||
@@ -195,3 +190,31 @@ apiVersion: v1
|
|||||||
kind: Namespace
|
kind: Namespace
|
||||||
metadata:
|
metadata:
|
||||||
name: ai
|
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
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
kind: Kustomization
|
kind: Kustomization
|
||||||
resources:
|
resources:
|
||||||
- externalsecret.yaml
|
|
||||||
- daemonset.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:
|
- ports:
|
||||||
- port: "8080"
|
- port: "8080"
|
||||||
protocol: TCP
|
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 —
|
# 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
|
# it has no use for the embedding endpoint, so unlike paperless above this
|
||||||
# grant does not extend to llama-embed.
|
# grant does not extend to llama-embed.
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ resources:
|
|||||||
- ingress-isolation.yaml
|
- ingress-isolation.yaml
|
||||||
- authentik-ns.yaml
|
- authentik-ns.yaml
|
||||||
- paperless-ns.yaml
|
- paperless-ns.yaml
|
||||||
- paperless-eval-ns.yaml
|
|
||||||
- audiobookshelf-ns.yaml
|
- audiobookshelf-ns.yaml
|
||||||
- home-assistant-ns.yaml
|
- home-assistant-ns.yaml
|
||||||
- spegel-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
|
name: openbao
|
||||||
namespace: openbao
|
namespace: openbao
|
||||||
install:
|
install:
|
||||||
# A fresh OpenBao pod is 0/1 Ready until `bao operator init` + unseal
|
# A fresh OpenBao pod is 0/1 Ready until `bao operator init` (readiness
|
||||||
# (readiness probe reports sealed). helm --wait would time out and
|
# reports sealed). helm --wait would time out and remediation would
|
||||||
# remediation would uninstall the release, so don't wait here; the
|
# uninstall the release, so don't wait here; init is manual (see
|
||||||
# init/unseal procedure is manual (see kubernetes/MIGRATION-sops-openbao.md).
|
# kubernetes/MIGRATION-sops-openbao.md). Unseal is automatic (static seal).
|
||||||
disableWait: true
|
disableWait: true
|
||||||
remediation:
|
remediation:
|
||||||
retries: 3
|
retries: 3
|
||||||
@@ -42,6 +42,11 @@ spec:
|
|||||||
# the agent-injector mutating webhook is unused surface.
|
# the agent-injector mutating webhook is unused surface.
|
||||||
enabled: false
|
enabled: false
|
||||||
server:
|
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
|
# Raft (not standalone/file) for two reasons: `bao operator raft
|
||||||
# snapshot` as a consistent backup primitive, and real HA across three
|
# snapshot` as a consistent backup primitive, and real HA across three
|
||||||
# nodes. TLS terminates at the Traefik ingress; in-cluster traffic is
|
# 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 moment one pod is sealed or rescheduled, which is worse than
|
||||||
# the single replica it replaced. Three tolerates one.
|
# the single replica it replaced. Three tolerates one.
|
||||||
#
|
#
|
||||||
# What this buys, and what it does not: a node drain seals the replica
|
# A restarted replica unseals itself from the static seal and rejoins
|
||||||
# on it, and raft elects a new leader from the remaining standbys
|
# raft, so a rolling reboot only ever costs one replica at a time.
|
||||||
# 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.
|
|
||||||
replicas: 3
|
replicas: 3
|
||||||
raft:
|
raft:
|
||||||
enabled: true
|
enabled: true
|
||||||
@@ -111,6 +108,14 @@ spec:
|
|||||||
|
|
||||||
service_registration "kubernetes" {}
|
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" {
|
audit "file" "stdout" {
|
||||||
description = "audit trail to pod stdout -> Loki"
|
description = "audit trail to pod stdout -> Loki"
|
||||||
options {
|
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
|
# name hairpins off the ingress LB from a pod. Needs BOTH netpol halves
|
||||||
# (gitea-allow-flux-rotator + rotator-gitea-flux-egress).
|
# (gitea-allow-flux-rotator + rotator-gitea-flux-egress).
|
||||||
#
|
#
|
||||||
# The ExternalSecret MERGES into Flux's existing flux-system secret (does not
|
# The ExternalSecret that publishes the rotated token to Flux lives in
|
||||||
# own it) so a sync failure leaves the last-good value in place; Flux's
|
# ../../post-crds/flux-git-externalsecret.yaml (needs ESO's CRDs).
|
||||||
# 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
|
|
||||||
---
|
---
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: ServiceAccount
|
kind: ServiceAccount
|
||||||
|
|||||||
@@ -11,8 +11,8 @@
|
|||||||
# secret is stored anywhere; each run logs in fresh.
|
# secret is stored anywhere; each run logs in fresh.
|
||||||
#
|
#
|
||||||
# A SEALED OpenBao fails the login step and the Job goes Failed — that is
|
# 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
|
# deliberate signal, not noise: it means the static seal could not unseal a
|
||||||
# restart and backups have stopped.
|
# restarted pod and backups have stopped.
|
||||||
#
|
#
|
||||||
# Retention: last 7 snapshots on the PVC; velero adds 30 d offsite.
|
# Retention: last 7 snapshots on the PVC; velero adds 30 d offsite.
|
||||||
apiVersion: batch/v1
|
apiVersion: batch/v1
|
||||||
|
|||||||
@@ -25,13 +25,13 @@ spec:
|
|||||||
stop: "fd99::ff"
|
stop: "fd99::ff"
|
||||||
---
|
---
|
||||||
# Cilium BGPv2 control-plane config peering each node with the UDM over the
|
# 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
|
# The source address comes from the kernel routing decision, so no explicit
|
||||||
# localAddress override is needed.
|
# localAddress override is needed.
|
||||||
#
|
#
|
||||||
# Two parallel sessions, one per address family:
|
# Two parallel sessions, one per address family:
|
||||||
# - v4 session: peer 10.2.40.1, family IPv4 unicast only
|
# - v4 session: peer 10.2.47.1, family IPv4 unicast only
|
||||||
# - v6 session: peer fd40::1, family IPv6 unicast only
|
# - v6 session: peer fd02:47::1, family IPv6 unicast only
|
||||||
# This avoids the "v6 NLRIs over v4 transport" path, which requires
|
# This avoids the "v6 NLRIs over v4 transport" path, which requires
|
||||||
# bilateral extended-nexthop capability negotiation (RFC 5549/8950).
|
# bilateral extended-nexthop capability negotiation (RFC 5549/8950).
|
||||||
# Cilium 1.19 BGPv2 advertises the capability only for the IPv4 Unicast
|
# Cilium 1.19 BGPv2 advertises the capability only for the IPv4 Unicast
|
||||||
@@ -74,14 +74,14 @@ spec:
|
|||||||
peers:
|
peers:
|
||||||
- name: udm-v6
|
- name: udm-v6
|
||||||
peerASN: 64512
|
peerASN: 64512
|
||||||
# ULA — stable across ISP renumber events. UDM's br40 has
|
# ULA — stable across ISP renumber events. UDM's br47 has
|
||||||
# fd40::1/64; nodes get fd40::<eui-64> via SLAAC on eth0.
|
# fd02:47::1/64; nodes get fd02:47::<eui-64> via SLAAC on ingress0.
|
||||||
peerAddress: fd40::1
|
peerAddress: fd02:47::1
|
||||||
peerConfigRef:
|
peerConfigRef:
|
||||||
name: bgp-peer-udm-v6
|
name: bgp-peer-udm-v6
|
||||||
- name: udm-v4
|
- name: udm-v4
|
||||||
peerASN: 64512
|
peerASN: 64512
|
||||||
peerAddress: 10.2.40.1
|
peerAddress: 10.2.47.1
|
||||||
peerConfigRef:
|
peerConfigRef:
|
||||||
name: bgp-peer-udm-v4
|
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
|
# configured (2026-07-13): a store that can't turn Ready would block this
|
||||||
# KS's wait gate and freeze the dependsOn chain.
|
# KS's wait gate and freeze the dependsOn chain.
|
||||||
- openbao-clustersecretstore.yaml
|
- 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/
|
- ../network-policies/
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ spec:
|
|||||||
- ingress
|
- ingress
|
||||||
- monitoring
|
- monitoring
|
||||||
- paperless
|
- paperless
|
||||||
- paperless-eval # same apps/paperless key plus the shared barman S3 creds
|
|
||||||
- trmnl
|
- trmnl
|
||||||
- velero
|
- velero
|
||||||
- woodpecker # wp-pg barman creds; woodpecker moving out of `apps` (#433)
|
- 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