Compare commits
59
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a101eb8ee | ||
|
|
116d124e2d | ||
|
|
cf1237bf33 | ||
|
|
4742b26047 | ||
|
|
3bde2a4316 | ||
|
|
f38033e30a | ||
|
|
a5d8bf35a9 | ||
|
|
60cfcf9e7e | ||
|
|
a88bebc76d | ||
|
|
7897b50318 | ||
|
|
22143c6a7c | ||
|
|
bc896e550c | ||
|
|
ea6e8e5083 | ||
|
|
f0dcda4917 | ||
|
|
e99e502da7 | ||
|
|
bf2b520fc4 | ||
|
|
780401fe91 | ||
|
|
f9a9dc7ff8 | ||
|
|
05abc3291f | ||
|
|
35a8a8d975 | ||
|
|
a003df0ef8 | ||
|
|
f0e48b226a | ||
|
|
669ef1c5ee | ||
|
|
11cd02ec44 | ||
|
|
68865e15c5 | ||
|
|
be2f8b9a18 | ||
|
|
7dacb6320c | ||
|
|
8a00b7735d | ||
|
|
2c4aad858c | ||
|
|
3f014a0020 | ||
|
|
a056f8f67f | ||
|
|
aa53335c2b | ||
|
|
5129ba7713 | ||
|
|
2b27d6c599 | ||
|
|
8b18fba424 | ||
|
|
32b5cacb37 | ||
|
|
b846b7fa26 | ||
|
|
b46635d94e | ||
|
|
b11cff0f10 | ||
|
|
b4a1224804 | ||
|
|
a43717c3a4 | ||
|
|
0e33041354 | ||
|
|
e740e9b444 | ||
|
|
10ed11c260 | ||
|
|
7c41a21f88 | ||
|
|
f26d84e869 | ||
|
|
5b7d8dfaca | ||
|
|
1211d97851 | ||
|
|
682af5df88 | ||
|
|
89e011fd2e | ||
|
|
f9ca731d5d | ||
|
|
8cde6d5127 | ||
|
|
593fb6b7d9 | ||
|
|
d4a683a0ad | ||
|
|
1551523764 | ||
|
|
40362925fa | ||
|
|
0fa4b8d361 | ||
|
|
c868b8cd9d | ||
|
|
99445753d1 |
@@ -87,30 +87,6 @@ steps:
|
||||
--destination gitea.renner.uno/gitea_admin/ci-k8s:$TAG \
|
||||
--destination gitea.renner.uno/gitea_admin/ci-k8s:latest
|
||||
|
||||
- name: build-vmtest
|
||||
image: gcr.io/kaniko-project/executor:v1.23.2-debug
|
||||
directory: ci/images/vmtest
|
||||
depends_on: []
|
||||
when:
|
||||
- event: deployment
|
||||
- event: push
|
||||
path: ["ci/images/vmtest/**", ".woodpecker/build-ci-images.yml"]
|
||||
environment:
|
||||
GITEA_USER: gitea_admin
|
||||
GITEA_TOKEN:
|
||||
from_secret: ci_images_token
|
||||
commands:
|
||||
- mkdir -p /kaniko/.docker
|
||||
- AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n')
|
||||
- printf '{"auths":{"gitea.renner.uno":{"auth":"%s"}}}' "$AUTH" > /kaniko/.docker/config.json
|
||||
- TAG=$(date +%Y-%m-%d)
|
||||
- |
|
||||
/kaniko/executor \
|
||||
--context . \
|
||||
--dockerfile Containerfile \
|
||||
--destination gitea.renner.uno/gitea_admin/ci-vmtest:$TAG \
|
||||
--destination gitea.renner.uno/gitea_admin/ci-vmtest:latest
|
||||
|
||||
- name: build-tofu
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
|
||||
directory: ci/images/tofu
|
||||
@@ -171,28 +147,6 @@ steps:
|
||||
# not for who uses it — its first consumer is the zigbee2mqtt seed-data init
|
||||
# container, but nothing about it is zigbee-specific. Exists so tools come from
|
||||
# the image rather than an `apk add` on every container start.
|
||||
#
|
||||
# Published THREE times, and the third one is not a typo. Its consumers — the
|
||||
# zigbee2mqtt seed-data init container and the trmnl renderer — start while
|
||||
# the cluster is still being rebuilt, and at that moment gitea.renner.uno does
|
||||
# not exist, because Gitea runs inside the cluster being built. A cold-rebuild
|
||||
# rehearsal caught both in ImagePullBackOff.
|
||||
#
|
||||
# So there is a copy on ghcr.io. But the manifests must NOT point at it:
|
||||
# normal operation pulls from the local registry, and an external reference
|
||||
# would make every steady-state pull leave the network. The recovery source
|
||||
# and the everyday source are different things, exactly as they are for Flux's
|
||||
# git source.
|
||||
#
|
||||
# Talos resolves that with machine.registries.mirrors, which takes an ordered
|
||||
# endpoint list — gitea first, ghcr second (bootstrap.sh). Containerd keeps
|
||||
# the repository path when it falls through, so the two registries have to
|
||||
# agree on it: `gitea_admin/alpine-jq` cannot be served by ghcr, where the
|
||||
# namespace is fixed to the account name. Hence the second Gitea destination
|
||||
# under konairius/, which the manifests reference and which matches the ghcr
|
||||
# path exactly.
|
||||
#
|
||||
# The gitea_admin/ copy stays until nothing references it any more.
|
||||
- name: build-alpine-jq
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
|
||||
directory: ci/images/alpine-jq
|
||||
@@ -205,47 +159,23 @@ steps:
|
||||
GITEA_USER: gitea_admin
|
||||
GITEA_TOKEN:
|
||||
from_secret: ci_images_token
|
||||
GHCR_USER: konairius
|
||||
GHCR_TOKEN:
|
||||
from_secret: ghcr_mirror_token
|
||||
commands:
|
||||
- mkdir -p /kaniko/.docker
|
||||
- AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n')
|
||||
- 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
|
||||
- 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/alpine-jq:$TAG \
|
||||
--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
|
||||
--destination gitea.renner.uno/gitea_admin/alpine-jq:latest
|
||||
|
||||
# Runtime image, not CI tooling: upstream containernetworking/plugins binaries
|
||||
# baked in, consumed by the multus cni-plugins-installer DaemonSet. Replaces a
|
||||
# node-side fetch from GitHub that had NO checksum verification and sat on the
|
||||
# critical path of every node rebuild. The download now happens here, once,
|
||||
# where the sha256 is checked — see the Containerfile for the full rationale.
|
||||
#
|
||||
# This one ALSO publishes to ghcr.io. A cold-rebuild rehearsal proved why: the
|
||||
# DaemonSet could not pull from gitea.renner.uno, because Gitea runs inside the
|
||||
# very cluster being rebuilt. Without the plugins there is no macvlan, so
|
||||
# Multus cannot attach ceph-san, so Rook's network canary never starts, so the
|
||||
# CephCluster sits at "Configuring Ceph Mons" — and OpenBao waits forever for a
|
||||
# PVC that will never bind. Moving the download here fixed the integrity gap
|
||||
# but quietly swapped github.com on the critical path for something strictly
|
||||
# worse: a registry that by definition does not exist during a recovery. The
|
||||
# GHCR copy is public, so a bare node can pull it with no credential and no
|
||||
# Gitea.
|
||||
#
|
||||
# The test that proved this also proved the rule is not specific to this
|
||||
# image. alpine-jq and alpine-openssl mirror for the same reason — see the
|
||||
# note on build-alpine-jq. Anything an app needs while the cluster is still
|
||||
# being built cannot live only in that cluster's own registry.
|
||||
- name: build-cni-plugins
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
|
||||
directory: ci/images/cni-plugins
|
||||
@@ -258,36 +188,23 @@ steps:
|
||||
GITEA_USER: gitea_admin
|
||||
GITEA_TOKEN:
|
||||
from_secret: ci_images_token
|
||||
GHCR_USER: konairius
|
||||
GHCR_TOKEN:
|
||||
from_secret: ghcr_mirror_token
|
||||
commands:
|
||||
- mkdir -p /kaniko/.docker
|
||||
- AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n')
|
||||
- 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
|
||||
- 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/cni-plugins:$TAG \
|
||||
--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
|
||||
--destination gitea.renner.uno/gitea_admin/cni-plugins:latest
|
||||
|
||||
# Runtime image, not CI tooling: alpine + the openssl CLI, for shell scripts
|
||||
# needing s_client/x509. Named for what it is; first consumer is the paperless
|
||||
# fetch-bridge-cert init container. Replaces an `apk add openssl` that ran on
|
||||
# every paperless pod start. The openssl CLI is genuinely absent from the alpine
|
||||
# base, so this one needs an image rather than a busybox substitute.
|
||||
#
|
||||
# Mirrored to ghcr.io for the reason given on build-alpine-jq: it is an init
|
||||
# container, so paperless does not start without it, and during a rebuild
|
||||
# gitea.renner.uno is not there to serve it. A cold-rebuild rehearsal left
|
||||
# paperless in Init:ErrImagePull on exactly this image.
|
||||
- name: build-alpine-openssl
|
||||
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
|
||||
directory: ci/images/alpine-openssl
|
||||
@@ -300,25 +217,17 @@ steps:
|
||||
GITEA_USER: gitea_admin
|
||||
GITEA_TOKEN:
|
||||
from_secret: ci_images_token
|
||||
GHCR_USER: konairius
|
||||
GHCR_TOKEN:
|
||||
from_secret: ghcr_mirror_token
|
||||
commands:
|
||||
- mkdir -p /kaniko/.docker
|
||||
- AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n')
|
||||
- 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
|
||||
- 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/alpine-openssl:$TAG \
|
||||
--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
|
||||
--destination gitea.renner.uno/gitea_admin/alpine-openssl:latest
|
||||
|
||||
# Heating CAN readers: open3e (Vitocal/VX3) + E3onCAN (E380 meter), one image
|
||||
# run as two services on edge-1. Replaces pip-into-venv on the host, where an
|
||||
|
||||
@@ -7,6 +7,8 @@ when:
|
||||
# install-netbird-proxy.yml also applies the netbird-proxy-watchdog role,
|
||||
# so changes to it must redeploy this host too.
|
||||
- "ansible/roles/netbird-proxy-watchdog/**"
|
||||
# install-netbird-proxy.yml also applies the edge bouncer.
|
||||
- "ansible/roles/crowdsec-edge-bouncer/**"
|
||||
- "ansible/plays/install-netbird-proxy.yml"
|
||||
- ".woodpecker/deploy-netbird-proxy.yml"
|
||||
- event: deployment
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
# Bouncer key for crowdsec-edge-bouncer, scoped to the proxy hosts.
|
||||
#
|
||||
# A single encrypted VALUE rather than a whole-file vault like its siblings in
|
||||
# group_vars/all and group_vars/edge_host. Deliberate: adding this variable to
|
||||
# an existing whole-file vault means decrypting that file first, which exposes
|
||||
# every other secret in it for the sake of appending one line. encrypt_string
|
||||
# keeps the blast radius at this one value.
|
||||
#
|
||||
# Same value as OpenBao homelab/infrastructure/crowdsec-edge-bouncer-key, which
|
||||
# the crowdsec-edge-bouncer-key ExternalSecret feeds to BOUNCER_KEY_edge on the
|
||||
# LAPI. Both sides must move together — and unlike crowdsec-bouncer-key, this
|
||||
# one is NOT in the quarterly rotator, because nothing on these hosts re-reads
|
||||
# OpenBao on a schedule. Rotating it means updating OpenBao and re-running
|
||||
# plays/install-netbird-proxy.yml.
|
||||
vault_crowdsec_edge_bouncer_key: !vault |
|
||||
$ANSIBLE_VAULT;1.1;AES256
|
||||
32653764333536316163303838333839616562663862666637626165303837633866643033393239
|
||||
3363376538656236643161386432326437633232643564630a633635306365323537346139626364
|
||||
36613033343938363366306239363638383466396337623333623136313234656366333366623433
|
||||
6437656632373364340a346566343866366433306533353431383064356362346536383136323663
|
||||
39333864643534326433653233303137373162663066633132623931316135323733333863306330
|
||||
6366623463666435636362376238313639666263323163303733
|
||||
@@ -26,3 +26,7 @@
|
||||
- docker-install
|
||||
- netbird-proxy
|
||||
- netbird-proxy-watchdog
|
||||
# Enforces the cluster's CrowdSec decisions here, where public traffic
|
||||
# enters. Deployed in dry-run: it logs what it would block and creates no
|
||||
# nftables rules until crowdsec_bouncer_mode is flipped per host.
|
||||
- crowdsec-edge-bouncer
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
# CrowdSec firewall bouncer on the NetBird proxy hosts.
|
||||
#
|
||||
# Enforces the decisions the in-cluster CrowdSec already detects, at the point
|
||||
# where public traffic actually enters: these hosts terminate TLS for every
|
||||
# *.renner.uno name. A drop here never reaches the mesh, the relay or the
|
||||
# cluster.
|
||||
#
|
||||
# Detection stays in the cluster. The reverse-proxy produces no parseable
|
||||
# access log and there is no CrowdSec collection for it, while Traefik has a
|
||||
# working one — and every scenario in use needs HTTP detail (paths, status
|
||||
# codes, user agents) that does not exist at this layer.
|
||||
|
||||
# Pinned. A release tarball URL is immutable, and this checksum is of the
|
||||
# amd64 asset for exactly this tag.
|
||||
crowdsec_bouncer_version: v0.0.36
|
||||
crowdsec_bouncer_sha256: f86e4b72693549d99f40a9402abefb894108f047a3fbe6e72fada25ee17ce88b
|
||||
|
||||
# The LAPI over the public path these hosts already serve, see
|
||||
# kubernetes/infrastructure/crowdsec/lapi-ingress.yaml. The proxies cannot
|
||||
# reach it over the mesh: the reverse-proxy container runs non-root, so its
|
||||
# embedded NetBird client terminates the tunnel in userspace and the tunnel
|
||||
# belongs to that process rather than to the host netns.
|
||||
#
|
||||
# No ca_cert_path: Traefik serves a Let's Encrypt certificate for this name,
|
||||
# and the bouncer adds ca_cert_path to the system pool rather than replacing
|
||||
# it — the system pool already trusts LE.
|
||||
crowdsec_bouncer_api_url: https://crowdsec.renner.uno/
|
||||
crowdsec_bouncer_api_key: "{{ vault_crowdsec_edge_bouncer_key }}"
|
||||
|
||||
# STARTS INERT, and that is the point.
|
||||
#
|
||||
# dry-run pulls decisions and logs what it would block without creating a
|
||||
# single nftables rule. The first bouncer to arm anywhere enforces ~24k CAPI
|
||||
# decisions at once on the only external entry path, so the false-positive
|
||||
# window has to come before the enforcement, not after it. Flip to `nftables`
|
||||
# one host at a time once the log looks right.
|
||||
crowdsec_bouncer_mode: dry-run
|
||||
|
||||
crowdsec_bouncer_update_frequency: 10s
|
||||
crowdsec_bouncer_log_level: info
|
||||
|
||||
# Bound to loopback deliberately. These hosts are public and have no metrics
|
||||
# collection path at all today — opening :60601 to the internet to gain a
|
||||
# scrape target is the wrong trade. Revisit when host metrics exist.
|
||||
crowdsec_bouncer_metrics_listen: 127.0.0.1
|
||||
crowdsec_bouncer_metrics_port: 60601
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
# A restart drops both nftables tables and rebuilds them from the next
|
||||
# successful LAPI pull, so there is a short window with no blocklist. That is
|
||||
# the documented behaviour of a clean shutdown, not a defect — and it is why
|
||||
# retry_initial_connect matters.
|
||||
- name: Restart crowdsec firewall bouncer
|
||||
ansible.builtin.systemd:
|
||||
name: crowdsec-firewall-bouncer.service
|
||||
state: restarted
|
||||
daemon_reload: true
|
||||
@@ -0,0 +1,111 @@
|
||||
---
|
||||
# See defaults/main.yml for why this exists and why it starts inert.
|
||||
#
|
||||
# The static release tarball, not a distribution package: the AUR package
|
||||
# `crowdsec-firewall-bouncer-nftables` depends on `crowdsec` and would pull the
|
||||
# whole Security Engine onto hosts that only need the bouncer, the group is
|
||||
# mixed (proxy-1 Arch, VPS-1 Debian), and the AUR would be a new pattern this
|
||||
# repo does not otherwise have. The tarball is the same artefact the AUR
|
||||
# package downloads.
|
||||
|
||||
- name: Ensure nftables is present (the enforcing backend)
|
||||
ansible.builtin.package:
|
||||
name: nftables
|
||||
state: present
|
||||
|
||||
- name: Ensure the bouncer config directory exists
|
||||
ansible.builtin.file:
|
||||
path: /etc/crowdsec/bouncers
|
||||
state: directory
|
||||
mode: "0755"
|
||||
|
||||
# Fetched on the CONTROLLER and pushed, not pulled by the host.
|
||||
#
|
||||
# proxy-1 is IPv6-only, and neither github.com nor objects.githubusercontent.com
|
||||
# publishes an AAAA record — a get_url on the host fails with "Network is
|
||||
# unreachable" and no amount of retrying fixes it. Mirroring into
|
||||
# gitea.renner.uno is no help either: its public records are the internal LB
|
||||
# VIP (10.2.99.86 / fd99::86), so it is unreachable from outside the LAN. And
|
||||
# upstream ships no container image for this bouncer, which is expected —
|
||||
# it manipulates host netfilter.
|
||||
#
|
||||
# The CI runner has v4, so it does the fetching. The checksum is verified
|
||||
# there, before anything is copied to a host.
|
||||
- name: Fetch the pinned bouncer release on the controller
|
||||
ansible.builtin.get_url:
|
||||
url: >-
|
||||
https://github.com/crowdsecurity/cs-firewall-bouncer/releases/download/{{
|
||||
crowdsec_bouncer_version }}/crowdsec-firewall-bouncer-linux-amd64.tgz
|
||||
dest: /tmp/crowdsec-firewall-bouncer-{{ crowdsec_bouncer_version }}.tgz
|
||||
checksum: sha256:{{ crowdsec_bouncer_sha256 }}
|
||||
mode: "0644"
|
||||
delegate_to: localhost
|
||||
become: false
|
||||
run_once: true
|
||||
|
||||
# Only the download had to move to the controller — the extraction stays on
|
||||
# the host. The ci-ansible image ships a tar that ansible rejects ("detected as
|
||||
# tar type None. GNU tar required"), while both host distributions have GNU
|
||||
# tar. Shipping the archive rather than the unpacked binary also keeps the
|
||||
# artefact that the checksum was verified against intact all the way to the
|
||||
# host.
|
||||
- name: Copy the bouncer archive to the host
|
||||
ansible.builtin.copy:
|
||||
src: /tmp/crowdsec-firewall-bouncer-{{ crowdsec_bouncer_version }}.tgz
|
||||
dest: /opt/crowdsec-firewall-bouncer-{{ crowdsec_bouncer_version }}.tgz
|
||||
mode: "0644"
|
||||
|
||||
# Only the binary. The tarball also carries install.sh / upgrade.sh /
|
||||
# uninstall.sh, which manage systemd and config themselves — that is this
|
||||
# role's job, and letting them run would leave two owners for the same files.
|
||||
- name: Unpack the bouncer binary
|
||||
ansible.builtin.unarchive:
|
||||
src: /opt/crowdsec-firewall-bouncer-{{ crowdsec_bouncer_version }}.tgz
|
||||
dest: /opt
|
||||
remote_src: true
|
||||
include:
|
||||
- crowdsec-firewall-bouncer-{{ crowdsec_bouncer_version }}/crowdsec-firewall-bouncer
|
||||
creates: /opt/crowdsec-firewall-bouncer-{{ crowdsec_bouncer_version }}/crowdsec-firewall-bouncer
|
||||
|
||||
- name: Install the bouncer binary
|
||||
ansible.builtin.copy:
|
||||
src: /opt/crowdsec-firewall-bouncer-{{ crowdsec_bouncer_version }}/crowdsec-firewall-bouncer
|
||||
dest: /usr/local/bin/crowdsec-firewall-bouncer
|
||||
remote_src: true
|
||||
mode: "0755"
|
||||
notify: Restart crowdsec firewall bouncer
|
||||
|
||||
# 0600: this file carries the LAPI key.
|
||||
- name: Deploy the bouncer configuration
|
||||
ansible.builtin.template:
|
||||
src: crowdsec-firewall-bouncer.yaml.j2
|
||||
dest: /etc/crowdsec/bouncers/crowdsec-firewall-bouncer.yaml
|
||||
mode: "0600"
|
||||
notify: Restart crowdsec firewall bouncer
|
||||
|
||||
- name: Deploy the bouncer systemd unit
|
||||
ansible.builtin.template:
|
||||
src: crowdsec-firewall-bouncer.service.j2
|
||||
dest: /etc/systemd/system/crowdsec-firewall-bouncer.service
|
||||
mode: "0644"
|
||||
notify: Restart crowdsec firewall bouncer
|
||||
|
||||
- name: Enable and start the bouncer
|
||||
ansible.builtin.systemd:
|
||||
name: crowdsec-firewall-bouncer.service
|
||||
enabled: true
|
||||
state: started
|
||||
daemon_reload: true
|
||||
|
||||
- name: Report how to read it
|
||||
ansible.builtin.debug:
|
||||
msg: |
|
||||
crowdsec-firewall-bouncer deployed in mode "{{ crowdsec_bouncer_mode }}".
|
||||
|
||||
What it would block: journalctl -u crowdsec-firewall-bouncer -f
|
||||
What it does block: nft list table ip crowdsec ; nft list table ip6 crowdsec6
|
||||
Metrics: curl -s {{ crowdsec_bouncer_metrics_listen }}:{{ crowdsec_bouncer_metrics_port }}/metrics
|
||||
|
||||
Rollback: systemctl stop crowdsec-firewall-bouncer. A clean stop deletes
|
||||
both nftables tables by itself. Only after a kill -9 do they need
|
||||
removing by hand: nft delete table ip crowdsec ; nft delete table ip6 crowdsec6
|
||||
@@ -0,0 +1,24 @@
|
||||
# Managed by Ansible (crowdsec-edge-bouncer role) — do not edit.
|
||||
#
|
||||
# Upstream's shipped unit orders itself After=crowdsec.service. There is no
|
||||
# local Security Engine on these hosts — the LAPI is in the cluster — so that
|
||||
# dependency is dropped; nss-lookup instead, because api_url is a public name
|
||||
# that has to resolve before the first connect.
|
||||
[Unit]
|
||||
Description=CrowdSec firewall bouncer (edge enforcement for the NetBird proxy)
|
||||
After=network-online.target nss-lookup.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=notify
|
||||
# -t validates the config and exits non-zero on a bad one, so a broken config
|
||||
# fails the start instead of taking the running rules down with it.
|
||||
ExecStartPre=/usr/local/bin/crowdsec-firewall-bouncer -c /etc/crowdsec/bouncers/crowdsec-firewall-bouncer.yaml -t
|
||||
ExecStart=/usr/local/bin/crowdsec-firewall-bouncer -c /etc/crowdsec/bouncers/crowdsec-firewall-bouncer.yaml
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
LimitNOFILE=65536
|
||||
KillMode=mixed
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,55 @@
|
||||
# Managed by Ansible (crowdsec-edge-bouncer role) — do not edit.
|
||||
mode: {{ crowdsec_bouncer_mode }}
|
||||
update_frequency: {{ crowdsec_bouncer_update_frequency }}
|
||||
|
||||
# stdout, so journald owns the log and there is no second rotation policy to
|
||||
# keep in sync. The shipped example writes to /var/log; that only matters for
|
||||
# hosts without journald.
|
||||
log_mode: stdout
|
||||
log_level: {{ crowdsec_bouncer_log_level }}
|
||||
|
||||
api_url: {{ crowdsec_bouncer_api_url }}
|
||||
api_key: {{ crowdsec_bouncer_api_key }}
|
||||
|
||||
# Without this the process EXITS when the LAPI is unreachable at startup —
|
||||
# there is no retry on the first connect. And because a clean shutdown deletes
|
||||
# the nftables tables, the systemd restart loop would leave the host with no
|
||||
# blocklist at all until the first successful connect. Present in the code,
|
||||
# absent from the shipped example config.
|
||||
retry_initial_connect: true
|
||||
|
||||
disable_ipv4: false
|
||||
disable_ipv6: false
|
||||
deny_action: DROP
|
||||
|
||||
supported_decisions_types:
|
||||
- ban
|
||||
|
||||
# Separate tables per family; the bouncer never touches an existing table or
|
||||
# chain, and never sets a chain policy. Priority -10 puts it ahead of anything
|
||||
# at the default filter priority.
|
||||
nftables:
|
||||
ipv4:
|
||||
enabled: true
|
||||
set-only: false
|
||||
table: crowdsec
|
||||
chain: crowdsec-chain
|
||||
priority: -10
|
||||
ipv6:
|
||||
enabled: true
|
||||
set-only: false
|
||||
table: crowdsec6
|
||||
chain: crowdsec6-chain
|
||||
priority: -10
|
||||
|
||||
# input is the traffic this exists for: the reverse-proxy is host-networked and
|
||||
# owns :443, so its packets traverse INPUT. forward covers the bridge-networked
|
||||
# http-redirect sidecar, whose packets never do.
|
||||
nftables_hooks:
|
||||
- input
|
||||
- forward
|
||||
|
||||
prometheus:
|
||||
enabled: true
|
||||
listen_addr: {{ crowdsec_bouncer_metrics_listen }}
|
||||
listen_port: {{ crowdsec_bouncer_metrics_port }}
|
||||
@@ -1,18 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Forced-command target for the netbird-backup SSH key (see the
|
||||
# "Authorize the cluster backup key" task): takes a CONSISTENT snapshot
|
||||
# of the management SQLite store via the online-backup API (safe against
|
||||
# a live writer, unlike copying store.db) and streams a gzipped tar to
|
||||
# stdout. The key's authorized_keys entry is restricted to exactly this
|
||||
# script — the in-cluster puller cannot run anything else.
|
||||
#
|
||||
# Deliberately store.db ONLY: /opt/netbird/config is fully regenerable
|
||||
# from the netbird-server role's templates + vault, and including it
|
||||
# would put the plaintext TURN secret (turnserver.conf) into every
|
||||
# nightly tarball. Restore = re-run the role (rebuilds config), then
|
||||
# untar this dump into /opt/netbird/management/.
|
||||
set -eu
|
||||
tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
sqlite3 /opt/netbird/management/store.db ".backup '$tmp/store.db'"
|
||||
tar -C "$tmp" -czf - store.db
|
||||
@@ -77,29 +77,22 @@
|
||||
register: docker_compose_result
|
||||
changed_when: "'Started' in docker_compose_result.stdout or 'Creating' in docker_compose_result.stdout"
|
||||
|
||||
# Off-VPS backup of the management store (SPOF assessment 2026-07-25:
|
||||
# this SQLite DB — peers, setup keys, groups, routes — had zero backup
|
||||
# coverage; losing the VPS meant re-enrolling the entire mesh). The VPS
|
||||
# cannot reach any PBS (both PBS names resolve to the public proxy,
|
||||
# ports closed), so the CLUSTER pulls: a CronJob
|
||||
# (kubernetes/infrastructure/netbird-backup) ssh's in with a dedicated
|
||||
# key that is locked to the dump script below, and lands the tarball on
|
||||
# a Ceph PVC that Velero ships offsite. The dump is store.db ONLY —
|
||||
# /opt/netbird/config is regenerable from this role's templates + vault
|
||||
# and would otherwise leak the TURN secret into every tarball. Restore:
|
||||
# re-run this role (rebuilds config), then untar the dump into
|
||||
# /opt/netbird/management/.
|
||||
- name: Install the backup dump script
|
||||
ansible.builtin.copy:
|
||||
src: netbird-backup-dump
|
||||
dest: /usr/local/bin/netbird-backup-dump
|
||||
mode: "0755"
|
||||
# The management store has no cluster-side backup. Pulling one put a root
|
||||
# key on this host that the cluster held, and this server's own state is
|
||||
# its own business to protect rather than the cluster's. Dropping the two
|
||||
# tasks that placed them would only stop ansible managing the key and the
|
||||
# script — the pair below revokes them instead, and can go once the role
|
||||
# has run against every host that ever carried them.
|
||||
- name: Remove the backup dump script
|
||||
ansible.builtin.file:
|
||||
path: /usr/local/bin/netbird-backup-dump
|
||||
state: absent
|
||||
|
||||
- name: Authorize the cluster backup key (forced command, no shell)
|
||||
- name: Revoke the cluster backup key
|
||||
ansible.posix.authorized_key:
|
||||
user: root
|
||||
key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFeW/L9bHMTSa5/Qb0ClppCf4Z0hL39JwzI6tBmKd2FG netbird-backup@homelab-cluster"
|
||||
key_options: 'restrict,command="/usr/local/bin/netbird-backup-dump"'
|
||||
state: absent
|
||||
|
||||
- name: Wait for management container to be ready
|
||||
ansible.builtin.pause:
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
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"
|
||||
@@ -2,7 +2,12 @@
|
||||
|
||||
Converts one MS-01 from "Proxmox host running a Talos VM" to "Talos on bare
|
||||
metal", and swaps its Ceph OSD onto a Samsung PM983 at the same time. Written
|
||||
after doing jupiter on 2026-08-25; saturn and neptune remain.
|
||||
after doing jupiter on 2026-08-25.
|
||||
|
||||
All three nodes are now on bare metal — `talosctl get systeminformation`
|
||||
reports Minisforum MS-01 hardware with distinct chassis serials for k8s-1,
|
||||
k8s-2 and k8s-3, with no hypervisor in between. Kept as the procedure for a
|
||||
rebuild, not as outstanding work.
|
||||
|
||||
Everything here that reads like an over-specific warning is something that
|
||||
actually happened. The conversion itself is straightforward — the failures were
|
||||
|
||||
@@ -119,9 +119,11 @@ against do not exist, and the node ends up with no address on any interface.
|
||||
Beyond that it carries:
|
||||
|
||||
- `siderolabs/intel-ucode` — no hypervisor supplies microcode here
|
||||
- `siderolabs/i915` — the Alder Lake-P Iris Xe. Nothing requests `/dev/dri`
|
||||
today; present so wanting hardware transcoding later is a config change
|
||||
rather than another rolling reinstall
|
||||
- `siderolabs/i915` — the Alder Lake-P Iris Xe. The Intel GPU device plugin
|
||||
(`infrastructure/intel-gpu-plugin`) turns this into the schedulable resource
|
||||
`gpu.intel.com/i915`, which `apps/llama-server` requests for LLM inference
|
||||
and which any hardware-transcoding workload would use too. Without the
|
||||
extension the plugin finds no `/dev/dri` and advertises nothing
|
||||
- `siderolabs/thunderbolt` — inert until `machine.kernel.modules` requests
|
||||
`thunderbolt`/`thunderbolt_net`, present for the same reason. Note its udev
|
||||
rule auto-authorises any Thunderbolt device attached once those modules load
|
||||
|
||||
@@ -877,6 +877,105 @@ data:
|
||||
kubernetes_ingress_secret_name: "authentik-outpost-tls"
|
||||
kubernetes_json_patches: null
|
||||
|
||||
# llama-server WebUI gate
|
||||
#
|
||||
# llama-server has no authentication of its own — it serves its chat UI
|
||||
# and a full OpenAI-compatible API to anyone who can reach the port. That
|
||||
# is why it was LAN-only until now. This gate is what makes a public
|
||||
# hostname defensible: the outpost terminates every request, enforces the
|
||||
# session, and only then forwards to llama-server.
|
||||
#
|
||||
# Same reasoning as vscode-gate, and the same passkey step-up: what sits
|
||||
# behind the gate is entirely unprotected, so the gate is the whole of the
|
||||
# access control rather than a second factor on top of an app login.
|
||||
#
|
||||
# In-cluster consumers (Home Assistant, Paperless, CI) are unaffected:
|
||||
# they address llama-server.ai.svc.cluster.local directly and never pass
|
||||
# through Traefik or this outpost.
|
||||
#
|
||||
# No group and no policy binding: any user who can authenticate gets in,
|
||||
# as with Immich, Audiobookshelf, Home Assistant and Music Assistant. The
|
||||
# gates that pin membership to a named user in git — vscode, esphome,
|
||||
# zigbee2mqtt, shelly-manager, prometheus — are admin interfaces, where
|
||||
# the allowlist is the point. A chat UI is not one of those, and deciding
|
||||
# its membership here would put an access-control choice in a manifest
|
||||
# instead of leaving it where the user directory belongs.
|
||||
# ------------------------------------------------------------------
|
||||
- id: llama-gate-provider
|
||||
model: authentik_providers_proxy.proxyprovider
|
||||
identifiers:
|
||||
name: llama-gate
|
||||
state: present
|
||||
attrs:
|
||||
# Both flows, mirroring the Audiobookshelf provider. Setting only
|
||||
# authorization_flow leaves authentication on Authentik's default
|
||||
# flow, which is what made the first login clumsy — the step-up ran
|
||||
# on top of a password prompt instead of replacing it. With the
|
||||
# passwordless flow named explicitly, signing in is the passkey
|
||||
# itself.
|
||||
#
|
||||
# Referenced by !Find rather than !KeyOf: both flows are defined in
|
||||
# the passkey-stepup document of this ConfigMap, and !KeyOf only
|
||||
# resolves within one document.
|
||||
authentication_flow: !Find [authentik_flows.flow, [slug, passkey-passwordless-authentication]]
|
||||
authorization_flow: !Find [authentik_flows.flow, [slug, passkey-stepup-authorization]]
|
||||
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
|
||||
mode: proxy
|
||||
internal_host: http://llama-server.ai.svc.cluster.local:8080
|
||||
internal_host_ssl_validation: false
|
||||
external_host: https://llm.renner.uno
|
||||
cookie_domain: renner.uno
|
||||
access_token_validity: hours=24
|
||||
refresh_token_validity: days=30
|
||||
intercept_header_auth: true
|
||||
# The WebUI ships `<link rel="manifest">` and registers a service
|
||||
# worker. Per the W3C spec a manifest is fetched with
|
||||
# `credentials: 'omit'`, so it carries no session cookie and the gate
|
||||
# answers it with a 302 to auth.renner.uno — which turns every page
|
||||
# load into a redirect round-trip. This is the failure that forced
|
||||
# paperless-gate's own carve-out and that retired music-assistant-gate
|
||||
# outright. Both paths serve no authenticated content, so letting them
|
||||
# through anonymously costs nothing.
|
||||
skip_path_regex: ^/(manifest\.webmanifest|sw\.js)$
|
||||
|
||||
- model: authentik_core.application
|
||||
identifiers:
|
||||
slug: llama-gate
|
||||
state: present
|
||||
attrs:
|
||||
name: Local LLM (Gate)
|
||||
slug: llama-gate
|
||||
provider: !KeyOf llama-gate-provider
|
||||
meta_launch_url: https://llm.renner.uno
|
||||
meta_description: "Forward-auth gate in front of the local llama-server WebUI"
|
||||
|
||||
- model: authentik_outposts.outpost
|
||||
identifiers:
|
||||
name: llama-gate
|
||||
state: present
|
||||
attrs:
|
||||
type: proxy
|
||||
service_connection: !KeyOf k8s-conn
|
||||
providers:
|
||||
- !KeyOf llama-gate-provider
|
||||
config:
|
||||
log_level: info
|
||||
authentik_host: "http://authentik-server.authentik.svc.cluster.local"
|
||||
authentik_host_insecure: false
|
||||
authentik_host_browser: "https://auth.renner.uno/"
|
||||
refresh_interval: "minutes=5"
|
||||
container_image: null
|
||||
object_naming_template: "ak-outpost-%(name)s"
|
||||
kubernetes_namespace: authentik
|
||||
kubernetes_replicas: 1
|
||||
kubernetes_service_type: ClusterIP
|
||||
kubernetes_disabled_components: ["ingress"]
|
||||
kubernetes_image_pull_secrets: []
|
||||
kubernetes_ingress_annotations: {}
|
||||
kubernetes_ingress_class_name: null
|
||||
kubernetes_ingress_secret_name: "authentik-outpost-tls"
|
||||
kubernetes_json_patches: null
|
||||
|
||||
- model: authentik_outposts.outpost
|
||||
identifiers:
|
||||
name: authentik Embedded Outpost
|
||||
|
||||
@@ -15,6 +15,8 @@ resources:
|
||||
- alertmanager-gate-egress.yaml
|
||||
- prometheus-gate-egress.yaml
|
||||
- wordpress-gate-egress.yaml
|
||||
- llama-gate-egress.yaml
|
||||
- outpost-servicemonitors.yaml
|
||||
- ldap-outpost-cert.yaml
|
||||
- ldap-outpost-lb.yaml
|
||||
- ldap-outpost-cnp.yaml
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
# Lets the llama-gate outpost reach llama-server. The cluster baseline CNP
|
||||
# (kubernetes/infrastructure/network-policies/authentik-ns.yaml) otherwise stops
|
||||
# any authentik-namespace pod from entering the ai namespace.
|
||||
#
|
||||
# The outpost is the trust boundary: it forwards to llama-server only after
|
||||
# validating the Authentik session, and the matching ingress rule in
|
||||
# ai-ns.yaml admits nothing else — so nothing in the cluster can route around
|
||||
# the gate to reach the unauthenticated API through the public hostname.
|
||||
#
|
||||
# Scoped to this outpost's pod label rather than the whole namespace, so the
|
||||
# other gates cannot reach llama-server either.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: llama-gate-egress
|
||||
namespace: authentik
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
goauthentik.io/outpost-name: llama-gate
|
||||
egress:
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": ai
|
||||
app.kubernetes.io/name: llama-server
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "8080"
|
||||
protocol: TCP
|
||||
@@ -111,9 +111,23 @@ statistics max_id=13323904 max_start_ts=1787162400.0
|
||||
statistics_short_term max_id=159205762 max_start_ts=1787166000.0
|
||||
```
|
||||
|
||||
## Phase 2c — cutover (REMAINING)
|
||||
## Phase 2c — cutover: DONE
|
||||
|
||||
Do not start until phase 1 is verified.
|
||||
Verified 2026-09-02 against the live database:
|
||||
|
||||
| table | rows | span |
|
||||
|---|---|---|
|
||||
| `statistics` | 11,092,586 | 2024-03-13 20:00 → current |
|
||||
| `statistics_short_term` | 1,883,709 | rolling ~7 days |
|
||||
| `statistics_meta` | 1,480 | |
|
||||
|
||||
The 2024-03-13 origin is the proof that matters. It is unchanged from the
|
||||
pre-migration count in "What is at risk" above, so the history was copied
|
||||
across engines rather than started from zero — which is the failure this
|
||||
document exists to prevent. `states` carries current rows, so `db_url` points
|
||||
at Postgres.
|
||||
|
||||
The procedure below is kept as the record of what was done.
|
||||
|
||||
**Migrate statistics only.** Copying all 18M `states` rows is slow, needs a long
|
||||
HA outage, and buys a table that is discarded within 7 days anyway.
|
||||
|
||||
@@ -56,10 +56,9 @@ spec:
|
||||
# Re-rendered on every pod start, so a CNPG password rotation takes
|
||||
# effect rather than silently leaving a stale URI behind.
|
||||
#
|
||||
# INERT until the recorder is repointed: nothing reads this key until
|
||||
# `db_url: !secret recorder_db_url` lands in configuration.yaml in the
|
||||
# homeassistant repo. That ordering is required — HA refuses to start
|
||||
# when a referenced secret is missing.
|
||||
# Load-bearing: `db_url: !secret recorder_db_url` in the homeassistant
|
||||
# repo's configuration.yaml reads this key, so HA does not start
|
||||
# without it. Removing this init container takes the recorder down.
|
||||
- name: render-db-url
|
||||
image: busybox:1.36
|
||||
command:
|
||||
|
||||
@@ -18,12 +18,12 @@
|
||||
# That is the point of the move — it attacks the multiplier rather than trimming
|
||||
# what gets recorded, which would only hide the problem.
|
||||
#
|
||||
# STAGING
|
||||
# Phase 1 (this manifest): the cluster comes up EMPTY and HA keeps using SQLite.
|
||||
# Nothing depends on it, so a failure here is inert. Verify Ready, WAL archiving
|
||||
# to Hetzner, and a successful base backup before going further.
|
||||
# Phase 2: migrate the statistics tables and repoint recorder.db_url.
|
||||
# See MIGRATION-recorder-postgres.md for the sequence and the rollback.
|
||||
# This is the live recorder database. HA writes here; the SQLite file is no
|
||||
# longer the system of record. The 2.4 years of long-term statistics were
|
||||
# copied across engines rather than regenerated — HA does not migrate data
|
||||
# between database engines, so an empty Postgres would have silently started
|
||||
# from zero and taken the energy dashboard's history with it.
|
||||
# MIGRATION-recorder-postgres.md holds the sequence that was followed.
|
||||
apiVersion: postgresql.cnpg.io/v1
|
||||
kind: Cluster
|
||||
metadata:
|
||||
|
||||
@@ -60,9 +60,8 @@ resources:
|
||||
- zigbee2mqtt-gate-service.yaml
|
||||
- zigbee2mqtt-ingress.yaml
|
||||
# Postgres backend for the recorder (MIGRATION-recorder-postgres.md).
|
||||
# PHASE 1: this cluster comes up EMPTY and nothing consumes it — HA is still
|
||||
# on SQLite. Deploying it is therefore inert; phase 2 migrates the statistics
|
||||
# tables and repoints recorder.db_url in the homeassistant repo.
|
||||
# Live: recorder.db_url in the homeassistant repo points here, and the
|
||||
# long-term statistics carry their full history from 2024.
|
||||
- ha-pg-hetzner-creds-externalsecret.yaml
|
||||
- ha-pg-objectstore.yaml
|
||||
- ha-pg-cluster.yaml
|
||||
|
||||
@@ -57,14 +57,7 @@ spec:
|
||||
# and leaves this one untouched (tags are not deleted), so this keeps
|
||||
# resolving — but the image then came from the branch build rather than
|
||||
# master. Re-point it at a master-built tag when convenient.
|
||||
#
|
||||
# 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
|
||||
image: gitea.renner.uno/gitea_admin/alpine-jq:2026-07-30
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
|
||||
@@ -10,15 +10,15 @@ metadata:
|
||||
# Kept permanently as a standing guard.
|
||||
kustomize.toolkit.fluxcd.io/prune: disabled
|
||||
spec:
|
||||
# Single instance during pgvecto.rs → VectorChord migration window;
|
||||
# pg_basebackup tar format chokes on the long pg_vectors/indexes/<uuid>
|
||||
# paths, blocking replica join. Bump back to 2 once Immich's in-app
|
||||
# vchord migration drops the vectors extension.
|
||||
instances: 1
|
||||
# See ak-pg cluster.yaml for rationale. Single-instance, so no failover, but
|
||||
# the barman-plugin shutdown wedge (cloudnative-pg#9770) takes the whole DB
|
||||
# down until SIGKILL — bounding terminationGracePeriod (stopDelay) matters
|
||||
# even without a standby. smartShutdownTimeout keeps pod restarts quick.
|
||||
# A replica is viable because nothing here writes pg_vectors any more. Both
|
||||
# vector indexes run on vchordrq and the vectors extension is gone, so the
|
||||
# long pg_vectors/indexes/<uuid> paths are absent — pg_basebackup's tar
|
||||
# format cannot represent them, and that is what blocks a replica join.
|
||||
instances: 2
|
||||
# See ak-pg cluster.yaml for rationale. The barman-plugin shutdown wedge
|
||||
# (cloudnative-pg#9770) holds an instance up until SIGKILL, so bounding
|
||||
# terminationGracePeriod (stopDelay) is what keeps a failover from waiting
|
||||
# on it. smartShutdownTimeout keeps pod restarts quick.
|
||||
smartShutdownTimeout: 10
|
||||
stopDelay: 60
|
||||
imageName: ghcr.io/corentingiraud/cnpg-pgvector-vectorchord:16-migration
|
||||
@@ -36,8 +36,10 @@ spec:
|
||||
database: immich
|
||||
owner: immich
|
||||
postgresql:
|
||||
# vchord only. The image also ships pgvecto.rs (vectors.so), but its
|
||||
# extension is not installed and preloading it would recreate pg_vectors
|
||||
# in PGDATA for nothing. pgvector needs no preloading.
|
||||
shared_preload_libraries:
|
||||
- vectors.so
|
||||
- vchord.so
|
||||
parameters:
|
||||
shared_buffers: "512MB"
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
---
|
||||
# The chat endpoint, and the only one behind the ingress.
|
||||
#
|
||||
# Single-model rather than router mode. Router mode served chat and embeddings
|
||||
# from one port, but two things made that wrong here:
|
||||
#
|
||||
# 1. Its /health answers 200 as soon as the router process listens, while the
|
||||
# models are still downloading behind it. Kubernetes marked the pod Ready
|
||||
# within seconds of start, so traffic arrived at a server that could not
|
||||
# answer. In single-model mode /health returns 503 until the model is
|
||||
# resident, which is what the probes below actually need.
|
||||
# 2. Every model on the port is selectable in the built-in WebUI, including
|
||||
# the embedding model. Asking an embedding model to chat returns fluent
|
||||
# nonsense ("capital capital capital ...") rather than an error, and
|
||||
# `--embeddings` does not prevent it. There is no flag to hide a model
|
||||
# from the list — `--tags` is informational and `--ui-config` only sets UI
|
||||
# preferences — so the only way to keep it out of the picker is to keep it
|
||||
# off this endpoint. It runs in embed.yaml instead.
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: llama-server
|
||||
namespace: ai
|
||||
labels:
|
||||
app.kubernetes.io/name: llama-server
|
||||
spec:
|
||||
replicas: 1
|
||||
# A rolling update would load a second copy of the weights onto the same
|
||||
# iGPU and into the same node's RAM before releasing the first.
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: llama-server
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: llama-server
|
||||
spec:
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
runAsGroup: 1000
|
||||
# Makes the CephFS volume group-writable so the weights can be cached.
|
||||
fsGroup: 1000
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: llama-server
|
||||
image: ghcr.io/ggml-org/llama.cpp:server-vulkan-b10731
|
||||
args:
|
||||
# The one place this model is named; llama-server resolves the repo
|
||||
# and downloads it itself, so there is nothing to keep in sync.
|
||||
#
|
||||
# A 35B mixture-of-experts with only ~3B parameters active per
|
||||
# token. Decode on this hardware is bound by how many bytes are read
|
||||
# per token rather than by parameter count, and this model reads
|
||||
# ~1.85 GB/token where a dense 27B reads ~16.5 GB — so it is both
|
||||
# far larger and faster than any dense model that would fit.
|
||||
#
|
||||
# Measured here (llama-bench, k8s-1, this image): tg128 11.59 tok/s,
|
||||
# against 10.78 for a dense 8B. Prefill 120.9 t/s at 512 tokens and
|
||||
# 69.8 at 16384 — a sublinear falloff, so the Gated Delta Net
|
||||
# prefill collapse of llama.cpp#27638 does not occur on this stack.
|
||||
- --hf-repo
|
||||
- ggml-org/Qwen3.6-35B-A3B-GGUF:Q4_K_M
|
||||
# Every layer on the Intel iGPU. Full offload matches CPU throughput
|
||||
# while leaving the node's 14 cores free for the rest of the
|
||||
# cluster; the iGPU is here for that isolation, not for a speed-up.
|
||||
- --n-gpu-layers
|
||||
- "99"
|
||||
# Decode runs on the GPU, so extra threads buy nothing. At 11.59
|
||||
# tok/s this model moves 21.4 GB/s against the 53.9 GB/s a dense
|
||||
# model sustains here, so it is the iGPU's 96 EUs working through
|
||||
# many small per-expert matmuls that sets the pace, not bandwidth.
|
||||
- --threads
|
||||
- "4"
|
||||
# 16k rather than 8k because the measured prefill falloff is mild
|
||||
# and this model's KV cache is unusually cheap — only 10 of its 40
|
||||
# layers use full attention.
|
||||
- --ctx-size
|
||||
- "16384"
|
||||
# A micro-batch of 2048 or more makes this Vulkan/Mesa stack fail
|
||||
# with ErrorDeviceLost.
|
||||
- --ubatch-size
|
||||
- "1024"
|
||||
# The server otherwise allows every CORS origin and warns about it
|
||||
# at startup. Without this, any page a LAN browser visits could call
|
||||
# this unauthenticated API from the user's machine. Consumers talk
|
||||
# to it server-side; the WebUI is same-origin and unaffected.
|
||||
- --cors-origins
|
||||
- localhost
|
||||
# Two names for one model. Without this the id is the whole
|
||||
# --hf-repo string, which is unreadable in a model picker and in
|
||||
# logs.
|
||||
#
|
||||
# Exactly one name, because a second one cannot be had usefully:
|
||||
# llama-server keeps aliases in a sorted set and reports the first as
|
||||
# the model's `id`, so with `chat` alongside this the picker always
|
||||
# showed `chat` — in either flag order, since "chat" sorts first.
|
||||
#
|
||||
# Dropping it costs nothing. A second name would only be worth
|
||||
# having as a stable handle for machine consumers, and there is
|
||||
# nothing to stabilise: a single-model server ignores the request's
|
||||
# `model` field entirely and answers to any string, verified against
|
||||
# this endpoint. So no consumer config can break on a model swap,
|
||||
# with or without an alias. The name exists purely to be advertised
|
||||
# on /v1/models, which is what a client reads to build its picker.
|
||||
- --alias
|
||||
- qwen3.6-35b-a3b
|
||||
# Qwen3.6 enables thinking by default. At ~11.6 tok/s that spends
|
||||
# minutes reasoning before answering, and a request with a modest
|
||||
# max_tokens returns an empty `content` with finish_reason "length"
|
||||
# because the whole budget went to reasoning_content. Off by default
|
||||
# keeps answers usable; a client that wants it can send
|
||||
# chat_template_kwargs per request.
|
||||
- --chat-template-kwargs
|
||||
- '{"enable_thinking": false}'
|
||||
env:
|
||||
# Where --hf-repo caches the weights. Pointing it at the PVC makes
|
||||
# the download a one-time cost, shared with llama-embed, and keeps
|
||||
# the server off a home directory it cannot write as UID 1000.
|
||||
- name: LLAMA_CACHE
|
||||
value: /models
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8080
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
# Vulkan allocates the iGPU's working set from system RAM, and that
|
||||
# counts against the pod's cgroup: a 5.2 GiB model measured 7.54 GiB
|
||||
# RSS. Sized as the 19 GiB of weights plus that overhead, plus the KV
|
||||
# cache — which is unusually cheap here, since only 10 of the 40
|
||||
# layers use full attention.
|
||||
resources:
|
||||
requests:
|
||||
cpu: 500m
|
||||
memory: 24Gi
|
||||
gpu.intel.com/i915: "1"
|
||||
limits:
|
||||
cpu: "6"
|
||||
memory: 32Gi
|
||||
gpu.intel.com/i915: "1"
|
||||
# /health is 503 while the model loads and 200 once it is resident, so
|
||||
# the pod only goes Ready when it can actually answer. On a cold PVC
|
||||
# that includes pulling ~19 GiB from Hugging Face, hence the long
|
||||
# startup budget (40 min); liveness takes over only after that.
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: http
|
||||
periodSeconds: 10
|
||||
failureThreshold: 240
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: http
|
||||
periodSeconds: 10
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: http
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 10
|
||||
failureThreshold: 6
|
||||
volumeMounts:
|
||||
- name: models
|
||||
mountPath: /models
|
||||
volumes:
|
||||
- name: models
|
||||
persistentVolumeClaim:
|
||||
claimName: llama-server-models
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: llama-server
|
||||
namespace: ai
|
||||
labels:
|
||||
app.kubernetes.io/name: llama-server
|
||||
spec:
|
||||
selector:
|
||||
app.kubernetes.io/name: llama-server
|
||||
ports:
|
||||
- name: http
|
||||
port: 8080
|
||||
targetPort: http
|
||||
@@ -0,0 +1,165 @@
|
||||
---
|
||||
# The embedding endpoint, for RAG and semantic search.
|
||||
#
|
||||
# Deliberately separate from llama-server and deliberately not behind the
|
||||
# ingress: an embedding model has no generative training, and when it shares a
|
||||
# port with the chat model the built-in WebUI lists it as a choice. Selecting it
|
||||
# returns fluent nonsense rather than an error, because `--embeddings` does not
|
||||
# actually refuse chat completions. Keeping it on its own ClusterIP-only Service
|
||||
# is what makes it unselectable, since llama.cpp has no way to hide a model from
|
||||
# the picker.
|
||||
#
|
||||
# Reachable in-cluster at http://llama-embed.ai.svc.cluster.local:8080/v1/embeddings.
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: llama-embed
|
||||
namespace: ai
|
||||
labels:
|
||||
app.kubernetes.io/name: llama-embed
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: llama-embed
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: llama-embed
|
||||
spec:
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
runAsGroup: 1000
|
||||
fsGroup: 1000
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: llama-embed
|
||||
image: ghcr.io/ggml-org/llama.cpp:server-vulkan-b10731
|
||||
args:
|
||||
- --hf-repo
|
||||
- gpustack/bge-m3-GGUF:Q8_0
|
||||
# Serves POST /v1/embeddings.
|
||||
- --embeddings
|
||||
# A short, stable id for consumers. Without it the model is
|
||||
# advertised as the whole --hf-repo string, which would end up
|
||||
# verbatim in Paperless's config and break on the next model swap.
|
||||
# One name only: llama-server keeps aliases in a sorted set and
|
||||
# reports the first as the id, so a second name can steal it.
|
||||
- --alias
|
||||
- bge-m3
|
||||
# bge-m3 is trained for CLS pooling — a different type from the
|
||||
# Qwen family this replaces, which uses last-token. Left unspecified
|
||||
# llama-server takes the GGUF default, which is not visible from
|
||||
# outside and would silently produce vectors the model was not
|
||||
# trained to emit. Read from the model's own 1_Pooling/config.json
|
||||
# rather than assumed; of the candidates measured, three different
|
||||
# pooling types were needed.
|
||||
- --pooling
|
||||
- cls
|
||||
- --n-gpu-layers
|
||||
- "99"
|
||||
- --threads
|
||||
- "2"
|
||||
# Paperless chunks at 1024 tokens, but counts them with its own
|
||||
# tokenizer while this server measures the batch in bge-m3's.
|
||||
# Those disagree: XLM-RoBERTa splits German into more pieces than
|
||||
# llama_index's counter does, so a chunk Paperless considers full
|
||||
# at 1024 arrived here as 1069 and the request failed outright —
|
||||
# "input (1069 tokens) is too large to process".
|
||||
#
|
||||
# The headroom therefore has to absorb a tokenizer ratio, not just
|
||||
# a rounding error, and 1.5x covers far more than the 1.044 that
|
||||
# broke it. The defaults are batch 2048 / ubatch 512, and with
|
||||
# --embeddings llama-server clamps both to the smaller rather than
|
||||
# run them mismatched, so both are set.
|
||||
#
|
||||
# 2048 stays avoided: that is where the chat model met
|
||||
# ErrorDeviceLost on this Vulkan stack. That failure came under a
|
||||
# 20 GB model's memory pressure and may not bind a 600 MB one, but
|
||||
# this does not need to find out.
|
||||
- --batch-size
|
||||
- "1536"
|
||||
- --ubatch-size
|
||||
- "1536"
|
||||
# Room for a full chunk plus overhead rather than exactly one.
|
||||
- --ctx-size
|
||||
- "4096"
|
||||
# No browser ever talks to this Service, so the WebUI is dead weight
|
||||
# and one more unauthenticated surface.
|
||||
- --no-webui
|
||||
env:
|
||||
# Same cache as llama-server, so the two share one copy of anything
|
||||
# they have in common and neither re-downloads the other's weights.
|
||||
- name: LLAMA_CACHE
|
||||
value: /models
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8080
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
# Measured, not estimated: embedding 400 real documents peaked at
|
||||
# 1.6 GiB (Prometheus max_over_time on the working set). The limit is
|
||||
# well above that because the footprint is dominated by the four slots
|
||||
# at 4096 context and the Vulkan working set rather than by the
|
||||
# weights — the Qwen model this replaces peaked at 10.6 GiB on the
|
||||
# same settings for the same 600 MiB of weights, and several
|
||||
# candidates were OOMKilled at limits that looked generous against
|
||||
# their model size.
|
||||
resources:
|
||||
requests:
|
||||
cpu: 250m
|
||||
memory: 2Gi
|
||||
gpu.intel.com/i915: "1"
|
||||
limits:
|
||||
cpu: "2"
|
||||
memory: 8Gi
|
||||
gpu.intel.com/i915: "1"
|
||||
# ~600 MiB rather than ~5 GiB, so a much shorter budget than the chat
|
||||
# model needs, but still sized for a cold download.
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: http
|
||||
periodSeconds: 10
|
||||
failureThreshold: 60
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: http
|
||||
periodSeconds: 10
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: http
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 10
|
||||
failureThreshold: 6
|
||||
volumeMounts:
|
||||
- name: models
|
||||
mountPath: /models
|
||||
volumes:
|
||||
- name: models
|
||||
persistentVolumeClaim:
|
||||
claimName: llama-server-models
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: llama-embed
|
||||
namespace: ai
|
||||
labels:
|
||||
app.kubernetes.io/name: llama-embed
|
||||
spec:
|
||||
selector:
|
||||
app.kubernetes.io/name: llama-embed
|
||||
ports:
|
||||
- name: http
|
||||
port: 8080
|
||||
targetPort: http
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
# ExternalName mirror so the ai-namespace Ingress can backend to the llama-gate
|
||||
# outpost that Authentik deploys into the authentik namespace (cross-namespace
|
||||
# Service references are not supported in a standard Ingress).
|
||||
#
|
||||
# Traffic path:
|
||||
# browser → traefik (llm.renner.uno)
|
||||
# → ak-outpost-llama-gate (ExternalName → authentik ns)
|
||||
# → outpost validates the session + passkey step-up
|
||||
# → http://llama-server.ai.svc.cluster.local:8080
|
||||
#
|
||||
# In-cluster consumers skip all of this and address llama-server directly.
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: ak-outpost-llama-gate
|
||||
namespace: ai
|
||||
spec:
|
||||
type: ExternalName
|
||||
externalName: ak-outpost-llama-gate.authentik.svc.cluster.local
|
||||
ports:
|
||||
- port: 9000
|
||||
targetPort: 9000
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
# llm.renner.uno — the chat WebUI, public and behind Authentik.
|
||||
#
|
||||
# Traefik no longer reaches llama-server directly. This Ingress backends the
|
||||
# llama-gate proxy outpost (ExternalName → authentik ns), which enforces a
|
||||
# passkey step-up and forwards only after the session validates. The ai
|
||||
# CiliumNetworkPolicy is tightened so ONLY that outpost pod may reach
|
||||
# llama-server, so nothing in the cluster can route around the gate. Pattern
|
||||
# mirrors kubernetes/apps/home-assistant/vscode-ingress.yaml.
|
||||
#
|
||||
# The gate is the entire access control here: llama-server has no
|
||||
# authentication of its own and serves a full OpenAI-compatible API on the same
|
||||
# port as the UI. That is what kept this hostname LAN-only until now.
|
||||
#
|
||||
# In-cluster consumers — Home Assistant, Paperless, CI — address
|
||||
# llama-server.ai.svc.cluster.local:8080 directly and never pass through
|
||||
# Traefik or the outpost, so they need no credentials and are unaffected by the
|
||||
# gate.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: llama-server
|
||||
namespace: ai
|
||||
labels:
|
||||
# Public: Cloudflare wildcard → tunnel → NetBird proxy → Traefik. The UDM
|
||||
# external-dns instance skips anything carrying this, and LAN clients reach
|
||||
# the same hostname through the hairpin NAT.
|
||||
dns.renner.uno/public: "true"
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-cluster
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
tls:
|
||||
- hosts:
|
||||
- llm.renner.uno
|
||||
secretName: llama-server-tls
|
||||
rules:
|
||||
- host: llm.renner.uno
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: ak-outpost-llama-gate
|
||||
port:
|
||||
number: 9000
|
||||
@@ -0,0 +1,8 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- pvc.yaml
|
||||
- deployment.yaml
|
||||
- embed.yaml
|
||||
- gate-service.yaml
|
||||
- ingress.yaml
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
# Holds the GGUF weights, downloaded once by llama-server itself via --hf-repo.
|
||||
# Prune-disabled so retiring this Kustomization does not throw away tens of GiB
|
||||
# of model files that would have to be pulled from Hugging Face again.
|
||||
#
|
||||
# ReadWriteMany so a second replica, a one-off benchmark Job, or a candidate
|
||||
# model being evaluated can mount the same weights; the models are read-only at
|
||||
# inference time.
|
||||
#
|
||||
# Sized well beyond the two deployed models. The cache is shared by every
|
||||
# candidate under evaluation (scripts/embed-eval-real.sh), and on a slow
|
||||
# connection the download is the long pole — a shared cache lets the large
|
||||
# candidates pull in the background while the small ones are already being
|
||||
# measured, and means a model is never fetched twice across runs.
|
||||
#
|
||||
# CephFS supports online expansion (allowVolumeExpansion on the storage class),
|
||||
# so growing this does not disturb the running pods.
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: llama-server-models
|
||||
namespace: ai
|
||||
annotations:
|
||||
kustomize.toolkit.fluxcd.io/prune: disabled
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteMany
|
||||
storageClassName: ceph-filesystem
|
||||
resources:
|
||||
requests:
|
||||
storage: 120Gi
|
||||
@@ -1,46 +0,0 @@
|
||||
# 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}
|
||||
@@ -13,7 +13,6 @@ resources:
|
||||
- prometheusrule-loki.yaml
|
||||
- prometheusrule-mariadb-backup.yaml
|
||||
- prometheusrule-netbird-availability.yaml
|
||||
- prometheusrule-netbird-backup.yaml
|
||||
- prometheusrule-netbird-ingress.yaml
|
||||
- prometheusrule-openbao.yaml
|
||||
- prometheusrule-velero.yaml
|
||||
@@ -23,20 +22,9 @@ resources:
|
||||
# endpoint, which proves reachability, not that anything was written.
|
||||
- prometheusrule-cnpg-backup.yaml
|
||||
- alloy-servicemonitor.yaml
|
||||
- cilium-servicemonitor.yaml
|
||||
- cnpg-podmonitors.yaml
|
||||
- home-assistant-servicemonitor.yaml
|
||||
- immich-servicemonitor.yaml
|
||||
- loki-servicemonitor.yaml
|
||||
- smartctl-exporter-servicemonitor.yaml
|
||||
- probes-blackbox.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
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
apiVersion: monitoring.coreos.com/v1
|
||||
kind: PrometheusRule
|
||||
metadata:
|
||||
name: netbird-backup
|
||||
namespace: monitoring
|
||||
labels:
|
||||
# kube-prometheus-stack's Prometheus selects on this label.
|
||||
release: kube-prometheus-stack
|
||||
spec:
|
||||
groups:
|
||||
- name: netbird-backup.rules
|
||||
rules:
|
||||
- alert: NetbirdVpsBackupStale
|
||||
# The nightly pull of the controller VPS's management store
|
||||
# hasn't succeeded in over 26 h (one missed cycle + slack).
|
||||
# This dump is the only copy of the mesh state (peers, setup
|
||||
# keys, routes) that survives losing the Hetzner VPS — a
|
||||
# silently dead backup is exactly the failure mode the SPOF
|
||||
# assessment flagged.
|
||||
expr: |
|
||||
time() - kube_cronjob_status_last_successful_time{namespace="netbird-backup", cronjob="netbird-vps-backup"} > 26 * 3600
|
||||
for: 30m
|
||||
labels:
|
||||
severity: critical
|
||||
annotations:
|
||||
summary: "NetBird VPS backup has not succeeded in 26h+"
|
||||
description: "The netbird-vps-backup CronJob's last successful run is over 26 hours old. Check the job logs in the netbird-backup namespace (ssh reachability of netbird.renner.uno, the netbird-backup-ssh ExternalSecret, PVC space)."
|
||||
|
||||
- alert: NetbirdVpsBackupNeverRan
|
||||
# The CronJob exists but has never succeeded — most likely the
|
||||
# one-time OpenBao provisioning step
|
||||
# (bao kv put homelab/infrastructure/netbird-backup ssh-key=@…)
|
||||
# hasn't been done, so the ExternalSecret cannot sync and the
|
||||
# job pod fails on the missing secret mount.
|
||||
expr: |
|
||||
kube_cronjob_created{namespace="netbird-backup", cronjob="netbird-vps-backup"}
|
||||
unless
|
||||
kube_cronjob_status_last_successful_time{namespace="netbird-backup", cronjob="netbird-vps-backup"}
|
||||
for: 6h
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "NetBird VPS backup has never succeeded"
|
||||
description: "The netbird-vps-backup CronJob exists but has no successful run. If this is the initial rollout: the private key must be provisioned once by hand — bao kv put homelab/infrastructure/netbird-backup ssh-key=@<keyfile> — before the ExternalSecret can sync."
|
||||
@@ -1,93 +0,0 @@
|
||||
# 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
|
||||
@@ -1,97 +0,0 @@
|
||||
# paperless-data: CephFS → RBD
|
||||
|
||||
## Why
|
||||
|
||||
`paperless-data` is `ReadWriteMany` on `ceph-filesystem`, but **only the paperless
|
||||
Deployment mounts it**. Verified 2026-08-20:
|
||||
|
||||
```
|
||||
$ pods mounting paperless-data
|
||||
paperless-68cb8b8744-7hjb8 <- one, and only one
|
||||
|
||||
paperless-data ReadWriteMany ceph-filesystem
|
||||
paperless-media ReadWriteMany ceph-filesystem <- genuinely shared (ftp-scanner)
|
||||
paperless-consume ReadWriteMany ceph-filesystem <- genuinely shared (ftp-scanner)
|
||||
```
|
||||
|
||||
Its contents are single-writer state — the 50 MB Whoosh index,
|
||||
`classification_model.pickle`, `celerybeat-schedule.db`, `log/`,
|
||||
`migration_lock`, `protonmail.pem`. 75 files. Nothing else reads or writes them.
|
||||
|
||||
It is on CephFS by default, not by requirement.
|
||||
|
||||
## What it costs
|
||||
|
||||
Every access goes through the MDS. Measured with **zero CI pipelines running**:
|
||||
|
||||
```
|
||||
paperless-data client request_load_avg 22 (all other sessions 0-1)
|
||||
MDS over 180s 4,052 client requests = 22.5/s
|
||||
2,718 journal events = 15/s
|
||||
2 dir commits <- almost none: not file writes
|
||||
```
|
||||
|
||||
So it is cap/inode churn from repeated access, not data being written. That is
|
||||
most of the constant **7-8 GB/day** written to `cephfs-metadata`, and since that
|
||||
pool is `size: 3`, roughly **21-24 GB/day** reaches the drives.
|
||||
|
||||
`cephfs-metadata` deliberately stays at size 3. Losing it destroys the entire
|
||||
filesystem rather than the objects on one OSD, and the 275 GiB in `cephfs-data0`
|
||||
depends on it. Cutting the writes at source is correct; cutting the replication
|
||||
is not.
|
||||
|
||||
## Sequence
|
||||
|
||||
Staged deliberately: the PVC lands first and empty, so merging it changes
|
||||
nothing. The copy and the repoint are separate.
|
||||
|
||||
### Phase 1 — provision (this PR)
|
||||
|
||||
`paperless-data-block` (RWO, `ceph-block`, 10Gi) is created and mounted by
|
||||
nothing. Inert.
|
||||
|
||||
Verify: `kubectl -n paperless get pvc paperless-data-block` → Bound.
|
||||
|
||||
### Phase 2 — copy
|
||||
|
||||
The Whoosh index must come across intact. Paperless *can* rebuild it
|
||||
(`document_index reindex`), but that reprocesses every document, so copying is
|
||||
both faster and less disruptive.
|
||||
|
||||
1. Scale paperless to 0. The source must be quiescent — `celerybeat-schedule.db`
|
||||
and the Whoosh index are both live-written, and copying them under an active
|
||||
writer gives a torn read.
|
||||
2. Run a one-off pod that mounts BOTH PVCs and copies:
|
||||
```
|
||||
cp -a /src/. /dst/
|
||||
```
|
||||
The RWO target pins the pod to one node; the RWX source will follow.
|
||||
3. Verify file count and index size match:
|
||||
```
|
||||
find /src -type f | wc -l == find /dst -type f | wc -l
|
||||
du -sh /src/index == du -sh /dst/index
|
||||
```
|
||||
|
||||
### Phase 3 — repoint
|
||||
|
||||
Change the Deployment's `data` volume to `claimName: paperless-data-block`,
|
||||
merge, let it roll.
|
||||
|
||||
Verify afterwards:
|
||||
- paperless starts, search returns results (proves the Whoosh index survived)
|
||||
- `ceph tell mds.cephfs-b session ls` no longer shows a busy `paperless-data`
|
||||
client
|
||||
- `rate(ceph_pool_wr_bytes{pool_id="3"})` drops from its 7-8 GB/day baseline
|
||||
|
||||
**Do not delete `paperless-data` until search has been confirmed working.** It is
|
||||
the rollback, and it is `prune: disabled` so Flux will not remove it.
|
||||
|
||||
## Expected effect
|
||||
|
||||
~7-8 GB/day off `cephfs-metadata`, so ~21-24 GB/day off the drives once
|
||||
replication is counted. Against ~432 GB/day cluster-wide that is roughly 5% —
|
||||
worth doing, but it is a correctness fix (a single-writer volume does not belong
|
||||
on a shared filesystem) more than a large win.
|
||||
|
||||
It also removes 22 req/sec of MDS load, which benefits the workloads that
|
||||
genuinely need CephFS — Woodpecker CI workspaces share the same MDS.
|
||||
@@ -57,6 +57,20 @@ spec:
|
||||
protocol: UDP
|
||||
- port: "53"
|
||||
protocol: TCP
|
||||
# The cluster's own LLM, for AI suggestions and RAG embeddings. Scoped to
|
||||
# the two pods and the one port rather than the ai namespace as a whole, so
|
||||
# this grant cannot widen if something else is ever placed there.
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": ai
|
||||
app.kubernetes.io/name: llama-server
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": ai
|
||||
app.kubernetes.io/name: llama-embed
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "8080"
|
||||
protocol: TCP
|
||||
# External HTTPS only — Tika model downloads, runtime image
|
||||
# fetches, etc. RFC1918 + ULA are excluded from `world` via the
|
||||
# CIDR-deny pattern. `world` covers public IPs / DNS-resolvable
|
||||
|
||||
@@ -31,12 +31,14 @@ data:
|
||||
PAPERLESS_CONSUMER_DELETE_DUPLICATES: "true"
|
||||
PAPERLESS_CONSUMER_ENABLE_BARCODES: "true"
|
||||
PAPERLESS_CONSUMER_ENABLE_ASN_BARCODE: "true"
|
||||
PAPERLESS_CONSUMER_BARCODE_SCANNER: "ZXING"
|
||||
# No CONSUMER_BARCODE_SCANNER: zxing-cpp is the only backend, so the
|
||||
# setting no longer exists. pyzbar and libzbar are gone from the image.
|
||||
#
|
||||
# CephFS does not propagate inotify across nodes — files written
|
||||
# via the RWX consume PVC from a pod on a different node than
|
||||
# paperless never fire IN_CREATE in paperless's kernel. Polling
|
||||
# is the reliable backstop. See wiki: cephfs-inotify-no-cross-node-propagation.
|
||||
PAPERLESS_CONSUMER_POLLING: "10"
|
||||
PAPERLESS_CONSUMER_POLLING_INTERVAL: "10"
|
||||
PAPERLESS_REDIS: "redis://redis:6379"
|
||||
PAPERLESS_DBENGINE: "postgresql"
|
||||
PAPERLESS_DBHOST: "pprl-pg-rw"
|
||||
@@ -44,6 +46,47 @@ data:
|
||||
PAPERLESS_TIKA_ENABLED: "1"
|
||||
PAPERLESS_TIKA_ENDPOINT: "http://tika:9998"
|
||||
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: "http://gotenberg:3000"
|
||||
# AI suggestions against the cluster's own LLM (kubernetes/apps/llama-server).
|
||||
# Nothing leaves the cluster: both endpoints are ClusterIP Services, so
|
||||
# document text is never sent to a third party and there is no API key to
|
||||
# rotate — llama-server has no authentication, and the ai namespace's network
|
||||
# policy is what restricts who may reach it.
|
||||
#
|
||||
# CAVEAT, and it is a real one: paperless reads these from an
|
||||
# ApplicationConfiguration row in the database and falls back to the
|
||||
# environment only when that row is empty. A value set in the admin UI
|
||||
# therefore overrides this ConfigMap silently. Configure AI in ONE place —
|
||||
# here — and leave those fields untouched in the UI.
|
||||
PAPERLESS_AI_ENABLED: "true"
|
||||
PAPERLESS_AI_LLM_BACKEND: "openai-like"
|
||||
PAPERLESS_AI_LLM_ENDPOINT: "http://llama-server.ai.svc.cluster.local:8080/v1"
|
||||
# Must match --alias in kubernetes/apps/llama-server/deployment.yaml. The
|
||||
# server ignores the field on the wire, but paperless sends it and the value
|
||||
# is what appears in its logs.
|
||||
PAPERLESS_AI_LLM_MODEL: "qwen3.6-35b-a3b"
|
||||
# ClusterIP addresses are private, and paperless blocks non-public endpoints
|
||||
# unless this is set. Default is already true; explicit because the whole
|
||||
# integration silently stops working if it ever flips.
|
||||
PAPERLESS_AI_LLM_ALLOW_INTERNAL_ENDPOINTS: "true"
|
||||
# Under llama-server's own 16384, leaving room for the response.
|
||||
PAPERLESS_AI_LLM_CONTEXT_SIZE: "12288"
|
||||
# The 120s default is too tight at ~11 tok/s: a classification is roughly
|
||||
# 2-4k prompt tokens plus a second round trip for localisation.
|
||||
PAPERLESS_AI_LLM_REQUEST_TIMEOUT: "300"
|
||||
# Matched against Django's settings.LANGUAGES, so the region is required —
|
||||
# "de" alone does not resolve. Without it the model returns German tags but
|
||||
# English document types, which was reproducible against this endpoint.
|
||||
PAPERLESS_AI_LLM_OUTPUT_LANGUAGE: "de-de"
|
||||
# RAG embeddings from llama-embed. Its Qwen3-Embedding covers 100+ languages,
|
||||
# where paperless's default all-MiniLM-L6-v2 is documented as suited to
|
||||
# primarily English archives — the better fit for a German corpus.
|
||||
PAPERLESS_AI_LLM_EMBEDDING_BACKEND: "openai-like"
|
||||
PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT: "http://llama-embed.ai.svc.cluster.local:8080/v1"
|
||||
# Must match --alias in kubernetes/apps/llama-server/embed.yaml. Changing the
|
||||
# embedding model invalidates every stored vector, so a swap means the LLM
|
||||
# index has to be rebuilt.
|
||||
PAPERLESS_AI_LLM_EMBEDDING_MODEL: "bge-m3"
|
||||
PAPERLESS_AI_LLM_EMBEDDING_CHUNK_SIZE: "1024"
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
@@ -56,6 +99,13 @@ metadata:
|
||||
# Reloader restarts paperless when paperless-secrets changes (e.g. after a
|
||||
# secret-key rotation). See kubernetes/infrastructure/openbao/rotation/paperless.yaml.
|
||||
secret.reloader.stakater.com/reload: paperless-secrets
|
||||
# And when paperless-config changes. The ConfigMap is consumed with
|
||||
# envFrom, and Kubernetes does not restart a pod when a ConfigMap it reads
|
||||
# that way is updated — the running container keeps the environment it
|
||||
# started with. Without this, a settings change applies cleanly in git and
|
||||
# in the cluster while the application never sees it, which is how the AI
|
||||
# settings landed with no effect at all.
|
||||
configmap.reloader.stakater.com/reload: paperless-config
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
@@ -93,20 +143,7 @@ spec:
|
||||
# on every paperless pod start: tools belong in the image, not
|
||||
# installed at runtime. Dated tag so a change is a reviewable diff
|
||||
# here, matching the ci-ansible / alpine-jq convention.
|
||||
#
|
||||
# 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
|
||||
image: gitea.renner.uno/gitea_admin/alpine-openssl:2026-07-30
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
@@ -133,7 +170,7 @@ spec:
|
||||
drop: ["ALL"]
|
||||
containers:
|
||||
- name: paperless
|
||||
image: ghcr.io/paperless-ngx/paperless-ngx:2.20.15 # {"$imagepolicy": "flux-system:paperless-ngx"}
|
||||
image: ghcr.io/paperless-ngx/paperless-ngx:3.1.2 # {"$imagepolicy": "flux-system:paperless-ngx"}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8000
|
||||
@@ -185,6 +222,25 @@ spec:
|
||||
# Safe against ALLOWED_HOSTS: the kubelet sends the pod IP as Host,
|
||||
# and PAPERLESS_ALLOWED_HOSTS is "*". Narrowing that value later
|
||||
# means giving these probes an explicit Host header.
|
||||
|
||||
# The container does not serve until its init sequence finishes:
|
||||
# s6 makes svc-webserver depend on init-migrations and
|
||||
# init-search-index. On the first 3.x start those are the SHA256
|
||||
# checksum backfill, which opens and reads every original and every
|
||||
# archive file, and a full Tantivy index build, since the Whoosh
|
||||
# index cannot be converted. Both scale with the corpus.
|
||||
#
|
||||
# startupProbe holds liveness and readiness off until the webserver
|
||||
# answers, giving that boot 30 * 20s = 10 minutes. Without it the
|
||||
# 120s liveness delay plus three 30s periods kills the pod 210s in,
|
||||
# part-way through a migration, and the next boot starts over.
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /accounts/login/
|
||||
port: http
|
||||
periodSeconds: 20
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 30
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /accounts/login/
|
||||
@@ -233,22 +289,16 @@ spec:
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
volumes:
|
||||
# paperless-data-block (RWO, ceph-block), NOT paperless-data
|
||||
# (RWX, ceph-filesystem). Only this Deployment ever mounted the volume,
|
||||
# and its contents are single-writer state — the Whoosh index,
|
||||
# classification model, celerybeat schedule, logs. Being on CephFS meant
|
||||
# every access went through the MDS: 22 requests/sec sustained with zero
|
||||
# CI running, ~15 journal events/sec against 2 directory commits per
|
||||
# 180s, i.e. cap/inode churn rather than file writes. That was most of
|
||||
# the constant 7-8 GB/day on cephfs-metadata, and that pool is size 3.
|
||||
# `data` is RWO on ceph-block, not RWX on ceph-filesystem. Only this
|
||||
# Deployment mounts it and its contents are single-writer state — the
|
||||
# search index, classification model, celerybeat schedule, logs. On
|
||||
# CephFS every access went through the MDS: 22 requests/sec sustained
|
||||
# with zero CI running, ~15 journal events/sec against 2 directory
|
||||
# commits per 180s, i.e. cap/inode churn rather than file writes. That
|
||||
# was most of the constant 7-8 GB/day on cephfs-metadata, a size 3 pool.
|
||||
#
|
||||
# `consume` and `media` below STAY on CephFS: the ftp-scanner writes to
|
||||
# those from another node, so they genuinely need RWX.
|
||||
#
|
||||
# paperless-data is retained (prune: disabled) as the rollback. Do not
|
||||
# delete it until search has been confirmed working — that is what
|
||||
# proves the Whoosh index survived the copy, not merely that paperless
|
||||
# starts. Migration record: MIGRATION-data-to-block.md.
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: paperless-data-block
|
||||
|
||||
@@ -3,33 +3,18 @@
|
||||
# (e.g. when the seed initContainer or the Garage migration creds are
|
||||
# removed once we're done with the migration phase). Velero's
|
||||
# bootstrap-restore then owns their contents.
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: paperless-data
|
||||
namespace: paperless
|
||||
annotations:
|
||||
kustomize.toolkit.fluxcd.io/prune: disabled
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteMany
|
||||
storageClassName: ceph-filesystem
|
||||
resources:
|
||||
requests:
|
||||
storage: 10Gi
|
||||
---
|
||||
# Replacement for paperless-data, on RBD instead of CephFS.
|
||||
|
||||
# paperless's single-writer state, on RBD rather than CephFS.
|
||||
#
|
||||
# WHY: paperless-data is declared ReadWriteMany on ceph-filesystem, but only ONE
|
||||
# pod ever mounts it — the paperless Deployment itself. Its contents are all
|
||||
# single-writer state: the 50 MB Whoosh index, classification_model.pickle,
|
||||
# celerybeat-schedule.db, logs, migration_lock. Nothing shares it. Only `consume`
|
||||
# and `media` genuinely need RWX, because the FTP scanner writes to those from a
|
||||
# different node.
|
||||
# WHY RWO: only ONE pod mounts it — the paperless Deployment itself. Its
|
||||
# contents are all single-writer state: the Tantivy search index,
|
||||
# classification_model.pickle, celerybeat-schedule.db, logs, migration_lock.
|
||||
# Nothing shares it. Only `consume` and `media` genuinely need RWX, because the
|
||||
# FTP scanner writes to those from a different node.
|
||||
#
|
||||
# COST OF LEAVING IT: every access goes through the MDS. Measured 2026-08-20 with
|
||||
# zero CI running, this volume's client was doing 22 MDS requests/sec sustained,
|
||||
# and the MDS logged ~15 journal events/sec against only 2 directory commits per
|
||||
# WHY NOT CEPHFS: every access goes through the MDS. With zero CI running, this
|
||||
# volume's client sustained 22 MDS requests/sec, and the MDS logged ~15 journal
|
||||
# events/sec against only 2 directory commits per
|
||||
# 180s — i.e. cap and inode churn from repeated access, not file writes. That is
|
||||
# most of the constant 7-8 GB/day written to cephfs-metadata, which is a size 3
|
||||
# pool, so ~21-24 GB/day actually hits the drives.
|
||||
@@ -38,10 +23,6 @@ spec:
|
||||
# the metadata pool destroys the whole filesystem, not just the objects on one
|
||||
# OSD, so the 275 GiB in cephfs-data0 rides on it. Reducing the writes at source
|
||||
# is the right fix; reducing the replication is not.
|
||||
#
|
||||
# MIGRATION: this PVC is created EMPTY and nothing mounts it yet. The data copy
|
||||
# and the deployment repoint are a separate, staged step — see
|
||||
# MIGRATION-data-to-block.md. Merging this alone is inert.
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
|
||||
@@ -177,14 +177,7 @@ spec:
|
||||
# webhook start failing with "expired JWT access token" about
|
||||
# thirty minutes after seeding.
|
||||
- name: render-schedule
|
||||
# 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
|
||||
image: gitea.renner.uno/gitea_admin/alpine-jq:2026-07-30
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
|
||||
@@ -27,7 +27,7 @@ spec:
|
||||
# Valkey major version tracks upstream's compose.yml (9.x). Sidekiq
|
||||
# is the only consumer; a major bump is a separate migration.
|
||||
- name: valkey
|
||||
image: valkey/valkey:9.1.1-alpine # {"$imagepolicy": "flux-system:valkey-trmnl"}
|
||||
image: valkey/valkey:9.1.2-alpine # {"$imagepolicy": "flux-system:valkey-trmnl"}
|
||||
args:
|
||||
- valkey-server
|
||||
- --save
|
||||
|
||||
@@ -18,7 +18,7 @@ spec:
|
||||
spec:
|
||||
containers:
|
||||
- name: whisper
|
||||
image: rhasspy/wyoming-whisper:3.6.0 # {"$imagepolicy": "flux-system:wyoming-whisper"}
|
||||
image: rhasspy/wyoming-whisper:3.7.0 # {"$imagepolicy": "flux-system:wyoming-whisper"}
|
||||
args:
|
||||
- --model=base-int8
|
||||
- --language=de
|
||||
|
||||
@@ -1,3 +1,18 @@
|
||||
# Dedicated namespace where the kubernetes backend spawns pipeline step pods,
|
||||
# isolated from the apps namespace.
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: woodpecker-ci
|
||||
labels:
|
||||
app.kubernetes.io/name: woodpecker
|
||||
# Image-build steps run a privileged docker/buildx plugin pod (see the
|
||||
# server's WOODPECKER_PLUGINS_PRIVILEGED). Talos enforces Pod Security;
|
||||
# without this the privileged step pods are rejected at admission. The
|
||||
# namespace is dedicated to ephemeral CI step pods, so scoping the
|
||||
# exemption here keeps it off apps/.
|
||||
pod-security.kubernetes.io/enforce: privileged
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
|
||||
+60
-1377
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -8,10 +8,10 @@ metadata:
|
||||
spec:
|
||||
interval: 1m0s
|
||||
ref:
|
||||
branch: vmtest-flux
|
||||
branch: master
|
||||
secretRef:
|
||||
name: flux-system
|
||||
url: https://gitea.renner.uno/gitea_admin/homelab.git
|
||||
url: https://gitea.renner.uno/k.renner/homelab.git
|
||||
---
|
||||
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||
kind: Kustomization
|
||||
|
||||
@@ -28,19 +28,6 @@ spec:
|
||||
- name: infrastructure
|
||||
- name: infrastructure-crds
|
||||
- name: infrastructure-ceph-cluster
|
||||
# ha-pg-objectstore.yaml declares an ObjectStore, whose CRD comes from the
|
||||
# CNPG Barman plugin in infrastructure-cnpg-barman. Without this edge the
|
||||
# kind is unknown when this path is first applied, and a server-side
|
||||
# dry-run rejects the ENTIRE set over the one object it cannot recognise:
|
||||
#
|
||||
# ObjectStore/home-assistant/ha-pg-store dry-run failed:
|
||||
# no matches for kind "ObjectStore" in version ...
|
||||
#
|
||||
# Every other ObjectStore user — authentik, immich, paperless, trmnl,
|
||||
# woodpecker — already declares it. This was the only one that did not, and
|
||||
# it took speech-to-phrase, zone-configurator and matter-server down with
|
||||
# it, since all three wait on home-assistant.
|
||||
- name: infrastructure-cnpg-barman
|
||||
---
|
||||
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||
kind: Kustomization
|
||||
|
||||
@@ -122,20 +122,8 @@ spec:
|
||||
path: ./kubernetes/infrastructure/rook-ceph-cluster
|
||||
prune: true
|
||||
wait: true
|
||||
# Deliberately NOT dependsOn: infrastructure. That edge closed a cycle no
|
||||
# existing cluster could ever hit and no new one could ever escape:
|
||||
# infrastructure is Ready only once its openbao healthCheck passes, openbao-0
|
||||
# only runs once its PVC binds, and that PVC wants the ceph-block StorageClass
|
||||
# defined in this very Kustomization. A cold rebuild stopped here for good,
|
||||
# with openbao-0 Pending on "unbound immediate PersistentVolumeClaims".
|
||||
#
|
||||
# The edge was never needed. infrastructure runs wait: false, so it applies
|
||||
# the rook-ceph namespace and the operator immediately and only its readiness
|
||||
# waits on the healthChecks — everything this path needs exists long before
|
||||
# infrastructure reports Ready. On an empty API server this Kustomization
|
||||
# retries on retryInterval until the operator installs its CRDs, the same
|
||||
# self-healing shape post-crds already relies on; on a live cluster the CRDs
|
||||
# are always present and nothing changes.
|
||||
dependsOn:
|
||||
- name: infrastructure
|
||||
# kstatus doesn't know how to read Rook CR readiness, so without these
|
||||
# this Kustomization reports Ready as soon as the manifests apply — long
|
||||
# before OSDs/mons/MDS are actually serving I/O. Apps depending on this
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||
kind: Kustomization
|
||||
metadata:
|
||||
name: llama-server
|
||||
namespace: flux-system
|
||||
spec:
|
||||
interval: 10m
|
||||
retryInterval: 1m
|
||||
# On a cold PVC the chat model pulls ~19 GiB of GGUF weights from Hugging
|
||||
# Face and then loads onto the iGPU before /health goes green, so a first
|
||||
# start is far slower than a normal app's.
|
||||
timeout: 60m
|
||||
sourceRef:
|
||||
kind: GitRepository
|
||||
name: flux-system
|
||||
path: ./kubernetes/apps/llama-server
|
||||
prune: true
|
||||
wait: true
|
||||
dependsOn:
|
||||
- name: infrastructure
|
||||
- name: infrastructure-crds
|
||||
- name: infrastructure-ceph-cluster
|
||||
@@ -524,6 +524,109 @@ check "alertmanager: no severity=critical alerts active" bash -c '
|
||||
test "$body" = "[]"
|
||||
'
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Local AI inference. Every check here corresponds to a defect that actually
|
||||
# shipped, because none of them are catchable by manifest validation — the
|
||||
# manifests were valid in each case and the endpoint still misbehaved.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
export LB_IP
|
||||
|
||||
# helper_post: POST JSON from inside e2e-helper, for Services with no ingress.
|
||||
# kubectl exec passes argv directly, so the JSON needs no shell quoting.
|
||||
helper_post() {
|
||||
local url="$1" data="$2" out body code attempt
|
||||
for attempt in 1 2 3; do
|
||||
out=$(kubectl -n monitoring exec e2e-helper -- \
|
||||
curl -sk --max-time 60 -H 'Content-Type: application/json' \
|
||||
-d "$data" -w $'\n__CODE__:%{http_code}' "$url" 2>&1)
|
||||
body=${out%$'\n'__CODE__:*}
|
||||
code=${out##*__CODE__:}
|
||||
printf 'attempt=%s code=%s body=%s\n' "$attempt" "$code" "${body:0:200}"
|
||||
if [[ "$code" =~ ^2[0-9][0-9]$ ]]; then
|
||||
printf '%s' "$body" > /tmp/helper_body
|
||||
return 0
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
return 1
|
||||
}
|
||||
export -f helper_post
|
||||
|
||||
# llm_chat: POST a chat completion to llama-server from inside the cluster.
|
||||
# It goes to the Service, not the public hostname: llm.renner.uno now answers
|
||||
# with an Authentik redirect, and the API is deliberately in-cluster only —
|
||||
# which is also how every real consumer reaches it. Result lands in
|
||||
# /tmp/helper_body.
|
||||
llm_chat() {
|
||||
local prompt="$1" max="${2:-64}"
|
||||
helper_post "http://llama-server.ai.svc.cluster.local:8080/v1/chat/completions" \
|
||||
"$(jq -nc --arg p "$prompt" --argjson n "$max" \
|
||||
'{model:"qwen3.6-35b-a3b",messages:[{role:"user",content:$p}],max_tokens:$n,temperature:0}')"
|
||||
}
|
||||
export -f llm_chat
|
||||
|
||||
echo "=== Local AI inference ==="
|
||||
|
||||
# The gate is the whole of the access control on the public hostname:
|
||||
# llama-server has no authentication and serves the API on the same port as the
|
||||
# UI. An anonymous request must be bounced to Authentik, never answered. A 200
|
||||
# here would mean the ingress is reaching llama-server directly again.
|
||||
check "public hostname is gated by authentik" \
|
||||
bash -c 'code=$(curl -sk --resolve "llm.renner.uno:443:$LB_IP" --max-time 15 \
|
||||
-o /dev/null -w "%{http_code}" "https://llm.renner.uno/v1/models")
|
||||
echo "got=$code"; [[ "$code" =~ ^(302|307|401|403)$ ]]'
|
||||
|
||||
# Asserts the id specifically, not merely that the string appears somewhere:
|
||||
# every configured name shows up in the `aliases` array regardless of order, so
|
||||
# a substring match passes even when the picker is showing the wrong one.
|
||||
check "descriptive name is the advertised id" \
|
||||
bash -c 'helper_curl "http://llama-server.ai.svc.cluster.local:8080/v1/models" &&
|
||||
jq -e ".data[0].id == \"qwen3.6-35b-a3b\"" /tmp/helper_body >/dev/null'
|
||||
|
||||
# Only one name is configured. A second would be advertised alongside it and,
|
||||
# if it sorted first, would take the id — which is what a picker displays.
|
||||
check "no second name competes for the id" \
|
||||
bash -c 'jq -e ".data[0].aliases | length == 1" /tmp/helper_body >/dev/null'
|
||||
|
||||
# Regression: the embedding model used to share this port. It appeared in the
|
||||
# chat picker, and selecting it returned fluent nonsense ("capital capital
|
||||
# capital ...") instead of an error, because --embeddings does not refuse chat
|
||||
# completions. It now lives on a separate Service; if it ever reappears here,
|
||||
# it is selectable again and this must fail.
|
||||
check "embedding model absent from chat endpoint" \
|
||||
bash -c '! grep -qi embed /tmp/helper_body'
|
||||
|
||||
# Regression: with thinking enabled by default the model spent its whole token
|
||||
# budget on reasoning_content and returned an EMPTY content with
|
||||
# finish_reason "length" — a 200 response that answers nothing. Asserting on
|
||||
# the status code alone would have passed.
|
||||
check "chat completion returns non-empty content" \
|
||||
bash -c 'llm_chat "Nenne die Hauptstadt von Frankreich." 64 &&
|
||||
jq -e ".choices[0].message.content | length > 0" /tmp/helper_body >/dev/null'
|
||||
|
||||
check "chat completion stops rather than truncating" \
|
||||
bash -c 'jq -e ".choices[0].finish_reason == \"stop\"" /tmp/helper_body >/dev/null'
|
||||
|
||||
# Coherence, not just liveness. This is what catches a model answering from the
|
||||
# wrong weights or a numerically broken backend — llama-bench measures
|
||||
# throughput and would report excellent numbers while emitting garbage.
|
||||
check "chat answer is coherent (knows Paris)" \
|
||||
bash -c 'jq -re ".choices[0].message.content" /tmp/helper_body | grep -qi paris'
|
||||
|
||||
# Reached from the monitoring namespace, which ai-ns.yaml admits; there is no
|
||||
# ingress for this Service by design.
|
||||
check "llama-embed returns an embedding vector" \
|
||||
bash -c 'helper_post "http://llama-embed.ai.svc.cluster.local:8080/v1/embeddings" \
|
||||
"{\"model\":\"embed\",\"input\":\"Testsatz\"}" &&
|
||||
jq -e ".data[0].embedding | length > 0" /tmp/helper_body >/dev/null'
|
||||
|
||||
# Guards the resource request silently disappearing: without it the pod still
|
||||
# runs, just on CPU, and only a throughput measurement would notice.
|
||||
check "llama-server holds an iGPU allocation" \
|
||||
bash -c 'kubectl -n ai get deploy llama-server -o json |
|
||||
jq -e ".spec.template.spec.containers[0].resources.limits[\"gpu.intel.com/i915\"]" >/dev/null'
|
||||
|
||||
echo "=== Authentik OIDC discovery (per-app) ==="
|
||||
# A reachable, parseable .well-known proves Authentik has an OIDC provider
|
||||
# bound to that application slug. If the app config is missing or Authentik
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
# The upstream ceph-csi-drivers chart, which is how Rook expects CSI drivers to
|
||||
# be provided since v1.20: "CSI drivers are admin-managed via the
|
||||
# ceph-csi-operator. Rook no longer deploys CSI."
|
||||
#
|
||||
# This replaces a static rendering of the chart's ServiceAccounts and RBAC. That
|
||||
# workaround (#babed078) was written during a five-day outage in which both
|
||||
# ctrlplugin Deployments sat at 0/2 and PVC attach/detach was blocked
|
||||
# cluster-wide, and it justified itself with:
|
||||
#
|
||||
# "helm-controller v1.5.4 predates disableTakeOwnership, so Helm would refuse
|
||||
# to adopt the operator-managed Driver and OperatorConfig CRs"
|
||||
#
|
||||
# That is not so. disableTakeOwnership landed in helm-controller v1.2.0, three
|
||||
# minors BEFORE the version running here, and it is an opt-OUT: taking ownership
|
||||
# of existing resources is the default. Nothing stood in the way of the chart.
|
||||
#
|
||||
# What the workaround could not do is create the Driver CRs, and a cold-rebuild
|
||||
# rehearsal is where that surfaced. Rook's v1.18 -> v1.20 migration had created
|
||||
# them once on this cluster, so they looked permanent; on an empty cluster Rook
|
||||
# creates the CephConnection and ClientProfile and stops there, no Driver is
|
||||
# ever made, the ceph-csi-operator deploys no provisioner, and every PVC waits
|
||||
# forever. OpenBao's among them, which is where the rebuild stopped.
|
||||
#
|
||||
# cephConnections and clientProfiles stay EMPTY here on purpose. Rook owns both:
|
||||
# the CephConnection carries the mon Service IPs Rook keeps current, and the
|
||||
# ClientProfile points at Secrets Rook creates. Handing either to Helm would
|
||||
# freeze a value that is supposed to move. Verified on a fresh cluster — Rook
|
||||
# creates exactly those two and nothing else.
|
||||
apiVersion: source.toolkit.fluxcd.io/v1
|
||||
kind: HelmRepository
|
||||
metadata:
|
||||
name: ceph-csi-operator
|
||||
namespace: rook-ceph
|
||||
spec:
|
||||
interval: 24h
|
||||
url: https://ceph.github.io/ceph-csi-operator/
|
||||
---
|
||||
apiVersion: helm.toolkit.fluxcd.io/v2
|
||||
kind: HelmRelease
|
||||
metadata:
|
||||
name: ceph-csi-drivers
|
||||
namespace: rook-ceph
|
||||
spec:
|
||||
interval: 30m
|
||||
# Wait for the rook-ceph HelmRelease to finish, not just for the CRDs to
|
||||
# appear. The two are not the same thing, and an earlier version of this file
|
||||
# argued the opposite: "nothing here installs those CRDs, so a dependsOn would
|
||||
# only move the wait". True as far as existence goes — but the csi.ceph.io
|
||||
# CRDs come from the ceph-csi-operator subchart inside the rook-ceph release,
|
||||
# which runs with `crds: CreateReplace` and `retries: 3`, so they are not
|
||||
# created once. They are created again on every attempt, and everything living
|
||||
# under them goes with them.
|
||||
#
|
||||
# A cold rebuild landed in exactly that window:
|
||||
#
|
||||
# 19:24:21 ceph-csi-drivers install succeeded — both Driver CRs created
|
||||
# 19:25:50 csi.ceph.io CRDs recreated by a later operator attempt
|
||||
# 19:29:33 rook-ceph-operator install finally succeeded
|
||||
#
|
||||
# The Driver objects were gone, but Helm still had them in its stored release
|
||||
# and would not re-render before the 30m interval. No controller reported
|
||||
# anything: the ceph-csi-operator sat idle with nothing to reconcile, no
|
||||
# provisioner Deployment was ever created, and every PVC on ceph-block stayed
|
||||
# Pending on "waiting for a volume to be created by the external provisioner
|
||||
# rook-ceph.rbd.csi.ceph.com". openbao-0 then never got a node, and the run
|
||||
# failed 15 minutes later reporting that OpenBao would not answer.
|
||||
dependsOn:
|
||||
- name: rook-ceph-operator
|
||||
chart:
|
||||
spec:
|
||||
chart: ceph-csi-drivers
|
||||
version: "~1.0.4"
|
||||
sourceRef:
|
||||
kind: HelmRepository
|
||||
name: ceph-csi-operator
|
||||
namespace: rook-ceph
|
||||
install:
|
||||
# Kept as a backstop. dependsOn removes the race this was covering for, but
|
||||
# a CRD replace outside an install of this release would still empty it, and
|
||||
# a retry is cheaper than a 30-minute wait for the next interval.
|
||||
remediation:
|
||||
retries: 5
|
||||
upgrade:
|
||||
remediation:
|
||||
retries: 3
|
||||
values:
|
||||
# Rook's, not ours — see the header.
|
||||
cephConnections: []
|
||||
clientProfiles: []
|
||||
# Names must match what the StorageClasses already name as their
|
||||
# provisioner (rook-ceph.rbd.csi.ceph.com), which is also what Rook's
|
||||
# migration produced on the live cluster. The chart's default drops the
|
||||
# namespace prefix and would leave every StorageClass pointing at a driver
|
||||
# that does not exist.
|
||||
# clusterName is load-bearing and easy to miss: it becomes --clustername on
|
||||
# the plugin, and ceph-csi hands it to rados_create2 as the Ceph cluster
|
||||
# name. The chart defaults it to "", which librados rejects outright — the
|
||||
# provisioner then fails every request with
|
||||
# failed to get connection: connecting failed: rados: ret=-22
|
||||
# which reads like a credential or network fault and is neither. The
|
||||
# toolbox keeps working throughout, because it falls back to the default
|
||||
# name "ceph", so the cluster looks healthy while nothing can provision.
|
||||
drivers:
|
||||
rbd:
|
||||
name: rook-ceph.rbd.csi.ceph.com
|
||||
clusterName: rook-ceph
|
||||
enabled: true
|
||||
cephfs:
|
||||
name: rook-ceph.cephfs.csi.ceph.com
|
||||
clusterName: rook-ceph
|
||||
enabled: true
|
||||
nfs:
|
||||
enabled: false
|
||||
nvmeof:
|
||||
enabled: false
|
||||
@@ -1,6 +1,5 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
# The ServiceAccounts and RBAC that used to be rendered here by hand now come
|
||||
# from the chart that owns them, together with the Driver CRs they exist for.
|
||||
resources:
|
||||
- helmrelease.yaml
|
||||
- serviceaccounts.yaml
|
||||
- rbac.yaml
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
# RBAC for the per-driver ServiceAccounts in serviceaccounts.yaml.
|
||||
# Rendered from ceph-csi-operator/ceph-csi-drivers chart v1.0.1 templates,
|
||||
# with $normalizedDriverName fixed to the operator's hardcoded driver-type
|
||||
# names (`rbd`, `cephfs`). Sync with upstream if the chart's RBAC changes.
|
||||
# Source: https://github.com/ceph/ceph-csi-operator/tree/v1.0.1/deploy/charts/ceph-csi-drivers/templates
|
||||
|
||||
# ---------- rbd-ctrlplugin ----------
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: rbd-ctrlplugin-cr
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: [secrets]
|
||||
verbs: [get, list, watch]
|
||||
- apiGroups: [""]
|
||||
resources: [persistentvolumes]
|
||||
verbs: [get, list, watch, create, delete, patch, update]
|
||||
- apiGroups: [""]
|
||||
resources: [persistentvolumeclaims]
|
||||
verbs: [get, list, watch, update]
|
||||
- apiGroups: [storage.k8s.io]
|
||||
resources: [storageclasses]
|
||||
verbs: [get, list, watch]
|
||||
- apiGroups: [""]
|
||||
resources: [events]
|
||||
verbs: [list, watch, create, update, patch]
|
||||
- apiGroups: [storage.k8s.io]
|
||||
resources: [volumeattachments]
|
||||
verbs: [get, list, watch, patch]
|
||||
- apiGroups: [storage.k8s.io]
|
||||
resources: [volumeattachments/status]
|
||||
verbs: [patch]
|
||||
- apiGroups: [""]
|
||||
resources: [nodes]
|
||||
verbs: [get, list, watch]
|
||||
- apiGroups: [storage.k8s.io]
|
||||
resources: [csinodes]
|
||||
verbs: [get, list, watch]
|
||||
- apiGroups: [""]
|
||||
resources: [persistentvolumeclaims/status]
|
||||
verbs: [patch]
|
||||
- apiGroups: [snapshot.storage.k8s.io]
|
||||
resources: [volumesnapshots]
|
||||
verbs: [get, list, watch]
|
||||
- apiGroups: [snapshot.storage.k8s.io]
|
||||
resources: [volumesnapshotclasses]
|
||||
verbs: [get, list, watch]
|
||||
- apiGroups: [snapshot.storage.k8s.io]
|
||||
resources: [volumesnapshotcontents]
|
||||
verbs: [get, list, watch, patch, update]
|
||||
- apiGroups: [snapshot.storage.k8s.io]
|
||||
resources: [volumesnapshotcontents/status]
|
||||
verbs: [update, patch]
|
||||
- apiGroups: [""]
|
||||
resources: [configmaps]
|
||||
verbs: [get]
|
||||
- apiGroups: [""]
|
||||
resources: [serviceaccounts]
|
||||
verbs: [get]
|
||||
- apiGroups: [""]
|
||||
resources: [serviceaccounts/token]
|
||||
verbs: [create]
|
||||
- apiGroups: [groupsnapshot.storage.k8s.io]
|
||||
resources: [volumegroupsnapshotclasses]
|
||||
verbs: [get, list, watch]
|
||||
- apiGroups: [groupsnapshot.storage.k8s.io]
|
||||
resources: [volumegroupsnapshotcontents]
|
||||
verbs: [get, list, watch, update, patch]
|
||||
- apiGroups: [groupsnapshot.storage.k8s.io]
|
||||
resources: [volumegroupsnapshotcontents/status]
|
||||
verbs: [update, patch]
|
||||
- apiGroups: [groupsnapshot.storage.openshift.io]
|
||||
resources: [volumegroupsnapshotclasses]
|
||||
verbs: [get, list, watch]
|
||||
- apiGroups: [groupsnapshot.storage.openshift.io]
|
||||
resources: [volumegroupsnapshotcontents]
|
||||
verbs: [get, list, watch, update, patch]
|
||||
- apiGroups: [groupsnapshot.storage.openshift.io]
|
||||
resources: [volumegroupsnapshotcontents/status]
|
||||
verbs: [update, patch]
|
||||
- apiGroups: [replication.storage.openshift.io]
|
||||
resources: [volumegroupreplicationcontents]
|
||||
verbs: [get, list, watch]
|
||||
- apiGroups: [replication.storage.openshift.io]
|
||||
resources: [volumegroupreplicationclasses]
|
||||
verbs: [get, list, watch]
|
||||
- apiGroups: [authentication.k8s.io]
|
||||
resources: [tokenreviews]
|
||||
verbs: [create]
|
||||
- apiGroups: [authorization.k8s.io]
|
||||
resources: [subjectaccessreviews]
|
||||
verbs: [create]
|
||||
- apiGroups: [cbt.storage.k8s.io]
|
||||
resources: [snapshotmetadataservices]
|
||||
verbs: [get, list]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: rbd-ctrlplugin-crb
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: ClusterRole
|
||||
name: rbd-ctrlplugin-cr
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: rbd-ctrlplugin-sa
|
||||
namespace: rook-ceph
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
metadata:
|
||||
name: rbd-ctrlplugin-r
|
||||
namespace: rook-ceph
|
||||
rules:
|
||||
- apiGroups: [coordination.k8s.io]
|
||||
resources: [leases]
|
||||
verbs: [get, watch, list, delete, update, create]
|
||||
- apiGroups: [csiaddons.openshift.io]
|
||||
resources: [csiaddonsnodes]
|
||||
verbs: [get, watch, list, create, update, delete]
|
||||
- apiGroups: [""]
|
||||
resources: [pods]
|
||||
verbs: [get]
|
||||
- apiGroups: [apps]
|
||||
resources: [replicasets]
|
||||
verbs: [get]
|
||||
- apiGroups: [apps]
|
||||
resources: [deployments/finalizers, daemonsets/finalizers]
|
||||
verbs: [update]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata:
|
||||
name: rbd-ctrlplugin-rb
|
||||
namespace: rook-ceph
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: Role
|
||||
name: rbd-ctrlplugin-r
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: rbd-ctrlplugin-sa
|
||||
namespace: rook-ceph
|
||||
|
||||
# ---------- rbd-nodeplugin ----------
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: rbd-nodeplugin-cr
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: [secrets]
|
||||
verbs: [get, list, watch]
|
||||
- apiGroups: [""]
|
||||
resources: [persistentvolumes]
|
||||
verbs: [get, list]
|
||||
- apiGroups: [storage.k8s.io]
|
||||
resources: [volumeattachments]
|
||||
verbs: [get, list]
|
||||
- apiGroups: [""]
|
||||
resources: [configmaps]
|
||||
verbs: [get]
|
||||
- apiGroups: [""]
|
||||
resources: [serviceaccounts]
|
||||
verbs: [get]
|
||||
- apiGroups: [""]
|
||||
resources: [serviceaccounts/token]
|
||||
verbs: [create]
|
||||
- apiGroups: [""]
|
||||
resources: [nodes]
|
||||
verbs: [get]
|
||||
- apiGroups: [authentication.k8s.io]
|
||||
resources: [tokenreviews]
|
||||
verbs: [create]
|
||||
- apiGroups: [""]
|
||||
resources: [events]
|
||||
verbs: [list, watch, create, update, patch]
|
||||
- apiGroups: [""]
|
||||
resources: [persistentvolumeclaims]
|
||||
verbs: [get]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: rbd-nodeplugin-crb
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: ClusterRole
|
||||
name: rbd-nodeplugin-cr
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: rbd-nodeplugin-sa
|
||||
namespace: rook-ceph
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
metadata:
|
||||
name: rbd-nodeplugin-r
|
||||
namespace: rook-ceph
|
||||
rules:
|
||||
- apiGroups: [csiaddons.openshift.io]
|
||||
resources: [csiaddonsnodes]
|
||||
verbs: [get, watch, list, create, update, delete]
|
||||
- apiGroups: [""]
|
||||
resources: [pods]
|
||||
verbs: [get]
|
||||
- apiGroups: [apps]
|
||||
resources: [replicasets]
|
||||
verbs: [get]
|
||||
- apiGroups: [apps]
|
||||
resources: [deployments/finalizers, daemonsets/finalizers]
|
||||
verbs: [update]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata:
|
||||
name: rbd-nodeplugin-rb
|
||||
namespace: rook-ceph
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: Role
|
||||
name: rbd-nodeplugin-r
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: rbd-nodeplugin-sa
|
||||
namespace: rook-ceph
|
||||
|
||||
# ---------- cephfs-ctrlplugin ----------
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: cephfs-ctrlplugin-cr
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: [secrets]
|
||||
verbs: [get, list, watch]
|
||||
- apiGroups: [""]
|
||||
resources: [configmaps]
|
||||
verbs: [get]
|
||||
- apiGroups: [""]
|
||||
resources: [nodes]
|
||||
verbs: [get, list, watch]
|
||||
- apiGroups: [storage.k8s.io]
|
||||
resources: [csinodes]
|
||||
verbs: [get, list, watch]
|
||||
- apiGroups: [""]
|
||||
resources: [persistentvolumes]
|
||||
verbs: [get, list, watch, create, delete, patch, update]
|
||||
- apiGroups: [""]
|
||||
resources: [persistentvolumeclaims]
|
||||
verbs: [get, list, watch, patch, update]
|
||||
- apiGroups: [storage.k8s.io]
|
||||
resources: [storageclasses]
|
||||
verbs: [get, list, watch]
|
||||
- apiGroups: [""]
|
||||
resources: [events]
|
||||
verbs: [list, watch, create, update, patch]
|
||||
- apiGroups: [storage.k8s.io]
|
||||
resources: [volumeattachments]
|
||||
verbs: [get, list, watch, patch]
|
||||
- apiGroups: [storage.k8s.io]
|
||||
resources: [volumeattachments/status]
|
||||
verbs: [patch]
|
||||
- apiGroups: [""]
|
||||
resources: [persistentvolumeclaims/status]
|
||||
verbs: [patch]
|
||||
- apiGroups: [snapshot.storage.k8s.io]
|
||||
resources: [volumesnapshots]
|
||||
verbs: [get, list]
|
||||
- apiGroups: [snapshot.storage.k8s.io]
|
||||
resources: [volumesnapshotclasses]
|
||||
verbs: [get, list, watch]
|
||||
- apiGroups: [snapshot.storage.k8s.io]
|
||||
resources: [volumesnapshotcontents]
|
||||
verbs: [get, list, watch, patch, update]
|
||||
- apiGroups: [snapshot.storage.k8s.io]
|
||||
resources: [volumesnapshotcontents/status]
|
||||
verbs: [update, patch]
|
||||
- apiGroups: [groupsnapshot.storage.k8s.io]
|
||||
resources: [volumegroupsnapshotclasses]
|
||||
verbs: [get, list, watch]
|
||||
- apiGroups: [groupsnapshot.storage.k8s.io]
|
||||
resources: [volumegroupsnapshotcontents]
|
||||
verbs: [get, list, watch, update, patch]
|
||||
- apiGroups: [groupsnapshot.storage.k8s.io]
|
||||
resources: [volumegroupsnapshotcontents/status]
|
||||
verbs: [update, patch]
|
||||
- apiGroups: [groupsnapshot.storage.openshift.io]
|
||||
resources: [volumegroupsnapshotclasses]
|
||||
verbs: [get, list, watch]
|
||||
- apiGroups: [groupsnapshot.storage.openshift.io]
|
||||
resources: [volumegroupsnapshotcontents]
|
||||
verbs: [get, list, watch, update, patch]
|
||||
- apiGroups: [groupsnapshot.storage.openshift.io]
|
||||
resources: [volumegroupsnapshotcontents/status]
|
||||
verbs: [update, patch]
|
||||
- apiGroups: [replication.storage.openshift.io]
|
||||
resources: [volumegroupreplicationcontents]
|
||||
verbs: [get, list, watch]
|
||||
- apiGroups: [replication.storage.openshift.io]
|
||||
resources: [volumegroupreplicationclasses]
|
||||
verbs: [get]
|
||||
- apiGroups: [""]
|
||||
resources: [serviceaccounts]
|
||||
verbs: [get]
|
||||
- apiGroups: [""]
|
||||
resources: [serviceaccounts/token]
|
||||
verbs: [create]
|
||||
- apiGroups: [authentication.k8s.io]
|
||||
resources: [tokenreviews]
|
||||
verbs: [create]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: cephfs-ctrlplugin-crb
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: ClusterRole
|
||||
name: cephfs-ctrlplugin-cr
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: cephfs-ctrlplugin-sa
|
||||
namespace: rook-ceph
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
metadata:
|
||||
name: cephfs-ctrlplugin-r
|
||||
namespace: rook-ceph
|
||||
rules:
|
||||
- apiGroups: [coordination.k8s.io]
|
||||
resources: [leases]
|
||||
verbs: [get, watch, list, delete, update, create]
|
||||
- apiGroups: [csiaddons.openshift.io]
|
||||
resources: [csiaddonsnodes]
|
||||
verbs: [get, watch, list, create, update, delete]
|
||||
- apiGroups: [""]
|
||||
resources: [pods]
|
||||
verbs: [get]
|
||||
- apiGroups: [apps]
|
||||
resources: [replicasets]
|
||||
verbs: [get]
|
||||
- apiGroups: [apps]
|
||||
resources: [deployments/finalizers, daemonsets/finalizers]
|
||||
verbs: [update]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata:
|
||||
name: cephfs-ctrlplugin-rb
|
||||
namespace: rook-ceph
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: Role
|
||||
name: cephfs-ctrlplugin-r
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: cephfs-ctrlplugin-sa
|
||||
namespace: rook-ceph
|
||||
|
||||
# ---------- cephfs-nodeplugin ----------
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: cephfs-nodeplugin-cr
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: [nodes]
|
||||
verbs: [get]
|
||||
- apiGroups: [""]
|
||||
resources: [secrets]
|
||||
verbs: [get, list, watch]
|
||||
- apiGroups: [""]
|
||||
resources: [configmaps]
|
||||
verbs: [get]
|
||||
- apiGroups: [""]
|
||||
resources: [serviceaccounts]
|
||||
verbs: [get]
|
||||
- apiGroups: [""]
|
||||
resources: [serviceaccounts/token]
|
||||
verbs: [create]
|
||||
- apiGroups: [""]
|
||||
resources: [events]
|
||||
verbs: [list, watch, create, update, patch]
|
||||
- apiGroups: [""]
|
||||
resources: [persistentvolumes, persistentvolumeclaims]
|
||||
verbs: [get]
|
||||
- apiGroups: [authentication.k8s.io]
|
||||
resources: [tokenreviews]
|
||||
verbs: [create]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: cephfs-nodeplugin-crb
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: ClusterRole
|
||||
name: cephfs-nodeplugin-cr
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: cephfs-nodeplugin-sa
|
||||
namespace: rook-ceph
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
metadata:
|
||||
name: cephfs-nodeplugin-r
|
||||
namespace: rook-ceph
|
||||
rules:
|
||||
- apiGroups: [csiaddons.openshift.io]
|
||||
resources: [csiaddonsnodes]
|
||||
verbs: [get, watch, list, create, update, delete]
|
||||
- apiGroups: [""]
|
||||
resources: [pods]
|
||||
verbs: [get]
|
||||
- apiGroups: [apps]
|
||||
resources: [replicasets]
|
||||
verbs: [get]
|
||||
- apiGroups: [apps]
|
||||
resources: [deployments/finalizers, daemonsets/finalizers]
|
||||
verbs: [update]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata:
|
||||
name: cephfs-nodeplugin-rb
|
||||
namespace: rook-ceph
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: Role
|
||||
name: cephfs-nodeplugin-r
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: cephfs-nodeplugin-sa
|
||||
namespace: rook-ceph
|
||||
@@ -0,0 +1,35 @@
|
||||
# Per-driver ServiceAccounts consumed by the workloads that the
|
||||
# ceph-csi-controller-manager generates for each `Driver` CR. Rook v1.20
|
||||
# extracted CSI driver management into the upstream ceph-csi-operator
|
||||
# (subchart of `rook-ceph`); these SAs are normally provisioned by the
|
||||
# separate `ceph-csi-drivers` chart, but installing that chart would also
|
||||
# create a parallel set of `Driver` / `OperatorConfig` CRs and Helm cannot
|
||||
# adopt the operator-managed ones on helm-controller v1.5.4. We render
|
||||
# only the SAs + RBAC the existing operator-managed `Driver` CRs need.
|
||||
#
|
||||
# Names are fixed by the operator (CSI_SERVICE_ACCOUNT_PREFIX="" on
|
||||
# ceph-csi-controller-manager): `<driver-type>-{ctrl,node}plugin-sa`,
|
||||
# where driver-type is `rbd` or `cephfs`. Do not rename.
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: rbd-ctrlplugin-sa
|
||||
namespace: rook-ceph
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: rbd-nodeplugin-sa
|
||||
namespace: rook-ceph
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: cephfs-ctrlplugin-sa
|
||||
namespace: rook-ceph
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: cephfs-nodeplugin-sa
|
||||
namespace: rook-ceph
|
||||
@@ -70,23 +70,9 @@ spec:
|
||||
# map only shows up as "no space left on device" in the log while pod
|
||||
# sandbox creation fails (#452). cilium_bpf_map_pressure makes it
|
||||
# alertable beforehand.
|
||||
#
|
||||
# That reasoning is about the metrics, which this line provides. The
|
||||
# ServiceMonitor is only the scrape wiring and is declared standalone in
|
||||
# apps/monitoring-rules instead — the same treatment velero/helmrelease
|
||||
# already gives its own, and for the same reason: the chart's template is
|
||||
# gated on monitoring.coreos.com/v1 existing at install time.
|
||||
#
|
||||
# Here it is worse than for velero. Cilium's chart does not skip the
|
||||
# template, it aborts rendering — "Service Monitor requires
|
||||
# monitoring.coreos.com/v1 CRDs" — and cilium is one of the seven
|
||||
# healthChecks the infrastructure Kustomization waits on. So on an empty
|
||||
# cluster the CNI blocked infrastructure, which blocked grafana-db, which
|
||||
# blocked monitoring, which is where those CRDs come from. The lowest
|
||||
# layer in the cluster was waiting on the highest.
|
||||
enabled: true
|
||||
serviceMonitor:
|
||||
enabled: false
|
||||
enabled: true
|
||||
bpf:
|
||||
# Per-endpoint policy map size: one entry per (identity, direction,
|
||||
# protocol, port) allowed. When it fills, endpoint regeneration fails
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Bouncer key for the firewall bouncers on proxy-1/proxy-2.
|
||||
#
|
||||
# Separate from crowdsec-bouncer-key ON PURPOSE. That key is rotated
|
||||
# quarterly by kubernetes/infrastructure/openbao/rotation/crowdsec-bouncer-key.yaml,
|
||||
# and both of its consumers live in the cluster, so ESO propagates a rotation
|
||||
# within 10m. The edge bouncers are Ansible-managed hosts: nothing there
|
||||
# re-reads OpenBao on a schedule, so a rotated key would leave them 401ing
|
||||
# against the LAPI until someone runs the playbook. This key is therefore
|
||||
# deliberately NOT enrolled in the rotator — it rotates when the playbook runs,
|
||||
# and the playbook is what carries the new value to the hosts.
|
||||
#
|
||||
# The value must match ansible vault_crowdsec_edge_bouncer_key.
|
||||
apiVersion: external-secrets.io/v1
|
||||
kind: ExternalSecret
|
||||
metadata:
|
||||
name: crowdsec-edge-bouncer-key
|
||||
namespace: crowdsec
|
||||
spec:
|
||||
refreshInterval: 10m
|
||||
secretStoreRef:
|
||||
kind: ClusterSecretStore
|
||||
name: openbao
|
||||
target:
|
||||
name: crowdsec-edge-bouncer-key
|
||||
dataFrom:
|
||||
- extract:
|
||||
key: infrastructure/crowdsec-edge-bouncer-key
|
||||
@@ -36,6 +36,36 @@ spec:
|
||||
values:
|
||||
container_runtime: containerd
|
||||
|
||||
# Addresses a community blocklist must never be allowed to ban.
|
||||
#
|
||||
# This is the ONLY declarative allowlist the chart offers. `cscli
|
||||
# allowlists` objects live in the LAPI database, and that database runs
|
||||
# without persistence here on purpose (see lapi.persistentVolume below),
|
||||
# so anything created through cscli is gone at the next restart. This file
|
||||
# is a ConfigMap mount, so it survives by construction. Upstream marks it
|
||||
# deprecated in favour of centralized allowlists; it is still the only
|
||||
# thing that works against a LAPI with no PVC.
|
||||
#
|
||||
# Scope: CAPI decisions only. Locally generated decisions are not filtered
|
||||
# here — they currently carry real client IPs (verified), because Traefik
|
||||
# resolves X-Forwarded-For past the netbird-ingress pod IP.
|
||||
#
|
||||
# The home WAN address is deliberately absent and is not needed: LAN
|
||||
# clients never present it. opentofu/unifi_nat_hairpin.tf DNATs the public
|
||||
# proxy addresses to the Traefik VIP on the UDM, so on-LAN traffic reaches
|
||||
# the cluster directly with a LAN source and never traverses the proxies.
|
||||
# Private ranges cannot appear in a community blocklist either way.
|
||||
config:
|
||||
capi_whitelists.yaml: |
|
||||
cidrs:
|
||||
# The proxy hosts themselves. Same three addresses as the
|
||||
# netbird-ingress-egress toCIDRSet and the hairpin endpoints —
|
||||
# keep all three lists in sync. proxy-1 is the v6-only member,
|
||||
# proxy-2 the dual-stack one.
|
||||
- 45.157.177.26/32
|
||||
- 2a03:4000:4b:c1e:4474:43ff:fedf:8f85/128
|
||||
- 2a03:4000:28:f6e:a4c7:59ff:fe1e:9a29/128
|
||||
|
||||
# mTLS between agents and the LAPI. Do not switch back to password auth:
|
||||
# the LAPI runs without persistence, so its machine-registration DB is
|
||||
# wiped on every restart, and password-registered agents then get 401
|
||||
@@ -54,7 +84,7 @@ spec:
|
||||
# required.
|
||||
#
|
||||
# NOTE: enabling this makes the LAPI serve HTTPS-only. The Traefik ingress
|
||||
# bouncer is currently INERT (detached, see post-crds/traefik-crowdsec-middleware.yaml)
|
||||
# bouncer is currently INERT (detached, see traefik/crowdsec-middleware.yaml)
|
||||
# so there is no live impact, but re-enabling it will require pointing the
|
||||
# plugin at https + trusting this CA (or giving it a bouncer-ou client cert).
|
||||
tls:
|
||||
@@ -86,10 +116,18 @@ spec:
|
||||
requests:
|
||||
cpu: 50m
|
||||
podAnnotations:
|
||||
# Reloader restarts the LAPI when crowdsec-bouncer-key changes (it's an
|
||||
# env-var secret, so a restart is required to pick up a rotated key). See
|
||||
# Reloader restarts the LAPI when either bouncer key changes. Both are
|
||||
# consumed as env vars from a secretKeyRef, and env is fixed at pod
|
||||
# start — without a restart the LAPI keeps trusting the old value while
|
||||
# the bouncer already presents the new one, which is a 401 at exactly
|
||||
# the moment a rotation is supposed to be seamless.
|
||||
#
|
||||
# crowdsec-bouncer-key rotates on a schedule, see
|
||||
# kubernetes/infrastructure/openbao/rotation/crowdsec-bouncer-key.yaml.
|
||||
secret.reloader.stakater.com/reload: crowdsec-bouncer-key
|
||||
# crowdsec-edge-bouncer-key rotates by hand together with
|
||||
# ansible/plays/install-netbird-proxy.yml; it still needs to be listed
|
||||
# here, because the LAPI side of that rotation is this env var.
|
||||
secret.reloader.stakater.com/reload: crowdsec-bouncer-key,crowdsec-edge-bouncer-key
|
||||
persistentVolume:
|
||||
data:
|
||||
# Kept OFF deliberately. The #337 attempt to set this `true` (persist
|
||||
@@ -118,6 +156,17 @@ spec:
|
||||
secretKeyRef:
|
||||
name: crowdsec-bouncer-key
|
||||
key: key
|
||||
# The firewall bouncers on proxy-1/proxy-2, over the public LAPI
|
||||
# endpoint (lapi-ingress.yaml). The chart's entrypoint re-runs
|
||||
# `register_bouncer` for every BOUNCER_KEY_* on each start and skips
|
||||
# names that already exist, so this survives the LAPI's wiped
|
||||
# registration DB the same way BOUNCER_KEY_ingress does — the env var
|
||||
# is the source of truth, not the database.
|
||||
- name: BOUNCER_KEY_edge
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: crowdsec-edge-bouncer-key
|
||||
key: key
|
||||
|
||||
agent:
|
||||
# Same reasoning as lapi.resources above, multiplied by three: this is a
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- bouncer-key-externalsecret.yaml
|
||||
- edge-bouncer-key-externalsecret.yaml
|
||||
- lapi-ingress.yaml
|
||||
- helmrelease.yaml
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# Public LAPI endpoint for the firewall bouncers on proxy-1/proxy-2.
|
||||
#
|
||||
# WHY PUBLIC AT ALL. The bouncers run as host processes on the proxies, and
|
||||
# the proxies have no mesh access: the netbird-proxy container runs non-root
|
||||
# (see the ip_unprivileged_port_start task in the netbird-proxy role), so it
|
||||
# has no NET_ADMIN and no /dev/net/tun, and its embedded NetBird client
|
||||
# therefore terminates the tunnel in userspace. The tunnel belongs to that
|
||||
# process, not to the host netns — a second process on the same host cannot
|
||||
# route through it. The alternative was a host-level NetBird client plus the
|
||||
# LAPI on a LoadBalancer VIP; this reuses the path every other service already
|
||||
# takes instead of adding two peers and an ACL policy.
|
||||
#
|
||||
# NO IP ALLOWLIST, deliberately. The bouncer key is the access control.
|
||||
#
|
||||
# An allowlist was the obvious second factor and it does not work here. A
|
||||
# request originating on a proxy host does not arrive carrying that host's
|
||||
# public address: the client connects to the local proxy on 127.0.0.1, the
|
||||
# reverse-proxy puts what it saw into X-Forwarded-For, and Traefik resolves
|
||||
# that. Measured against the netbird-proxy-watchdog, which makes exactly this
|
||||
# kind of request every round:
|
||||
#
|
||||
# 172.18.0.3 - - [02/Sep/2026:10:12:01] "GET /" 200 websecure-immich-...
|
||||
# 172.18.0.3 - - [02/Sep/2026:10:12:01] "GET /" 200 websecure-home-...
|
||||
#
|
||||
# 172.18.0.3 is a Docker bridge address on the proxy side. Allowlisting the
|
||||
# published proxy addresses would have rejected the bouncer with 403;
|
||||
# allowlisting 172.18.0.0/16 would be a private range on a host we do not
|
||||
# control the addressing of, which is not a security control.
|
||||
#
|
||||
# What remains is the API key, which is the real control anyway: this endpoint
|
||||
# serves read-only decision streams to a valid bouncer key and nothing else.
|
||||
# The admin surface is cscli, in-cluster. If the source address ever becomes
|
||||
# deterministic, an ipAllowList can be added on top.
|
||||
---
|
||||
# The LAPI serves HTTPS only once tls.enabled is set — there is no plaintext
|
||||
# fallback and no second port; 8080 is the TLS port. Its certificate comes
|
||||
# from the chart's own self-signed CA, so Traefik needs that CA to verify the
|
||||
# backend. SANs include crowdsec-service.crowdsec.svc.cluster.local, which is
|
||||
# what the Service resolves to.
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: ServersTransport
|
||||
metadata:
|
||||
name: crowdsec-lapi
|
||||
namespace: crowdsec
|
||||
spec:
|
||||
serverName: crowdsec-service.crowdsec.svc.cluster.local
|
||||
rootCAsSecrets:
|
||||
- crowdsec-root-secret
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: crowdsec-lapi
|
||||
namespace: crowdsec
|
||||
labels:
|
||||
# Public DNS via Cloudflare; the proxies resolve it from outside. The UDM
|
||||
# external-dns instance skips it, LAN reaches it via the hairpin NAT.
|
||||
dns.renner.uno/public: "true"
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-cluster
|
||||
# NOT behind the Authentik gate: the client is a daemon with an API key,
|
||||
# not a browser that can complete an OIDC flow.
|
||||
traefik.ingress.kubernetes.io/service.serversscheme: https
|
||||
traefik.ingress.kubernetes.io/service.serverstransport: crowdsec-crowdsec-lapi@kubernetescrd
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
rules:
|
||||
- host: crowdsec.renner.uno
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: crowdsec-service
|
||||
port:
|
||||
number: 8080
|
||||
tls:
|
||||
- secretName: crowdsec-lapi-public-tls
|
||||
hosts:
|
||||
- crowdsec.renner.uno
|
||||
@@ -1,4 +1,5 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- externalsecret.yaml
|
||||
- helmrelease.yaml
|
||||
|
||||
@@ -9,26 +9,9 @@ spec:
|
||||
---
|
||||
# v-prefix tags (v19.2.3). Upstream also publishes date-suffixed builds
|
||||
# (v19.2.3-20250717) and moving major/minor tags (v19, v19.2); the
|
||||
# filter pins us to the bare three-component release.
|
||||
#
|
||||
# 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.
|
||||
# filter pins us to the bare three-component release. Range stays at
|
||||
# 19.x — the 19 → 20 (Tentacle) cutover should be a deliberate
|
||||
# upgrade tied to the Rook operator version.
|
||||
apiVersion: image.toolkit.fluxcd.io/v1
|
||||
kind: ImagePolicy
|
||||
metadata:
|
||||
@@ -42,4 +25,4 @@ spec:
|
||||
extract: '$ver'
|
||||
policy:
|
||||
semver:
|
||||
range: ">=20.2.1 <21.0.0"
|
||||
range: "19.x"
|
||||
|
||||
@@ -7,7 +7,7 @@ spec:
|
||||
image: ghcr.io/paperless-ngx/paperless-ngx
|
||||
interval: 1h
|
||||
---
|
||||
# Allow only patch + minor bumps within the 2.x line. A 3.0 release
|
||||
# Allow only patch + minor bumps within the 3.x line. A 4.0 release
|
||||
# requires manual policy widening — major upgrades typically need a
|
||||
# migration plan and shouldn't land via auto-merge.
|
||||
apiVersion: image.toolkit.fluxcd.io/v1
|
||||
@@ -20,4 +20,4 @@ spec:
|
||||
name: paperless-ngx
|
||||
policy:
|
||||
semver:
|
||||
range: "2.x"
|
||||
range: "3.x"
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
---
|
||||
# Advertises the on-board Intel iGPU as the schedulable resource
|
||||
# `gpu.intel.com/i915`, so pods can request it without a /dev/dri hostPath or
|
||||
# any privilege escalation — the plugin hands the render node to the container
|
||||
# through the kubelet device-plugin API. The same resource covers both GPU
|
||||
# compute (LLM inference) and QuickSync video transcoding; it is not
|
||||
# compute-specific.
|
||||
#
|
||||
# All three nodes are MS-01 with an i9-12900H (Alder Lake-P, PCI 8086:46a6,
|
||||
# Iris Xe 96 EU) at 0000:00:02.0. Talos carries the driver in the `i915` system
|
||||
# extension, already present in the schematic, so no machineconfig change is
|
||||
# needed.
|
||||
#
|
||||
# Vendored from upstream rather than installed via the
|
||||
# `intel-device-plugins-gpu` Helm chart: that chart renders a `GpuDevicePlugin`
|
||||
# custom resource, which only works alongside the Intel Device Plugin Operator
|
||||
# and its CRDs, cert-manager webhook and controller. With one device type on
|
||||
# three identical nodes, the operator manages nothing this DaemonSet does not.
|
||||
#
|
||||
# Source: intel/intel-device-plugins-for-kubernetes, v0.36.0,
|
||||
# deployments/gpu_plugin/base/intel-gpu-plugin.yaml
|
||||
# Upgrading means re-syncing this file against that path at the new tag and
|
||||
# bumping the image tag below.
|
||||
#
|
||||
# Namespace is kube-system because the plugin mounts hostPaths (/dev/dri,
|
||||
# /sys/class/drm, the kubelet device-plugin socket and the CDI path). The
|
||||
# cluster enforces PodSecurity `baseline`, which forbids hostPath volumes, and
|
||||
# kube-system is the only namespace in the API server's exemption list.
|
||||
#
|
||||
# Node Feature Discovery is not deployed, so upstream's `nfd_labeled_nodes`
|
||||
# overlay is omitted along with its `intel.feature.node.kubernetes.io/gpu`
|
||||
# nodeSelector. Every node carries the same iGPU, leaving nothing to discover;
|
||||
# the plugin exits cleanly on a node without /dev/dri.
|
||||
apiVersion: apps/v1
|
||||
kind: DaemonSet
|
||||
metadata:
|
||||
name: intel-gpu-plugin
|
||||
namespace: kube-system
|
||||
labels:
|
||||
app: intel-gpu-plugin
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app: intel-gpu-plugin
|
||||
updateStrategy:
|
||||
type: RollingUpdate
|
||||
rollingUpdate:
|
||||
maxSurge: 0
|
||||
maxUnavailable: 1
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: intel-gpu-plugin
|
||||
spec:
|
||||
containers:
|
||||
- name: intel-gpu-plugin
|
||||
env:
|
||||
- name: NODE_NAME
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: spec.nodeName
|
||||
- name: HOST_IP
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: status.hostIP
|
||||
image: intel/intel-gpu-plugin:0.36.0
|
||||
imagePullPolicy: IfNotPresent
|
||||
args:
|
||||
# Slots the single iGPU is handed out as, letting that many
|
||||
# containers share it concurrently. A slot count, not fractional
|
||||
# accounting — nothing partitions the device between sharers, so
|
||||
# they contend for it. Sized for an inference server plus room for
|
||||
# a transcoder and a benchmark pod alongside it.
|
||||
- -shared-dev-num=4
|
||||
securityContext:
|
||||
seLinuxOptions:
|
||||
type: "container_device_plugin_t"
|
||||
readOnlyRootFilesystem: true
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
resources:
|
||||
requests:
|
||||
memory: "45Mi"
|
||||
cpu: 40m
|
||||
limits:
|
||||
memory: "90Mi"
|
||||
cpu: 100m
|
||||
volumeMounts:
|
||||
- name: devfs
|
||||
mountPath: /dev/dri
|
||||
readOnly: true
|
||||
- name: sysfsdrm
|
||||
mountPath: /sys/class/drm
|
||||
readOnly: true
|
||||
- name: kubeletsockets
|
||||
mountPath: /var/lib/kubelet/device-plugins
|
||||
- name: cdipath
|
||||
mountPath: /var/run/cdi
|
||||
volumes:
|
||||
- name: devfs
|
||||
hostPath:
|
||||
path: /dev/dri
|
||||
- name: sysfsdrm
|
||||
hostPath:
|
||||
path: /sys/class/drm
|
||||
- name: kubeletsockets
|
||||
hostPath:
|
||||
path: /var/lib/kubelet/device-plugins
|
||||
- name: cdipath
|
||||
hostPath:
|
||||
path: /var/run/cdi
|
||||
type: DirectoryOrCreate
|
||||
nodeSelector:
|
||||
kubernetes.io/arch: amd64
|
||||
@@ -0,0 +1,4 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- daemonset.yaml
|
||||
@@ -10,6 +10,7 @@ resources:
|
||||
- cert-manager/
|
||||
- snapshot-controller/
|
||||
- metrics-server/
|
||||
- intel-gpu-plugin/
|
||||
- crowdsec/
|
||||
- traefik/
|
||||
- rook-ceph/
|
||||
@@ -17,7 +18,6 @@ resources:
|
||||
- cloudnative-pg/
|
||||
- mariadb-operator/
|
||||
- netbird-ingress/
|
||||
- netbird-backup/
|
||||
- coredns-config/
|
||||
- k8s-gateway/
|
||||
- external-dns-unifi/
|
||||
|
||||
@@ -30,11 +30,6 @@ spec:
|
||||
labels:
|
||||
app: cni-plugins-installer
|
||||
spec:
|
||||
# Same reasoning as multus-daemonset.yaml. This one installs the macvlan
|
||||
# binary the ceph-san attachment is made of, so a node that cannot
|
||||
# schedule it has no plugin to delegate to — the failure the init
|
||||
# container comment below already describes, reached by a different road.
|
||||
priorityClassName: system-node-critical
|
||||
hostNetwork: true
|
||||
tolerations:
|
||||
- operator: Exists
|
||||
@@ -49,32 +44,7 @@ spec:
|
||||
# at all and put github.com on the critical path of every node rebuild.
|
||||
# Dated tag so a version change is a reviewable diff here, matching the
|
||||
# ci-ansible / alpine-jq convention.
|
||||
#
|
||||
# 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
|
||||
image: gitea.renner.uno/gitea_admin/cni-plugins:2026-07-30
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
|
||||
@@ -154,40 +154,6 @@ spec:
|
||||
app: multus
|
||||
name: multus
|
||||
spec:
|
||||
# Upstream ships this DaemonSet with no priority class, so it runs at 0
|
||||
# and can preempt nothing. Any event that makes the pod need a fresh
|
||||
# scheduling decision — a rollout, a node restart, an eviction — then has
|
||||
# to fit it onto a node that may since have filled up, and if it does not
|
||||
# fit it simply stays Pending. The node has no multus after that, and
|
||||
# nothing reports it: pods still start, they just never get their
|
||||
# secondary attachment, and the annotation everything waits on never
|
||||
# appears.
|
||||
#
|
||||
# Seen on a cold rebuild: two of four nodes sat at 99% memory requests
|
||||
# when the DaemonSet rolled, both multus pods went Pending, and Rook's
|
||||
# network canary hung in Init:0/1 waiting for a network-status annotation
|
||||
# that could never come. The CephCluster stayed in Progressing, which is
|
||||
# what infrastructure-ceph-cluster gates on, so the whole apps layer
|
||||
# stopped behind a scheduling decision three layers away.
|
||||
#
|
||||
# node-critical, the same class cilium and cni-dhcp-daemon carry, because
|
||||
# that is what this is: without multus a node cannot attach a secondary
|
||||
# network, and only that node is affected. Nothing cluster-wide fails.
|
||||
#
|
||||
# This does outrank kube-apiserver, which Talos runs as a static pod at
|
||||
# system-cluster-critical (2000000000, against 2000001000 here), and on a
|
||||
# control plane that has run out of memory the kubelet will pick the API
|
||||
# server as the pod to evict. That was observed twice, at 6GiB and at
|
||||
# 8GiB.
|
||||
#
|
||||
# It is not a reason to demote this DaemonSet. Kubernetes puts the CNI
|
||||
# above the control plane deliberately — a node without its CNI cannot run
|
||||
# anything at all — and a class name is a statement about what the pod is,
|
||||
# not a dial for picking a number. The eviction says the control plane is
|
||||
# starved, and that is where it has to be fixed: enough allocatable
|
||||
# memory, or kubeReserved/systemReserved so the static pods keep what they
|
||||
# need. Demoting this to cluster-critical would only move the victim.
|
||||
priorityClassName: system-node-critical
|
||||
hostNetwork: true
|
||||
hostPID: true
|
||||
tolerations:
|
||||
|
||||
@@ -146,11 +146,6 @@ spec:
|
||||
app: whereabouts
|
||||
name: whereabouts
|
||||
spec:
|
||||
# Same reasoning as multus-daemonset.yaml: this is the IPAM half of the
|
||||
# secondary-network path, so a node without it cannot hand out an address
|
||||
# on ceph-san and the attachment fails just as completely as if multus
|
||||
# itself were missing. Upstream leaves the priority class unset.
|
||||
priorityClassName: system-node-critical
|
||||
hostNetwork: true
|
||||
serviceAccountName: whereabouts
|
||||
# Every node here is a control plane node, so the NoSchedule toleration
|
||||
|
||||
@@ -3,13 +3,6 @@ kind: Namespace
|
||||
metadata:
|
||||
name: apps
|
||||
---
|
||||
# Pull-based off-VPS backup of the NetBird controller state
|
||||
# (kubernetes/infrastructure/netbird-backup).
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: netbird-backup
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
@@ -184,30 +177,14 @@ kind: Namespace
|
||||
metadata:
|
||||
name: woodpecker
|
||||
---
|
||||
# Where the Woodpecker kubernetes backend spawns pipeline step pods, isolated
|
||||
# from `apps`. Distinct from `woodpecker`, which holds the service itself.
|
||||
# Local LLM inference (kubernetes/apps/llama-server). Declared here, like
|
||||
# `woodpecker` above, so the `infrastructure` KS creates it before
|
||||
# `infrastructure-crds` applies the namespace's CiliumNetworkPolicies.
|
||||
#
|
||||
# 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/.
|
||||
# No pod-security labels: the workload needs no hostPath or privilege — the
|
||||
# Intel GPU device plugin passes the iGPU in through the device-plugin API —
|
||||
# so the cluster's default `baseline` enforcement is enough.
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: woodpecker-ci
|
||||
labels:
|
||||
app.kubernetes.io/name: woodpecker
|
||||
pod-security.kubernetes.io/enforce: privileged
|
||||
name: ai
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
apiVersion: batch/v1
|
||||
kind: CronJob
|
||||
metadata:
|
||||
name: netbird-vps-backup
|
||||
namespace: netbird-backup
|
||||
labels:
|
||||
app.kubernetes.io/name: netbird-backup
|
||||
spec:
|
||||
# Nightly at 02:45, well ahead of the 07:45 Velero Schedule
|
||||
# (post-crds/velero-schedules.yaml) that ships this PVC offsite.
|
||||
schedule: "45 2 * * *"
|
||||
timeZone: Europe/Berlin
|
||||
concurrencyPolicy: Forbid
|
||||
successfulJobsHistoryLimit: 2
|
||||
failedJobsHistoryLimit: 5
|
||||
startingDeadlineSeconds: 3600
|
||||
jobTemplate:
|
||||
spec:
|
||||
backoffLimit: 2
|
||||
activeDeadlineSeconds: 600
|
||||
ttlSecondsAfterFinished: 259200 # 3 d
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: netbird-backup
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 65534
|
||||
runAsGroup: 65534
|
||||
fsGroup: 65534
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: pull
|
||||
# ci-ansible ships an ssh client and standard coreutils and
|
||||
# is pulled from the local registry; a dedicated slim image
|
||||
# is not worth maintaining for one nightly ssh pull.
|
||||
image: gitea.renner.uno/gitea_admin/ci-ansible:2026-04-29
|
||||
command:
|
||||
- sh
|
||||
- -ec
|
||||
- |
|
||||
# The key's authorized_keys entry on the VPS is
|
||||
# restrict,command=… — the server runs the dump script
|
||||
# regardless of what we request, streaming a tar.gz.
|
||||
# ssh demands 0600 on the key; secret mounts are group-
|
||||
# readable, so copy first. The host key is PINNED (no
|
||||
# TOFU): captured 2026-07-26 via ssh-keyscan and
|
||||
# cross-checked against the workstation's known_hosts.
|
||||
# If the VPS is ever reprovisioned with a new host key,
|
||||
# update this line — the job failing loudly on a key
|
||||
# mismatch is the desired behavior.
|
||||
install -m 600 /ssh/ssh-key /tmp/key
|
||||
printf '%s\n' 'netbird.renner.uno ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKCe8LRXbyn6Y+5V4ia4ErnBEcTFzHI1fAnsTvT77HJU' > /tmp/known_hosts
|
||||
# Leftover .part files from aborted earlier runs are
|
||||
# outside the retention glob — clear them up front.
|
||||
rm -f /backup/*.part
|
||||
ts=$(date +%Y-%m-%d)
|
||||
ssh -i /tmp/key \
|
||||
-o StrictHostKeyChecking=yes \
|
||||
-o UserKnownHostsFile=/tmp/known_hosts \
|
||||
-o ConnectTimeout=15 \
|
||||
root@netbird.renner.uno > "/backup/netbird-$ts.tar.gz.part"
|
||||
gzip -t "/backup/netbird-$ts.tar.gz.part"
|
||||
mv "/backup/netbird-$ts.tar.gz.part" "/backup/netbird-$ts.tar.gz"
|
||||
# Retention: newest 14 dumps (Velero keeps 30 d offsite).
|
||||
ls -1t /backup/netbird-*.tar.gz | tail -n +15 | xargs -r rm --
|
||||
ls -lh /backup/
|
||||
env:
|
||||
- name: HOME
|
||||
value: /tmp
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
resources:
|
||||
requests:
|
||||
cpu: 25m
|
||||
memory: 64Mi
|
||||
limits:
|
||||
memory: 256Mi
|
||||
volumeMounts:
|
||||
- name: ssh-key
|
||||
mountPath: /ssh
|
||||
readOnly: true
|
||||
- name: backup
|
||||
mountPath: /backup
|
||||
volumes:
|
||||
- name: ssh-key
|
||||
secret:
|
||||
secretName: netbird-backup-ssh
|
||||
defaultMode: 0440
|
||||
- name: backup
|
||||
persistentVolumeClaim:
|
||||
claimName: netbird-backup
|
||||
@@ -1,10 +0,0 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
# Pull-based backup of the NetBird controller VPS's management store.
|
||||
# The VPS side (dump script + restricted SSH key) is deployed by the
|
||||
# netbird-server Ansible role; the Velero Schedule that ships the PVC
|
||||
# offsite lives in post-crds/velero-schedules.yaml; the staleness alert
|
||||
# in apps/monitoring-rules/prometheusrule-netbird-backup.yaml.
|
||||
resources:
|
||||
- pvc.yaml
|
||||
- cronjob.yaml
|
||||
@@ -1,14 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: netbird-backup
|
||||
namespace: netbird-backup
|
||||
spec:
|
||||
# 14 daily dumps of a small SQLite store plus config — 5Gi is
|
||||
# generous headroom.
|
||||
storageClassName: ceph-block
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 5Gi
|
||||
@@ -1,4 +1,5 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- externalsecret.yaml
|
||||
- daemonset.yaml
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: default-deny-ingress
|
||||
namespace: ai
|
||||
spec:
|
||||
endpointSelector: {}
|
||||
ingress:
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": ai
|
||||
# Traefik is deliberately NOT admitted. llm.renner.uno is public and
|
||||
# llama-server has no authentication of its own, so the Authentik llama-gate
|
||||
# outpost is the only thing standing in front of it. Traefik routes the
|
||||
# hostname to that outpost and never to llama-server, and this policy is
|
||||
# what makes that structural rather than a matter of configuration: with no
|
||||
# ingress rule for traefik, an Ingress that pointed straight at the Service
|
||||
# — by accident or otherwise — simply would not connect.
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": authentik
|
||||
goauthentik.io/outpost-name: llama-gate
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "8080"
|
||||
protocol: TCP
|
||||
# Paperless calls both models for AI suggestions and RAG embeddings. The
|
||||
# matching egress grant on its side is scoped to these two pods on :8080.
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": paperless
|
||||
app.kubernetes.io/name: paperless
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "8080"
|
||||
protocol: TCP
|
||||
- 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 (CT-RELATED path not
|
||||
# passing related ICMP empirically; per-namespace shape, mirroring
|
||||
# commit 3c517a0's verified-safe pattern with the 66dbe9f type set).
|
||||
- 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": ai
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": ingress
|
||||
app.kubernetes.io/name: traefik
|
||||
# rook-ceph: the model weights live on a CephFS RWX volume
|
||||
- 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
|
||||
# host/remote-node: kubelet probes + node-local reach (Ceph mons on host net)
|
||||
# world: image pulls and llama-server's Hugging Face model download
|
||||
- toEntities:
|
||||
- host
|
||||
- remote-node
|
||||
- world
|
||||
# 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 }
|
||||
@@ -24,4 +24,5 @@ resources:
|
||||
- woodpecker-ns.yaml
|
||||
- trmnl-ns.yaml
|
||||
- wordpress-ns.yaml
|
||||
- ai-ns.yaml
|
||||
- allow-cilium-health.yaml
|
||||
|
||||
@@ -10,8 +10,33 @@
|
||||
# name hairpins off the ingress LB from a pod. Needs BOTH netpol halves
|
||||
# (gitea-allow-flux-rotator + rotator-gitea-flux-egress).
|
||||
#
|
||||
# The ExternalSecret that publishes the rotated token to Flux lives in
|
||||
# ../../post-crds/flux-git-externalsecret.yaml (needs ESO's CRDs).
|
||||
# The ExternalSecret MERGES into Flux's existing flux-system secret (does not
|
||||
# own it) so a sync failure leaves the last-good value in place; Flux's
|
||||
# source-controller reads it fresh each fetch, no restart. OpenBao policy/role
|
||||
# rotator-gitea-flux + the gitea-flux bootstrap (flux account password) are
|
||||
# provisioned by hand (see policies/rotator-gitea-flux.hcl).
|
||||
apiVersion: external-secrets.io/v1
|
||||
kind: ExternalSecret
|
||||
metadata:
|
||||
name: flux-system-git-auth
|
||||
namespace: flux-system
|
||||
spec:
|
||||
refreshInterval: 10m
|
||||
secretStoreRef:
|
||||
kind: ClusterSecretStore
|
||||
name: openbao
|
||||
target:
|
||||
name: flux-system
|
||||
creationPolicy: Merge
|
||||
data:
|
||||
- secretKey: username
|
||||
remoteRef:
|
||||
key: infrastructure/flux-git
|
||||
property: username
|
||||
- secretKey: password
|
||||
remoteRef:
|
||||
key: infrastructure/flux-git
|
||||
property: token
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
# 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,25 +10,4 @@ resources:
|
||||
# configured (2026-07-13): a store that can't turn Ready would block this
|
||||
# KS's wait gate and freeze the dependsOn chain.
|
||||
- openbao-clustersecretstore.yaml
|
||||
# ExternalSecrets. external-secrets.io CRDs come from the ESO HelmRelease in
|
||||
# the infrastructure Kustomization, so these cannot pass its server-side
|
||||
# dry-run on a fresh cluster — Flux rejects the whole apply set, including
|
||||
# the ESO HelmRelease that would have installed the CRDs. They sat next to
|
||||
# their consumers until a cold-rebuild test hit that deadlock (2026-09-01);
|
||||
# nothing had ever applied this layer against an empty API server before.
|
||||
- crowdsec-bouncer-key-externalsecret.yaml
|
||||
- external-dns-unifi-externalsecret.yaml
|
||||
- flux-git-externalsecret.yaml
|
||||
- netbird-backup-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
|
||||
- ../network-policies/
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
apiVersion: external-secrets.io/v1
|
||||
kind: ExternalSecret
|
||||
metadata:
|
||||
name: netbird-backup-ssh
|
||||
namespace: netbird-backup
|
||||
spec:
|
||||
refreshInterval: 1h
|
||||
secretStoreRef:
|
||||
kind: ClusterSecretStore
|
||||
name: openbao
|
||||
target:
|
||||
name: netbird-backup-ssh
|
||||
# OpenBao path homelab/infrastructure/netbird-backup, key `ssh-key`:
|
||||
# the private half of the dedicated backup keypair whose public half
|
||||
# is pinned (forced-command, restrict) in the VPS's authorized_keys by
|
||||
# the netbird-server role. ONE-TIME provisioning by hand (same pattern
|
||||
# as the rotator bootstrap credentials):
|
||||
# bao kv put homelab/infrastructure/netbird-backup ssh-key=@<keyfile>
|
||||
dataFrom:
|
||||
- extract:
|
||||
key: infrastructure/netbird-backup
|
||||
@@ -26,7 +26,6 @@ spec:
|
||||
- influxdb
|
||||
- ingress
|
||||
- monitoring
|
||||
- netbird-backup # netbird-backup-ssh: the VPS-pull backup key
|
||||
- paperless
|
||||
- trmnl
|
||||
- velero
|
||||
|
||||
@@ -247,40 +247,14 @@ spec:
|
||||
---
|
||||
apiVersion: velero.io/v1
|
||||
kind: Schedule
|
||||
metadata:
|
||||
name: netbird-backup-daily
|
||||
namespace: velero
|
||||
labels:
|
||||
app.kubernetes.io/part-of: homelab-backup
|
||||
spec:
|
||||
# Daily at 07:45 local (next free stagger slot after apps-daily at
|
||||
# 07:15). Ships the netbird-backup PVC — nightly pulls of the NetBird
|
||||
# controller VPS's management store (SQLite dump + config, pulled at
|
||||
# 02:45 by the netbird-vps-backup CronJob) — offsite. This is the
|
||||
# only copy of the mesh state that survives losing the Hetzner VPS.
|
||||
# Added 2026-07-26 (SPOF assessment: controller store had zero backup
|
||||
# coverage). No CNPG in this namespace.
|
||||
schedule: "45 7 * * *"
|
||||
useOwnerReferencesInBackup: false
|
||||
template:
|
||||
includedNamespaces:
|
||||
- netbird-backup
|
||||
includedResources:
|
||||
- persistentvolumeclaims
|
||||
storageLocation: default
|
||||
ttl: 720h0m0s
|
||||
snapshotMoveData: true
|
||||
---
|
||||
apiVersion: velero.io/v1
|
||||
kind: Schedule
|
||||
metadata:
|
||||
name: trmnl-daily
|
||||
namespace: velero
|
||||
labels:
|
||||
app.kubernetes.io/part-of: homelab-backup
|
||||
spec:
|
||||
# Daily at 08:15 local (next free stagger slot after netbird-backup-daily
|
||||
# at 07:45). Selects the whole namespace minus the CNPG-labelled PVCs, so
|
||||
# Daily at 08:15 local (next free stagger slot after apps-daily at
|
||||
# 07:15). Selects the whole namespace minus the CNPG-labelled PVCs, so
|
||||
# it picks up terminus-data (device screens + synchronised fonts),
|
||||
# terminus-valkey-data and trmnl-renderer-data (the renderer's schedule
|
||||
# and rendered output) without needing to be edited per PVC. The tr-pg
|
||||
|
||||
@@ -5,7 +5,7 @@ metadata:
|
||||
namespace: rook-ceph
|
||||
spec:
|
||||
cephVersion:
|
||||
image: quay.io/ceph/ceph:v20.2.1 # {"$imagepolicy": "flux-system:ceph"}
|
||||
image: quay.io/ceph/ceph:v19.2.6 # {"$imagepolicy": "flux-system:ceph"}
|
||||
allowUnsupported: false
|
||||
dataDirHostPath: /var/lib/rook
|
||||
# Defaults to false. With three OSDs and size 3 there is no spare copy, so
|
||||
@@ -78,20 +78,7 @@ spec:
|
||||
enabled: true
|
||||
ssl: false
|
||||
monitoring:
|
||||
# false, and this is not a decision about metrics. `metricsDisabled` is the
|
||||
# switch that turns metrics off; this one only decides whether Rook creates
|
||||
# the ServiceMonitors — and creating them is fatal to the reconcile when it
|
||||
# fails. On an empty cluster monitoring.coreos.com/v1 does not exist, so
|
||||
# Rook's node-daemon reconcile aborted, the CephCluster never left
|
||||
# status.phase Progressing, and infrastructure-ceph-cluster's healthCheckExpr
|
||||
# waits on exactly that — with grafana-db → monitoring → those same CRDs
|
||||
# behind it, closing the loop.
|
||||
#
|
||||
# The ceph-exporter DaemonSet and the mgr metrics endpoint are untouched;
|
||||
# only the scrape wiring moved, to apps/monitoring-rules/
|
||||
# rook-servicemonitors.yaml, where the CRDs exist by the time it applies.
|
||||
# Both objects are reproduced there field for field off the live cluster.
|
||||
enabled: false
|
||||
enabled: true
|
||||
# Dedicated Ceph cluster network on VLAN 49, attached to the OSDs through
|
||||
# Multus. The attachment is san-network-attachment.yaml.
|
||||
#
|
||||
@@ -144,7 +131,7 @@ spec:
|
||||
useAllDevices: false
|
||||
# Two forms while the production drives are migrated:
|
||||
#
|
||||
# by-partlabel/r-osd-data the target. Talos names the drive in the
|
||||
# by-partlabel/r-ceph-osd the target. Talos names the drive in the
|
||||
# machine config (RawVolumeConfig ceph-osd), so
|
||||
# this file needs to know nothing about models,
|
||||
# serials or hostnames.
|
||||
@@ -159,7 +146,7 @@ spec:
|
||||
#
|
||||
# The second half goes when those three are repartitioned, one node at a
|
||||
# time. Until then it is what keeps the running OSDs claimed.
|
||||
devicePathFilter: "^/dev/disk/by-(partlabel/r-osd-data|id/nvme-SAMSUNG_MZQLB960HAJR)"
|
||||
devicePathFilter: "^/dev/disk/by-(partlabel/r-ceph-osd|id/nvme-SAMSUNG_MZQLB960HAJR)"
|
||||
config:
|
||||
osdsPerDevice: "1"
|
||||
# Highest built-in class: kubelet evicts these last and the kernel gives
|
||||
|
||||
@@ -3,26 +3,33 @@
|
||||
# without every Ceph daemon landing on host networking to get there. The
|
||||
# alternative and why it was rejected is in cephcluster.yaml.
|
||||
#
|
||||
# host-device: the SAN NIC is dedicated to Ceph and has exactly one consumer
|
||||
# per node — the OSD pod — so it is handed to that pod whole, not virtualised
|
||||
# under it. The device keeps its identity (MAC, MTU) and moves back to the
|
||||
# host when the pod goes away.
|
||||
#
|
||||
# `device` names the role, not hardware: every machine renames its own SAN
|
||||
# interface to ceph-san at boot (udev rule in its Talos patch). No kernel
|
||||
# interface name appears in this file.
|
||||
#
|
||||
# ISOLATION
|
||||
#
|
||||
# VLAN 49 carries Ceph-internal traffic and nothing else. No gateway and no
|
||||
# routes: there is no router on this segment and there must not be one.
|
||||
# whereabouts adds neither unless asked, so nothing here can leak onto or off
|
||||
# the SAN.
|
||||
# VLAN 49 carries Ceph-internal traffic and nothing else. Two consequences
|
||||
# are deliberate:
|
||||
#
|
||||
# The pool is the whole usable /29, .1 through .6 — headroom for a leaked
|
||||
# allocation to sit until the reconciler collects it. One address per node is
|
||||
# in use; a second OSD on a node would need a second SAN interface, which is a
|
||||
# hardware change, not a config one.
|
||||
# No gateway and no routes. There is no router on this segment and there
|
||||
# must not be one. whereabouts adds neither unless asked, so nothing here
|
||||
# can leak onto or off the SAN. This also means no `sbr` chaining is
|
||||
# needed — unlike the v30iot attachments, there is no DHCP-supplied default
|
||||
# route to confine, because there is no DHCP and no default route.
|
||||
#
|
||||
# The pool is the whole usable /29, .1 through .6. It was .4-.6 while the
|
||||
# migration ran, because the nodes still held .1-.3 for as long as Ceph
|
||||
# needed host networking to reach this link — three addresses for three
|
||||
# OSDs, no spare. The nodes have since given those up (the link is only a
|
||||
# macvlan parent now and needs no address of its own), so the reservation
|
||||
# has nothing left to avoid.
|
||||
#
|
||||
# Keeping the pool inside the /29 rather than moving it elsewhere on the
|
||||
# segment is not cosmetic. There is no router here, so an address outside
|
||||
# the /29 would be unreachable from anything still using one inside it —
|
||||
# which is exactly what would break a future rolling change, where migrated
|
||||
# and un-migrated OSDs have to keep talking to each other.
|
||||
#
|
||||
# Six addresses for three OSDs is the headroom the migration could not
|
||||
# afford: room for a leaked allocation to sit until the reconciler collects
|
||||
# it, and room to add a second OSD per node without touching this file.
|
||||
#
|
||||
# IPAM
|
||||
#
|
||||
@@ -30,18 +37,29 @@
|
||||
# a server that must not exist here, and host-local keeps its state per node
|
||||
# with no cluster-wide coordination, so two nodes would hand out the same
|
||||
# address on shared L2. See infrastructure/multus/whereabouts-daemonset.yaml.
|
||||
#
|
||||
# MTU 9000 matches the link. macvlan children inherit the master's MTU, but
|
||||
# it is stated so that a change to the parent does not silently halve
|
||||
# replication throughput without anything in the tree recording the
|
||||
# expectation.
|
||||
apiVersion: k8s.cni.cncf.io/v1
|
||||
kind: NetworkAttachmentDefinition
|
||||
metadata:
|
||||
name: ceph-san
|
||||
namespace: rook-ceph
|
||||
spec:
|
||||
# `master` is an interface name. enp2s0f1np1 is the second X710 SFP+ port,
|
||||
# a busPath-derived predictable name (net.ifnames=1 via the Image Factory
|
||||
# schematic) that is identical on k8s-1/2/3 and cannot shuffle across
|
||||
# reboots.
|
||||
config: |
|
||||
{
|
||||
"cniVersion": "0.3.1",
|
||||
"name": "ceph-san",
|
||||
"type": "host-device",
|
||||
"device": "ceph-san",
|
||||
"type": "macvlan",
|
||||
"master": "enp2s0f1np1",
|
||||
"mode": "bridge",
|
||||
"mtu": 9000,
|
||||
"ipam": {
|
||||
"type": "whereabouts",
|
||||
"range": "10.99.0.0/29",
|
||||
|
||||
@@ -38,7 +38,7 @@ spec:
|
||||
serviceAccountName: rook-ceph-default
|
||||
containers:
|
||||
- name: rook-ceph-tools
|
||||
image: quay.io/ceph/ceph:v20.2.1 # {"$imagepolicy": "flux-system:ceph"}
|
||||
image: quay.io/ceph/ceph:v19.2.6 # {"$imagepolicy": "flux-system:ceph"}
|
||||
command:
|
||||
- /bin/bash
|
||||
- -c
|
||||
|
||||
@@ -2,3 +2,6 @@ apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- helmrelease.yaml
|
||||
- crowdsec-middleware.yaml
|
||||
- security-headers-middleware.yaml
|
||||
- rate-limit-middleware.yaml
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- externalsecret.yaml
|
||||
- hetzner-creds-externalsecret.yaml
|
||||
- snapshot-readonly-storageclass.yaml
|
||||
- node-agent-config.yaml
|
||||
- helmrelease.yaml
|
||||
- servicemonitor.yaml
|
||||
- podmonitor.yaml
|
||||
- restore-test-rbac.yaml
|
||||
- restore-test-cronjob.yaml
|
||||
# Velero Schedule CRs live in kubernetes/infrastructure/post-crds/
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
# 1. Velero Restore reaches phase=Completed against yesterday's
|
||||
# scheduled Backup (real artifact, not a synthetic one).
|
||||
# 2. Each restored PVC is mountable and its file count is within ±10%
|
||||
# of the live source volume, ignoring the transient subtrees listed
|
||||
# in volatile_paths_for().
|
||||
# (or ±ABS_PARITY_SLACK files, whichever is wider) of the live
|
||||
# source volume, ignoring the transient subtrees listed in
|
||||
# volatile_paths_for().
|
||||
#
|
||||
# What this does NOT exercise:
|
||||
# - CNPG database restore (barman-cloud, separate code path).
|
||||
@@ -317,11 +318,37 @@ EOF
|
||||
count_files_in_pvc "$src_ns" "$tmp_pvc" "src-${pvc:0:40}-${RUN_ID}" "$excludes"
|
||||
}
|
||||
|
||||
# Restored count must be within ±10% of the source's current file count.
|
||||
# That tolerance absorbs day-of drift since the backup ran without
|
||||
# letting "lost half the data" pass silently. Volatile subtrees are pruned
|
||||
# from BOTH sides — keyed off the source identity, since the PVC name is
|
||||
# preserved across the restore but the destination namespace is not.
|
||||
# Restored count must be within max(±10%, ±ABS_PARITY_SLACK files) of the
|
||||
# source's current file count. That tolerance absorbs day-of drift since
|
||||
# the backup ran without letting "lost half the data" pass silently.
|
||||
# Volatile subtrees are pruned from BOTH sides — keyed off the source
|
||||
# identity, since the PVC name is preserved across the restore but the
|
||||
# destination namespace is not.
|
||||
#
|
||||
# Why the absolute floor exists. A purely relative window is structurally
|
||||
# unable to pass a small volume: ±10% of 9 files is ±0.9, i.e. a budget of
|
||||
# ZERO files, and ±10% of 2 files is ±0.2. The 2026-09-01 run failed on
|
||||
# exactly that — audiobookshelf/libation-config source=9 restored=8 and
|
||||
# trmnl/trmnl-renderer-data source=2 restored=3, both off by one file and
|
||||
# in opposite directions, with all 12 restores at phase=Completed and the
|
||||
# other 46 checks green. Parity compares a backup taken up to ~24 h ago
|
||||
# against the live volume *now*, so a single config write on a tiny volume
|
||||
# between those two moments is expected, not a defect in the backup.
|
||||
#
|
||||
# The floor only governs volumes below ~10*ABS_PARITY_SLACK files; above
|
||||
# that the 10% window still binds, so real data loss on the volumes that
|
||||
# carry data keeps failing loudly. Two files of slack rather than one
|
||||
# because a single logical write on a config volume routinely moves more
|
||||
# than one file (an app writing a settings file plus a transient sidecar),
|
||||
# and a floor that buys exactly one month is not worth the change.
|
||||
#
|
||||
# What the floor must NOT swallow, and does not: a non-empty source that
|
||||
# restored to an *empty* volume fails unconditionally below, before any
|
||||
# tolerance is applied. The old relative-only check caught that case as a
|
||||
# side effect (delta = 1.0 > 0.10); it is now explicit so the floor cannot
|
||||
# erode it on a volume of ABS_PARITY_SLACK files or fewer.
|
||||
ABS_PARITY_SLACK=${ABS_PARITY_SLACK:-2}
|
||||
|
||||
verify_pvc_file_count_parity() {
|
||||
local src_ns="$1" dst_ns="$2" pvc="$3"
|
||||
local restored source excludes
|
||||
@@ -339,11 +366,15 @@ verify_pvc_file_count_parity() {
|
||||
if [[ "$source" -eq 0 && "$restored" -eq 0 ]]; then
|
||||
return 0
|
||||
fi
|
||||
# Use awk to compute a 10% window without bc.
|
||||
awk -v s="$source" -v r="$restored" 'BEGIN {
|
||||
if (s == 0) { exit !(r == 0) }
|
||||
delta = (r - s) / s; if (delta < 0) delta = -delta
|
||||
exit !(delta <= 0.10)
|
||||
# Empty on either side while the other has data: never tolerable.
|
||||
if [[ "$source" -eq 0 || "$restored" -eq 0 ]]; then
|
||||
return 1
|
||||
fi
|
||||
# Use awk to compute the window without bc.
|
||||
awk -v s="$source" -v r="$restored" -v slack="$ABS_PARITY_SLACK" 'BEGIN {
|
||||
delta = r - s; if (delta < 0) delta = -delta
|
||||
tol = 0.10 * s; if (tol < slack) tol = slack
|
||||
exit !(delta <= tol)
|
||||
}'
|
||||
}
|
||||
|
||||
@@ -492,7 +523,7 @@ EOF
|
||||
check "${src_ns}: at least one PVC was restored" false
|
||||
else
|
||||
for pvc in "${PVCS[@]}"; do
|
||||
check "${src_ns}: PVC ${pvc} file count parity (±10%)" \
|
||||
check "${src_ns}: PVC ${pvc} file count parity (±10% / ±${ABS_PARITY_SLACK})" \
|
||||
verify_pvc_file_count_parity "$src_ns" "$dst_ns" "$pvc"
|
||||
done
|
||||
fi
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
# First machine of the throwaway cluster on Hetzner.
|
||||
#
|
||||
# enp1s0 is the public NIC; the private networks are enp7s0 and enp8s0, in the
|
||||
# order bootstrap.sh attaches them.
|
||||
machine:
|
||||
install:
|
||||
disk: /dev/sda
|
||||
certSANs:
|
||||
- hcloud-1
|
||||
nodeLabels:
|
||||
# Holds a Ceph OSD device; the CephCluster selects OSD nodes by this label.
|
||||
storage.renner.uno/ceph-osd: "true"
|
||||
udev:
|
||||
# Renames the SAN NIC to the role name at boot. Matched by PCI bus path
|
||||
# rather than MAC: the address follows from the order bootstrap.sh attaches
|
||||
# the networks, while the MAC is assigned by Hetzner at attach time and is
|
||||
# not known when this file is written.
|
||||
rules:
|
||||
- SUBSYSTEM=="net", ACTION=="add", KERNELS=="0000:08:00.0", NAME="ceph-san"
|
||||
---
|
||||
# In the generated schema rather than machine.network.hostname: naming the host
|
||||
# in both is rejected at apply time. `auto: off` switches off the generated
|
||||
# `auto: stable`, which is mutually exclusive with a static name.
|
||||
apiVersion: v1alpha1
|
||||
kind: HostnameConfig
|
||||
auto: off
|
||||
hostname: hcloud-1
|
||||
---
|
||||
# Every interface is stated, the public one included. Talos stops running its
|
||||
# default DHCP operators for the whole machine as soon as one new-style network
|
||||
# document exists, so an omitted interface gets no address.
|
||||
apiVersion: v1alpha1
|
||||
kind: LinkConfig
|
||||
name: enp1s0
|
||||
up: true
|
||||
---
|
||||
apiVersion: v1alpha1
|
||||
kind: DHCPv4Config
|
||||
name: enp1s0
|
||||
---
|
||||
apiVersion: v1alpha1
|
||||
kind: LinkConfig
|
||||
name: enp7s0
|
||||
up: true
|
||||
---
|
||||
apiVersion: v1alpha1
|
||||
kind: DHCPv4Config
|
||||
name: enp7s0
|
||||
---
|
||||
# Ceph cluster network, renamed to the role name by the udev rule in the
|
||||
# machine section. The node holds no address here; the link exists for the OSD
|
||||
# pod. No mtu: a Hetzner network carries 1450, not production's 9000.
|
||||
apiVersion: v1alpha1
|
||||
kind: LinkConfig
|
||||
name: ceph-san
|
||||
up: true
|
||||
---
|
||||
apiVersion: v1alpha1
|
||||
kind: RawVolumeConfig
|
||||
name: osd-data
|
||||
provisioning:
|
||||
diskSelector:
|
||||
# Two disks: the system disk and the attached volume. Matching on transport
|
||||
# as the qemu patches do selects nothing here — a Hetzner volume arrives
|
||||
# over virtio-scsi, and a selector matching zero disks is skipped silently.
|
||||
match: '!system_disk'
|
||||
# bootstrap.sh reads this size and creates the volume to match.
|
||||
minSize: 50GiB
|
||||
grow: true
|
||||
@@ -1,50 +0,0 @@
|
||||
# Second machine. See hcloud-1.yaml.
|
||||
machine:
|
||||
install:
|
||||
disk: /dev/sda
|
||||
certSANs:
|
||||
- hcloud-2
|
||||
nodeLabels:
|
||||
storage.renner.uno/ceph-osd: "true"
|
||||
udev:
|
||||
# See hcloud-1.yaml.
|
||||
rules:
|
||||
- SUBSYSTEM=="net", ACTION=="add", KERNELS=="0000:08:00.0", NAME="ceph-san"
|
||||
---
|
||||
apiVersion: v1alpha1
|
||||
kind: HostnameConfig
|
||||
auto: off
|
||||
hostname: hcloud-2
|
||||
---
|
||||
apiVersion: v1alpha1
|
||||
kind: LinkConfig
|
||||
name: enp1s0
|
||||
up: true
|
||||
---
|
||||
apiVersion: v1alpha1
|
||||
kind: DHCPv4Config
|
||||
name: enp1s0
|
||||
---
|
||||
apiVersion: v1alpha1
|
||||
kind: LinkConfig
|
||||
name: enp7s0
|
||||
up: true
|
||||
---
|
||||
apiVersion: v1alpha1
|
||||
kind: DHCPv4Config
|
||||
name: enp7s0
|
||||
---
|
||||
# See hcloud-1.yaml.
|
||||
apiVersion: v1alpha1
|
||||
kind: LinkConfig
|
||||
name: ceph-san
|
||||
up: true
|
||||
---
|
||||
apiVersion: v1alpha1
|
||||
kind: RawVolumeConfig
|
||||
name: osd-data
|
||||
provisioning:
|
||||
diskSelector:
|
||||
match: '!system_disk'
|
||||
minSize: 50GiB
|
||||
grow: true
|
||||
@@ -1,50 +0,0 @@
|
||||
# Third machine. See hcloud-1.yaml.
|
||||
machine:
|
||||
install:
|
||||
disk: /dev/sda
|
||||
certSANs:
|
||||
- hcloud-3
|
||||
nodeLabels:
|
||||
storage.renner.uno/ceph-osd: "true"
|
||||
udev:
|
||||
# See hcloud-1.yaml.
|
||||
rules:
|
||||
- SUBSYSTEM=="net", ACTION=="add", KERNELS=="0000:08:00.0", NAME="ceph-san"
|
||||
---
|
||||
apiVersion: v1alpha1
|
||||
kind: HostnameConfig
|
||||
auto: off
|
||||
hostname: hcloud-3
|
||||
---
|
||||
apiVersion: v1alpha1
|
||||
kind: LinkConfig
|
||||
name: enp1s0
|
||||
up: true
|
||||
---
|
||||
apiVersion: v1alpha1
|
||||
kind: DHCPv4Config
|
||||
name: enp1s0
|
||||
---
|
||||
apiVersion: v1alpha1
|
||||
kind: LinkConfig
|
||||
name: enp7s0
|
||||
up: true
|
||||
---
|
||||
apiVersion: v1alpha1
|
||||
kind: DHCPv4Config
|
||||
name: enp7s0
|
||||
---
|
||||
# See hcloud-1.yaml.
|
||||
apiVersion: v1alpha1
|
||||
kind: LinkConfig
|
||||
name: ceph-san
|
||||
up: true
|
||||
---
|
||||
apiVersion: v1alpha1
|
||||
kind: RawVolumeConfig
|
||||
name: osd-data
|
||||
provisioning:
|
||||
diskSelector:
|
||||
match: '!system_disk'
|
||||
minSize: 50GiB
|
||||
grow: true
|
||||
@@ -28,13 +28,6 @@ machine:
|
||||
# this label instead of listing hostnames, so which boxes carry storage is
|
||||
# stated once, here, by the machine that knows.
|
||||
storage.renner.uno/ceph-osd: "true"
|
||||
udev:
|
||||
# Renames the SAN port (X710 port 1, matched by its burned-in MAC) to the
|
||||
# role name at boot, before the network stack opens links — a rule applied
|
||||
# to a RUNNING node cannot rename (Resource busy) and takes effect at the
|
||||
# next reboot.
|
||||
rules:
|
||||
- SUBSYSTEM=="net", ACTION=="add", ATTR{address}=="58:47:ca:74:70:28", NAME="ceph-san"
|
||||
cluster:
|
||||
allowSchedulingOnControlPlanes: true
|
||||
---
|
||||
@@ -131,15 +124,14 @@ parent: enp2s0f0np0
|
||||
up: true
|
||||
---
|
||||
# Dedicated Ceph cluster network on X710 port 1, VLAN 49, untagged on the
|
||||
# wire. OSD-to-OSD replication and nothing else. The udev rule in the machine
|
||||
# section above renames the port to ceph-san at boot, so shared manifests name
|
||||
# the role and only this file knows the hardware.
|
||||
# wire. OSD-to-OSD replication and nothing else.
|
||||
#
|
||||
# The node holds NO address here on purpose: the link is handed whole to the
|
||||
# OSD pod (host-device attachment). Giving the node an address would put it on
|
||||
# a segment reserved for Ceph-internal traffic.
|
||||
# The node holds NO address here on purpose. Rook attaches the link to the OSD
|
||||
# pods through a Multus macvlan, and a macvlan parent only needs to be up.
|
||||
# Giving the node an address would put it on a segment reserved for
|
||||
# Ceph-internal traffic.
|
||||
apiVersion: v1alpha1
|
||||
kind: LinkConfig
|
||||
name: ceph-san
|
||||
name: enp2s0f1np1
|
||||
up: true
|
||||
mtu: 9000
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user