Compare commits
118
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05cd7f3674 | ||
|
|
d655d65ad9 | ||
|
|
60a3da9447 | ||
|
|
47086a5fe9 | ||
|
|
412acf7cb4 | ||
|
|
75ecf020cc | ||
|
|
6a65a5982a | ||
|
|
154d0328ce | ||
|
|
0a101eb8ee | ||
|
|
f86f19045e | ||
|
|
7903c81c9a | ||
|
|
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 | ||
|
|
95095ffb3d | ||
|
|
3b178068a8 | ||
|
|
ddd3dee23c | ||
|
|
97b6461856 | ||
|
|
94701ea236 | ||
|
|
b63440ee39 | ||
|
|
6c74bf7674 | ||
|
|
9c5ed5dfcf | ||
|
|
e39d8e8b4d | ||
|
|
6e8d4cdfba | ||
|
|
bcb5110758 | ||
|
|
5c6d59b0b4 | ||
|
|
c92aff9e75 | ||
|
|
cfba60afd0 | ||
|
|
ecb19e5085 | ||
|
|
17d4c55485 | ||
|
|
1f18599b4c | ||
|
|
bce155b852 | ||
|
|
6d543194af | ||
|
|
96e20146a7 | ||
|
|
c0ed09754d | ||
|
|
78904a2d62 | ||
|
|
fd7e4a6acd | ||
|
|
e205f58b87 | ||
|
|
1f3afcd7e3 | ||
|
|
7a278da1df | ||
|
|
469cdbc2ee | ||
|
|
b0df454fe4 | ||
|
|
3f6f1edcb3 | ||
|
|
09c10e3fa6 | ||
|
|
ad0102d2a9 | ||
|
|
62f1a62b2b | ||
|
|
f1445a2217 | ||
|
|
5d21426a6b | ||
|
|
64462f540a | ||
|
|
1d8ba05123 | ||
|
|
f935776f2b | ||
|
|
75e8a81c56 | ||
|
|
5c412f0486 | ||
|
|
e15067a784 | ||
|
|
541f3fcfe9 | ||
|
|
74b1bb242f | ||
|
|
847664eaa3 | ||
|
|
2ef9811b13 | ||
|
|
b8125e4669 | ||
|
|
9c9e45d17d | ||
|
|
b68ef964d9 | ||
|
|
290befa208 | ||
|
|
fbf41d27cd |
@@ -101,7 +101,7 @@ steps:
|
||||
bash scripts/ci/make-diag-kubeconfig.sh
|
||||
|
||||
- name: alert-triage-agent
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.247
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.250
|
||||
environment:
|
||||
CLAUDE_CODE_OAUTH_TOKEN:
|
||||
from_secret: claude_code_oauth_token
|
||||
|
||||
@@ -17,6 +17,6 @@ when:
|
||||
|
||||
steps:
|
||||
- name: model-selection-tests
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.247
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.250
|
||||
commands:
|
||||
- sh scripts/ci/test-model-selection.sh
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ steps:
|
||||
# fetch-models.sh response mapping. No cluster, no secrets, no network — the
|
||||
# ci-claude image just provides sh + jq. Keeps the flag-injection regression
|
||||
# test from rotting (it exercises the exact injection shapes).
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.247
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.250
|
||||
commands:
|
||||
- sh scripts/ci/test-model-selection.sh
|
||||
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
when:
|
||||
- event: deployment
|
||||
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "install-garage-synology"'
|
||||
|
||||
steps:
|
||||
- name: run-playbook
|
||||
image: gitea.renner.uno/gitea_admin/ci-ansible:2026-04-29
|
||||
environment:
|
||||
ANSIBLE_FORCE_COLOR: "true"
|
||||
ANSIBLE_HOST_KEY_CHECKING: "false"
|
||||
SSH_PRIVATE_KEY:
|
||||
from_secret: ssh_private_key
|
||||
VAULT_PASS:
|
||||
from_secret: vault_pass
|
||||
PROXMOX_TOKEN_ID:
|
||||
from_secret: proxmox_token_id
|
||||
PROXMOX_TOKEN_SECRET:
|
||||
from_secret: proxmox_token_secret
|
||||
commands:
|
||||
- mkdir -p ~/.ssh
|
||||
- echo "$SSH_PRIVATE_KEY" | base64 -d > ~/.ssh/id_ed25519
|
||||
- chmod 600 ~/.ssh/id_ed25519
|
||||
- 'printf "Host *\n\tStrictHostKeyChecking no\n\tUserKnownHostsFile /dev/null\n" > ~/.ssh/config'
|
||||
- cd ansible
|
||||
- echo "$$VAULT_PASS" > .vault_pass && chmod 600 .vault_pass
|
||||
- export ANSIBLE_VAULT_PASSWORD_FILE=.vault_pass
|
||||
- ansible-playbook -i inventory/ plays/install-garage-synology.yml -v
|
||||
@@ -62,7 +62,7 @@ steps:
|
||||
- bash scripts/ci/make-diag-kubeconfig.sh
|
||||
|
||||
- name: issue-fix-agent
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.247
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.250
|
||||
environment:
|
||||
CLAUDE_CODE_OAUTH_TOKEN:
|
||||
from_secret: claude_code_oauth_token
|
||||
|
||||
@@ -65,7 +65,7 @@ when:
|
||||
|
||||
steps:
|
||||
- name: pr-review-agent
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.247
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.250
|
||||
environment:
|
||||
CLAUDE_CODE_OAUTH_TOKEN:
|
||||
from_secret: claude_code_oauth_token
|
||||
|
||||
@@ -40,12 +40,28 @@ steps:
|
||||
cat /tmp/images.txt
|
||||
echo "---"
|
||||
i=0
|
||||
failed=0
|
||||
failed_names=""
|
||||
while IFS= read -r img; do
|
||||
i=$$((i+1))
|
||||
out="trivy-image-$$i.json"
|
||||
echo "=== [$$i] $$img -> $$out ==="
|
||||
trivy image --skip-db-update --scanners vuln --timeout 15m --severity CRITICAL,HIGH --ignore-unfixed --format json -o "$$out" "$$img" || true
|
||||
# Keep going on a bad image so one unpullable reference cannot cost
|
||||
# the scan of every image after it, but record that it happened.
|
||||
if ! trivy image --skip-db-update --scanners vuln --timeout 15m --severity CRITICAL,HIGH --ignore-unfixed --format json -o "$$out" "$$img"; then
|
||||
failed=$$((failed+1))
|
||||
failed_names="$$failed_names $$img"
|
||||
fi
|
||||
done < /tmp/images.txt
|
||||
echo "--- scanned $$((i - failed)) of $$i images ---"
|
||||
if [ "$$failed" -gt 0 ]; then
|
||||
echo "NOT SCANNED:$$failed_names"
|
||||
exit 1
|
||||
fi
|
||||
# Non-zero above turns this step red without failing the workflow, so the
|
||||
# fix agent still runs on what was scanned. Coverage is reported, not
|
||||
# enforced: an incomplete scan is visible in the step status instead of
|
||||
# being indistinguishable from a clean one.
|
||||
failure: ignore
|
||||
|
||||
- name: security-fix-agent
|
||||
@@ -54,7 +70,7 @@ steps:
|
||||
# this step is skipped — otherwise we'd fork-bomb PRs.
|
||||
when:
|
||||
- branch: master
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.247
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.250
|
||||
environment:
|
||||
CLAUDE_CODE_OAUTH_TOKEN:
|
||||
from_secret: claude_code_oauth_token
|
||||
|
||||
@@ -30,7 +30,7 @@ when:
|
||||
|
||||
steps:
|
||||
- name: update-audit-agent
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.247
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.250
|
||||
environment:
|
||||
CLAUDE_CODE_OAUTH_TOKEN:
|
||||
from_secret: claude_code_oauth_token
|
||||
|
||||
@@ -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
|
||||
@@ -1,37 +0,0 @@
|
||||
---
|
||||
# PBS Client configuration for nas-1
|
||||
pbs_backup_paths:
|
||||
- /volume1/Documents
|
||||
- /volume1/homes
|
||||
- /volume1/Homeassistant Backup
|
||||
- /volume1/Games
|
||||
- /volume1/Media
|
||||
- /volume1/Music
|
||||
- /volume1/video
|
||||
- /volume2/Backup
|
||||
- /volume1/Movies
|
||||
|
||||
# Add more paths as needed:
|
||||
# - /volume1/photos
|
||||
# - /volume1/music
|
||||
|
||||
# PBS Server connection
|
||||
pbs_server_host: "pbs2.renner.uno"
|
||||
pbs_server_port: 8007
|
||||
pbs_datastore: "pool"
|
||||
pbs_backup_id: "nas-1"
|
||||
|
||||
# PBS Authentication - credentials in vault.yml
|
||||
pbs_username: "root@pam!nas-1"
|
||||
|
||||
# Garage S3 (K8s backup target)
|
||||
garage_image: "dxflrs/garage:v2.3.0"
|
||||
garage_zone: "dsm"
|
||||
garage_capacity: "2T"
|
||||
|
||||
# SSH connection - use password auth (key not supported by DSM)
|
||||
ansible_ssh_pass: "{{ nas_ssh_password }}"
|
||||
ansible_become_pass: "{{ nas_ssh_password }}"
|
||||
|
||||
# Python 3.9+ from Synology package
|
||||
ansible_python_interpreter: /usr/local/bin/python3.9
|
||||
@@ -1,27 +0,0 @@
|
||||
$ANSIBLE_VAULT;1.1;AES256
|
||||
34663462636437633437613863366266306334666635643839313737343739643864613136613938
|
||||
3138393966306561656366396465383539303137333036300a376237376636356230343834386362
|
||||
36636535396330333732383233323263613132333166343739396137636538653435336662353138
|
||||
6565643866623235610a316266383933303830323936303735623338623165386339383733373435
|
||||
62366461363833616438373237383132366438326364363462333265366533323861376630643365
|
||||
39343036343838333639363432643738666630363063323636646661313539643465366532363438
|
||||
33303837623234613434633563666562346536346435353461373536323737376233386163326537
|
||||
61383963343737656438356136613537623538333163343466343739346135333439616432663763
|
||||
36663962633037376561326434303035333938646436663832303432346365353439666435613337
|
||||
34363636326530333233313231343566306432376532396666643761633136316333376337666135
|
||||
65363338623035663438323338663863366362653131336434323865326530326534623631336263
|
||||
36633063383031386161363131333636393364623436613331353733313235383062656131313164
|
||||
63393665633361313961363637663237616364643236383835393736643937393564656238376362
|
||||
31393465383361326264376133393233616264663636383337363962356532613438663637616536
|
||||
33653137656335323763313461323633616635346232303062323062333766383931313965346361
|
||||
66656461313239633366636463633465636463663937393932636337643861383937663662306466
|
||||
32353166643832303234643264633062616330393963333034623432613730366339306637613239
|
||||
34626161663666316339323762373639353335653663353635366664643063393432623039306237
|
||||
38363161343930363763393364663364353735343236313665313461626165636135303839363164
|
||||
37336638396564326166376233353730346563656266323535666265323062646232323938353732
|
||||
32636235333839623166633761376334316434613235333730353439343262393537363461333238
|
||||
32653435623362666662336532643832356163326332636436346132643237643030343761663332
|
||||
36663530356539303636663438646430363335353135323932613863666264363533326137353164
|
||||
31663032346236616263343637396439663334396166656364623637666535386530383535663435
|
||||
66303037303265616634373763616437333835333136336563323339666563663931396133633131
|
||||
3963653266633535303630653335666166313336653831623362
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
# Arms the CrowdSec firewall bouncer on THIS host only.
|
||||
#
|
||||
# The role defaults to dry-run for the whole netbird_proxy group, so the two
|
||||
# members can be armed one at a time. proxy-2 goes first deliberately: it is
|
||||
# the dual-stack member, while proxy-1 is IPv6-only and the one where every
|
||||
# network-shaped problem has surfaced first — the dhcpcd/resolv.conf breakage,
|
||||
# and the GitHub download that could not work at all.
|
||||
#
|
||||
# What flipping this does: the bouncer stops logging what it would block and
|
||||
# creates two nftables tables (ip crowdsec, ip6 crowdsec6) with the decisions
|
||||
# it holds. It touches no existing chain and sets no chain policy.
|
||||
#
|
||||
# Rollback is `systemctl stop crowdsec-firewall-bouncer` — a clean stop deletes
|
||||
# both tables itself. Setting this back to dry-run and re-running the play does
|
||||
# the same through the handler.
|
||||
#
|
||||
# Remove this file once proxy-1 follows and the role default moves to nftables.
|
||||
crowdsec_bouncer_mode: nftables
|
||||
@@ -8,6 +8,8 @@
|
||||
# pve_cluster (jupiter/neptune/saturn) run Talos on bare metal; no SSH at all
|
||||
# authentik_db / authentik_app authentik runs in-cluster (CNPG)
|
||||
# grafana runs in-cluster
|
||||
# synology_nas (nas-1) powered off; Garage and the PBS client
|
||||
# it hosted are gone with it
|
||||
# Their hosts no longer resolve, except the three that resolve to Talos nodes.
|
||||
|
||||
[netbird_server]
|
||||
@@ -17,10 +19,6 @@ netbird.renner.uno ansible_user=root
|
||||
proxy-1.renner.uno ansible_user=root
|
||||
proxy-2.renner.uno ansible_user=root
|
||||
|
||||
|
||||
[synology_nas]
|
||||
nas-1.renner.uno ansible_user=ladmin ansible_python_interpreter=/usr/bin/python3
|
||||
|
||||
# Every bare-metal edge thin client. Gets the host-generic roles only:
|
||||
# flash-wear handling, the log shipper, the MQTT bridge, the VLAN
|
||||
# sub-interface. Nothing here assumes any peripheral is attached.
|
||||
|
||||
@@ -1,313 +0,0 @@
|
||||
---
|
||||
# Synology-specific playbook using raw module (Python 3.8 workaround)
|
||||
# Deploys Garage (https://garagehq.deuxfleurs.fr) as a single-node S3 target
|
||||
# for K8s backups (Velero + CNPG Barman Cloud Plugin).
|
||||
#
|
||||
# Required secrets in host_vars/nas-1.renner.uno/vault.yml:
|
||||
# garage_rpc_secret: "<openssl rand -hex 32>"
|
||||
# garage_admin_token: "<openssl rand -base64 32>"
|
||||
# garage_metrics_token: "<openssl rand -base64 32>"
|
||||
#
|
||||
# Post-install bootstrap (run once, record the returned secret keys):
|
||||
# docker exec garaged /garage status
|
||||
# docker exec garaged /garage bucket create k8s-velero
|
||||
# docker exec garaged /garage bucket create k8s-cnpg
|
||||
# docker exec garaged /garage bucket create k8s-secrets
|
||||
# docker exec garaged /garage bucket create k8s-migration
|
||||
# docker exec garaged /garage key create velero-key
|
||||
# docker exec garaged /garage key create cnpg-key
|
||||
# docker exec garaged /garage key create secrets-key
|
||||
# docker exec garaged /garage key create migration-key
|
||||
# docker exec garaged /garage bucket allow --read --write --owner k8s-velero --key velero-key
|
||||
# docker exec garaged /garage bucket allow --read --write --owner k8s-cnpg --key cnpg-key
|
||||
# docker exec garaged /garage bucket allow --read --write --owner k8s-secrets --key secrets-key
|
||||
# docker exec garaged /garage bucket allow --read --write --owner k8s-migration --key migration-key
|
||||
- name: Install Garage S3 on Synology NAS
|
||||
hosts: synology_nas
|
||||
gather_facts: false
|
||||
|
||||
vars:
|
||||
garage_data_dir: "/volume1/docker/garage"
|
||||
|
||||
tasks:
|
||||
- name: Create Garage directory tree
|
||||
ansible.builtin.raw: |
|
||||
mkdir -p {{ garage_data_dir }}/config \
|
||||
{{ garage_data_dir }}/meta \
|
||||
{{ garage_data_dir }}/data
|
||||
changed_when: true
|
||||
|
||||
# Secret writes use the same compare-then-write pattern as garage.toml so
|
||||
# rotation is idempotent AND a real change can drive force-recreate below.
|
||||
# Garage reads these files once at startup; without container restart, a
|
||||
# rotated token leaves the running process holding the previous value.
|
||||
- name: Write rpc_secret
|
||||
ansible.builtin.raw: |
|
||||
cat > {{ garage_data_dir }}/config/rpc_secret.new << 'SECRET_EOF'
|
||||
{{ garage_rpc_secret }}
|
||||
SECRET_EOF
|
||||
chmod 600 {{ garage_data_dir }}/config/rpc_secret.new
|
||||
if [ ! -f {{ garage_data_dir }}/config/rpc_secret ] \
|
||||
|| ! cmp -s {{ garage_data_dir }}/config/rpc_secret.new {{ garage_data_dir }}/config/rpc_secret; then
|
||||
mv {{ garage_data_dir }}/config/rpc_secret.new {{ garage_data_dir }}/config/rpc_secret
|
||||
echo RPC_SECRET_CHANGED
|
||||
else
|
||||
rm {{ garage_data_dir }}/config/rpc_secret.new
|
||||
fi
|
||||
register: rpc_secret_write
|
||||
changed_when: "'RPC_SECRET_CHANGED' in rpc_secret_write.stdout"
|
||||
no_log: true
|
||||
|
||||
- name: Write admin_token
|
||||
ansible.builtin.raw: |
|
||||
cat > {{ garage_data_dir }}/config/admin_token.new << 'SECRET_EOF'
|
||||
{{ garage_admin_token }}
|
||||
SECRET_EOF
|
||||
chmod 600 {{ garage_data_dir }}/config/admin_token.new
|
||||
if [ ! -f {{ garage_data_dir }}/config/admin_token ] \
|
||||
|| ! cmp -s {{ garage_data_dir }}/config/admin_token.new {{ garage_data_dir }}/config/admin_token; then
|
||||
mv {{ garage_data_dir }}/config/admin_token.new {{ garage_data_dir }}/config/admin_token
|
||||
echo ADMIN_TOKEN_CHANGED
|
||||
else
|
||||
rm {{ garage_data_dir }}/config/admin_token.new
|
||||
fi
|
||||
register: admin_token_write
|
||||
changed_when: "'ADMIN_TOKEN_CHANGED' in admin_token_write.stdout"
|
||||
no_log: true
|
||||
|
||||
- name: Write metrics_token
|
||||
ansible.builtin.raw: |
|
||||
cat > {{ garage_data_dir }}/config/metrics_token.new << 'SECRET_EOF'
|
||||
{{ garage_metrics_token }}
|
||||
SECRET_EOF
|
||||
chmod 600 {{ garage_data_dir }}/config/metrics_token.new
|
||||
if [ ! -f {{ garage_data_dir }}/config/metrics_token ] \
|
||||
|| ! cmp -s {{ garage_data_dir }}/config/metrics_token.new {{ garage_data_dir }}/config/metrics_token; then
|
||||
mv {{ garage_data_dir }}/config/metrics_token.new {{ garage_data_dir }}/config/metrics_token
|
||||
echo METRICS_TOKEN_CHANGED
|
||||
else
|
||||
rm {{ garage_data_dir }}/config/metrics_token.new
|
||||
fi
|
||||
register: metrics_token_write
|
||||
changed_when: "'METRICS_TOKEN_CHANGED' in metrics_token_write.stdout"
|
||||
no_log: true
|
||||
|
||||
- name: Write garage.toml
|
||||
ansible.builtin.raw: |
|
||||
cat > {{ garage_data_dir }}/config/garage.toml.new << 'TOML_EOF'
|
||||
metadata_dir = "/var/lib/garage/meta"
|
||||
data_dir = "/var/lib/garage/data"
|
||||
|
||||
# SQLite chosen over LMDB: single-node deployment has no peers to
|
||||
# rebuild metadata from on unclean-shutdown corruption. SQLite is
|
||||
# slower but portable and has no LMDB corruption window.
|
||||
db_engine = "sqlite"
|
||||
|
||||
# 6-hour auto-snapshot of the metadata DB as a backstop against
|
||||
# corruption; last two snapshots retained.
|
||||
metadata_auto_snapshot_interval = "6h"
|
||||
|
||||
# Single-zone, single-node: only coherent replication factor.
|
||||
replication_factor = 1
|
||||
|
||||
rpc_bind_addr = "[::]:3901"
|
||||
rpc_secret_file = "/etc/garage/rpc_secret"
|
||||
|
||||
compression_level = 2
|
||||
|
||||
# 64 MB blocks (default 1 MB; previously 20 MB). On the 5 × 7 TB
|
||||
# Btrfs RAID 5 volume the per-block fixed cost (SQLite lookup,
|
||||
# dir traversal, hash verify, zstd decompress, nginx buffering)
|
||||
# is the dominant overhead per object; larger blocks amortize
|
||||
# it. block_size is a maximum, so 16 MiB CNPG WAL segments
|
||||
# still upload as one block each. Only applies to NEW uploads;
|
||||
# existing objects keep their original chunking until re-uploaded.
|
||||
block_size = "64M"
|
||||
|
||||
[s3_api]
|
||||
api_bind_addr = "[::]:3900"
|
||||
s3_region = "garage"
|
||||
root_domain = ".s3.{{ inventory_hostname }}"
|
||||
|
||||
[s3_web]
|
||||
bind_addr = "[::]:3902"
|
||||
root_domain = ".web.{{ inventory_hostname }}"
|
||||
|
||||
[admin]
|
||||
api_bind_addr = "[::]:3903"
|
||||
admin_token_file = "/etc/garage/admin_token"
|
||||
metrics_token_file = "/etc/garage/metrics_token"
|
||||
TOML_EOF
|
||||
if [ ! -f {{ garage_data_dir }}/config/garage.toml ] \
|
||||
|| ! cmp -s {{ garage_data_dir }}/config/garage.toml.new {{ garage_data_dir }}/config/garage.toml; then
|
||||
mv {{ garage_data_dir }}/config/garage.toml.new {{ garage_data_dir }}/config/garage.toml
|
||||
echo GARAGE_TOML_CHANGED
|
||||
else
|
||||
rm {{ garage_data_dir }}/config/garage.toml.new
|
||||
fi
|
||||
register: garage_toml_write
|
||||
changed_when: "'GARAGE_TOML_CHANGED' in garage_toml_write.stdout"
|
||||
|
||||
- name: Write docker-compose.yml
|
||||
ansible.builtin.raw: |
|
||||
cat > {{ garage_data_dir }}/docker-compose.yml << 'COMPOSE_EOF'
|
||||
services:
|
||||
garage:
|
||||
image: {{ garage_image }}
|
||||
container_name: garaged
|
||||
restart: unless-stopped
|
||||
# HTTP services (3900/3902/3903) bind to loopback; DSM
|
||||
# reverse proxy fronts and TLS-terminates them. RPC 3901
|
||||
# is exposed directly — the Noise handshake is raw TCP,
|
||||
# so DSM RP cannot front it. rpc_secret gates auth.
|
||||
ports:
|
||||
- "3901:3901"
|
||||
- "127.0.0.1:3900:3900"
|
||||
- "127.0.0.1:3902:3902"
|
||||
- "127.0.0.1:3903:3903"
|
||||
volumes:
|
||||
- {{ garage_data_dir }}/config/garage.toml:/etc/garage.toml:ro
|
||||
- {{ garage_data_dir }}/config/rpc_secret:/etc/garage/rpc_secret:ro
|
||||
- {{ garage_data_dir }}/config/admin_token:/etc/garage/admin_token:ro
|
||||
- {{ garage_data_dir }}/config/metrics_token:/etc/garage/metrics_token:ro
|
||||
- {{ garage_data_dir }}/meta:/var/lib/garage/meta
|
||||
- {{ garage_data_dir }}/data:/var/lib/garage/data
|
||||
COMPOSE_EOF
|
||||
changed_when: true
|
||||
|
||||
- name: Pull image and start Garage container
|
||||
ansible.builtin.shell: |
|
||||
export PATH=$PATH:/usr/local/bin
|
||||
cd {{ garage_data_dir }} && docker compose pull && docker compose up -d
|
||||
become: true
|
||||
changed_when: true
|
||||
|
||||
# Garage does not reload config on SIGHUP and compose does not notice
|
||||
# when a bind-mounted host file changes. Force-recreate the container
|
||||
# when garage.toml OR any of the three secret files changed so the new
|
||||
# config / rotated tokens are picked up.
|
||||
- name: Force-recreate garaged when garage.toml or secrets changed
|
||||
ansible.builtin.shell: |
|
||||
export PATH=$PATH:/usr/local/bin
|
||||
cd {{ garage_data_dir }} && docker compose up -d --force-recreate garage
|
||||
become: true
|
||||
when: garage_toml_write.changed
|
||||
or rpc_secret_write.changed
|
||||
or admin_token_write.changed
|
||||
or metrics_token_write.changed
|
||||
changed_when: true
|
||||
|
||||
- name: Wait for Garage to accept CLI calls
|
||||
ansible.builtin.shell: |
|
||||
export PATH=$PATH:/usr/local/bin
|
||||
for i in $(seq 1 30); do
|
||||
if docker exec garaged /garage status >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
exit 1
|
||||
become: true
|
||||
changed_when: false
|
||||
|
||||
- name: Check whether cluster layout is already applied
|
||||
ansible.builtin.shell: |
|
||||
export PATH=$PATH:/usr/local/bin
|
||||
docker exec garaged /garage status
|
||||
become: true
|
||||
register: garage_status
|
||||
changed_when: false
|
||||
|
||||
- name: Apply single-node layout (idempotent — only on first run)
|
||||
when: "'NO ROLE ASSIGNED' in garage_status.stdout"
|
||||
ansible.builtin.shell: |
|
||||
export PATH=$PATH:/usr/local/bin
|
||||
NODE_ID=$(docker exec garaged /garage node id | head -n 1 | cut -d@ -f1)
|
||||
docker exec garaged /garage layout assign "$NODE_ID" \
|
||||
-z {{ garage_zone }} \
|
||||
-c {{ garage_capacity }} \
|
||||
-t {{ inventory_hostname_short }}
|
||||
docker exec garaged /garage layout apply --version 1
|
||||
become: true
|
||||
changed_when: true
|
||||
|
||||
- name: Report Garage status
|
||||
ansible.builtin.shell: |
|
||||
export PATH=$PATH:/usr/local/bin
|
||||
docker exec garaged /garage status
|
||||
become: true
|
||||
register: garage_status_final
|
||||
changed_when: false
|
||||
|
||||
- name: Show Garage status
|
||||
ansible.builtin.debug:
|
||||
var: garage_status_final.stdout_lines
|
||||
|
||||
# Bootstrap buckets + keys. Idempotent: bucket_info / key_info return
|
||||
# non-zero when missing, so we only create on first run. Access-key
|
||||
# secrets are captured at create time and persisted to chmod-600 files
|
||||
# under {{ garage_data_dir }}/config/keys/<key>.creds on nas-1. These
|
||||
# files are the only post-install record of the secrets — Garage does
|
||||
# not expose them again.
|
||||
- name: Bootstrap Garage buckets, keys, and grants
|
||||
ansible.builtin.shell: |
|
||||
set -e
|
||||
export PATH=$PATH:/usr/local/bin
|
||||
KEYS_DIR={{ garage_data_dir }}/config/keys
|
||||
mkdir -p "$KEYS_DIR"
|
||||
chmod 700 "$KEYS_DIR"
|
||||
|
||||
bootstrap_pair() {
|
||||
local BUCKET="$1"
|
||||
local KEY="$2"
|
||||
local CREDS_FILE="$KEYS_DIR/$KEY.creds"
|
||||
|
||||
if ! docker exec garaged /garage bucket info "$BUCKET" >/dev/null 2>&1; then
|
||||
docker exec garaged /garage bucket create "$BUCKET"
|
||||
fi
|
||||
|
||||
if ! docker exec garaged /garage key info "$KEY" >/dev/null 2>&1; then
|
||||
CREATE_OUT=$(docker exec garaged /garage key create "$KEY")
|
||||
KEY_ID=$(echo "$CREATE_OUT" | awk '/^Key ID:/ {print $3}')
|
||||
SECRET=$(echo "$CREATE_OUT" | awk '/^Secret key:/ {print $3}')
|
||||
umask 177
|
||||
printf 'KEY_ID=%s\nSECRET_KEY=%s\n' "$KEY_ID" "$SECRET" > "$CREDS_FILE"
|
||||
fi
|
||||
|
||||
docker exec garaged /garage bucket allow \
|
||||
--read --write --owner \
|
||||
"$BUCKET" --key "$KEY"
|
||||
}
|
||||
|
||||
bootstrap_pair k8s-velero velero-key
|
||||
bootstrap_pair k8s-cnpg cnpg-key
|
||||
bootstrap_pair k8s-secrets secrets-key
|
||||
bootstrap_pair k8s-migration migration-key
|
||||
bootstrap_pair tfstate tfstate-key
|
||||
become: true
|
||||
changed_when: true
|
||||
no_log: true
|
||||
|
||||
- name: Report bucket + key state (no secrets)
|
||||
ansible.builtin.shell: |
|
||||
export PATH=$PATH:/usr/local/bin
|
||||
docker exec garaged /garage bucket list
|
||||
echo "---"
|
||||
docker exec garaged /garage key list
|
||||
echo "---"
|
||||
ls -l {{ garage_data_dir }}/config/keys/ 2>&1
|
||||
become: true
|
||||
register: garage_bootstrap_state
|
||||
changed_when: false
|
||||
|
||||
- name: Show bucket + key state
|
||||
ansible.builtin.debug:
|
||||
var: garage_bootstrap_state.stdout_lines
|
||||
|
||||
- name: Post-install note
|
||||
ansible.builtin.debug:
|
||||
msg:
|
||||
- "Buckets: k8s-velero, k8s-cnpg, k8s-secrets, k8s-migration, tfstate"
|
||||
- "Keys: velero-key, cnpg-key, secrets-key, migration-key, tfstate-key"
|
||||
- "Secrets are written to {{ garage_data_dir }}/config/keys/<key>.creds on nas-1 (chmod 600, root-owned)."
|
||||
- "SSH to nas-1 and `sudo cat` the file to retrieve KEY_ID + SECRET_KEY for Velero / CNPG / migration / tfstate config."
|
||||
@@ -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
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
---
|
||||
# Synology-specific playbook using raw module (Python 3.8 workaround)
|
||||
- name: Install PBS Client on Synology NAS
|
||||
hosts: synology_nas
|
||||
gather_facts: false
|
||||
|
||||
vars:
|
||||
pbs_client_data_dir: "/volume1/docker/pbs-client"
|
||||
|
||||
tasks:
|
||||
- name: Create PBS client directory
|
||||
ansible.builtin.raw: |
|
||||
mkdir -p {{ pbs_client_data_dir }}/state
|
||||
changed_when: true
|
||||
|
||||
- name: Create docker-compose.yml
|
||||
ansible.builtin.raw: |
|
||||
cat > {{ pbs_client_data_dir }}/docker-compose.yml << 'COMPOSE_EOF'
|
||||
services:
|
||||
pbs-client:
|
||||
image: proxmox/proxmox-backup-client:latest
|
||||
container_name: pbs-client
|
||||
restart: unless-stopped
|
||||
hostname: {{ pbs_backup_id }}
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- {{ pbs_client_data_dir }}/state:/root/.proxmox-backup
|
||||
{% for path in pbs_backup_paths %}
|
||||
- "{{ path }}:{{ path }}:ro"
|
||||
{% endfor %}
|
||||
mem_limit: 2g
|
||||
command: ["sleep", "infinity"]
|
||||
COMPOSE_EOF
|
||||
changed_when: true
|
||||
|
||||
- name: Create .env file
|
||||
ansible.builtin.raw: |
|
||||
cat > {{ pbs_client_data_dir }}/.env << 'ENV_EOF'
|
||||
PBS_REPOSITORY={{ pbs_username }}@{{ pbs_server_host }}:{{ pbs_server_port }}:{{ pbs_datastore }}
|
||||
PBS_PASSWORD={{ pbs_password }}
|
||||
ENV_EOF
|
||||
chmod 600 {{ pbs_client_data_dir }}/.env
|
||||
changed_when: true
|
||||
|
||||
- name: Create backup script
|
||||
ansible.builtin.raw: |
|
||||
cat > {{ pbs_client_data_dir }}/backup.sh << 'SCRIPT_EOF'
|
||||
#!/bin/bash
|
||||
set -e
|
||||
source {{ pbs_client_data_dir }}/.env
|
||||
|
||||
BACKUP_SOURCES=""
|
||||
{% for path in pbs_backup_paths %}
|
||||
BACKUP_SOURCES="$BACKUP_SOURCES {{ path | basename }}.pxar:{{ path }}"
|
||||
{% endfor %}
|
||||
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Starting backup for {{ pbs_backup_id }}"
|
||||
docker exec pbs-client proxmox-backup-client backup \
|
||||
$BACKUP_SOURCES \
|
||||
--backup-id {{ pbs_backup_id }} \
|
||||
--repository "${PBS_REPOSITORY}"
|
||||
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Backup completed, pruning..."
|
||||
docker exec pbs-client proxmox-backup-client prune \
|
||||
--repository "${PBS_REPOSITORY}" \
|
||||
--backup-id {{ pbs_backup_id }} \
|
||||
--keep-last {{ pbs_keep_last | default(3) }} \
|
||||
--keep-daily {{ pbs_keep_daily | default(7) }} \
|
||||
--keep-weekly {{ pbs_keep_weekly | default(4) }} \
|
||||
--keep-monthly {{ pbs_keep_monthly | default(6) }}
|
||||
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Done"
|
||||
SCRIPT_EOF
|
||||
chmod 755 {{ pbs_client_data_dir }}/backup.sh
|
||||
changed_when: true
|
||||
|
||||
- name: Start PBS client container
|
||||
ansible.builtin.shell: |
|
||||
export PATH=$PATH:/usr/local/bin
|
||||
cd {{ pbs_client_data_dir }} && docker compose pull && docker compose up -d
|
||||
become: true
|
||||
changed_when: true
|
||||
|
||||
- name: Setup cron job for daily backup at 2 AM (root crontab)
|
||||
ansible.builtin.shell: |
|
||||
# Remove existing entry if present
|
||||
crontab -l 2>/dev/null | grep -v "pbs-client/backup.sh" | crontab - 2>/dev/null || true
|
||||
# Add new entry
|
||||
(crontab -l 2>/dev/null; echo "0 2 * * * {{ pbs_client_data_dir }}/backup.sh >> /var/log/pbs-backup.log 2>&1") | crontab -
|
||||
become: true
|
||||
changed_when: true
|
||||
|
||||
- name: Verify container is running
|
||||
ansible.builtin.shell: |
|
||||
export PATH=$PATH:/usr/local/bin
|
||||
docker ps --filter name=pbs-client --format "{{ '{{' }}.Status{{ '}}' }}"
|
||||
become: true
|
||||
register: container_status
|
||||
changed_when: false
|
||||
|
||||
- name: Show container status
|
||||
ansible.builtin.debug:
|
||||
msg: "PBS client container status: {{ container_status.stdout }}"
|
||||
@@ -1,11 +0,0 @@
|
||||
---
|
||||
- name: Install PBS Client on Synology NAS
|
||||
hosts: synology_nas
|
||||
become: true
|
||||
# Variables are set in:
|
||||
# - Role defaults: ansible/roles/pbs-client/defaults/main.yml
|
||||
# - Host overrides: ansible/inventory/host_vars/<hostname>/vars.yml
|
||||
# - Secrets: ansible/inventory/host_vars/<hostname>/vault.yml
|
||||
|
||||
roles:
|
||||
- pbs-client
|
||||
@@ -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,32 +0,0 @@
|
||||
---
|
||||
# PBS Client configuration
|
||||
pbs_client_version: "latest"
|
||||
pbs_client_data_dir: "/opt/pbs-client"
|
||||
|
||||
# PBS Server connection (override in host_vars)
|
||||
pbs_server_host: "pbs.obg.renner.uno"
|
||||
pbs_server_port: 8007
|
||||
pbs_datastore: "backups"
|
||||
|
||||
# Authentication (override in vault!)
|
||||
pbs_username: "backup@pbs"
|
||||
pbs_password: ""
|
||||
# pbs_fingerprint: "" # Optional - omit for Let's Encrypt certs
|
||||
|
||||
# Backup configuration
|
||||
pbs_backup_id: "nas-1"
|
||||
pbs_backup_paths:
|
||||
- /volume1/docker
|
||||
- /volume1/homes
|
||||
|
||||
# Schedule (cron format)
|
||||
pbs_backup_schedule: "0 2 * * *" # 2 AM daily
|
||||
|
||||
# Retention policy (prune settings)
|
||||
pbs_keep_last: 3
|
||||
pbs_keep_daily: 7
|
||||
pbs_keep_weekly: 4
|
||||
pbs_keep_monthly: 6
|
||||
|
||||
# Container resource limits
|
||||
pbs_memory_limit: "2g"
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
- name: Restart PBS client
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: "{{ pbs_client_data_dir }}"
|
||||
state: restarted
|
||||
docker_cli: "{{ docker_cli | default('/usr/local/bin/docker') }}"
|
||||
@@ -1,23 +0,0 @@
|
||||
---
|
||||
- name: Create PBS client directories
|
||||
ansible.builtin.file:
|
||||
path: "{{ item }}"
|
||||
state: directory
|
||||
mode: '0755'
|
||||
loop:
|
||||
- "{{ pbs_client_data_dir }}"
|
||||
- "{{ pbs_client_data_dir }}/state"
|
||||
|
||||
- name: Copy PBS client docker-compose file
|
||||
ansible.builtin.template:
|
||||
src: docker-compose.yml.j2
|
||||
dest: "{{ pbs_client_data_dir }}/docker-compose.yml"
|
||||
mode: '0644'
|
||||
notify: Restart PBS client
|
||||
|
||||
- name: Start PBS client via Docker Compose
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: "{{ pbs_client_data_dir }}"
|
||||
state: present
|
||||
pull: always
|
||||
docker_cli: "{{ docker_cli | default('/usr/local/bin/docker') }}"
|
||||
@@ -1,6 +0,0 @@
|
||||
# PBS Client Environment Configuration
|
||||
PBS_REPOSITORY={{ pbs_username }}@{{ pbs_server_host }}:{{ pbs_server_port }}:{{ pbs_datastore }}
|
||||
PBS_PASSWORD={{ pbs_password }}
|
||||
{% if pbs_fingerprint | default('') != '' %}
|
||||
PBS_FINGERPRINT={{ pbs_fingerprint }}
|
||||
{% endif %}
|
||||
@@ -1,36 +0,0 @@
|
||||
#!/bin/bash
|
||||
# PBS Backup Script for {{ pbs_backup_id }}
|
||||
# Managed by Ansible - do not edit manually
|
||||
|
||||
set -e
|
||||
|
||||
# Load environment
|
||||
source {{ pbs_client_data_dir }}/.env
|
||||
|
||||
# Build backup command with all paths
|
||||
BACKUP_SOURCES=""
|
||||
{% for path in pbs_backup_paths %}
|
||||
BACKUP_SOURCES="$BACKUP_SOURCES {{ path | basename }}.pxar:{{ path }}"
|
||||
{% endfor %}
|
||||
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Starting backup for {{ pbs_backup_id }}"
|
||||
|
||||
# Run backup via Docker
|
||||
docker exec pbs-client proxmox-backup-client backup \
|
||||
$BACKUP_SOURCES \
|
||||
--backup-id {{ pbs_backup_id }} \
|
||||
--repository "${PBS_REPOSITORY}"
|
||||
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Backup completed"
|
||||
|
||||
# Prune old backups
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Pruning old backups"
|
||||
docker exec pbs-client proxmox-backup-client prune \
|
||||
--repository "${PBS_REPOSITORY}" \
|
||||
--backup-id {{ pbs_backup_id }} \
|
||||
--keep-last {{ pbs_keep_last }} \
|
||||
--keep-daily {{ pbs_keep_daily }} \
|
||||
--keep-weekly {{ pbs_keep_weekly }} \
|
||||
--keep-monthly {{ pbs_keep_monthly }}
|
||||
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Prune completed"
|
||||
@@ -1,30 +0,0 @@
|
||||
services:
|
||||
pbs-client:
|
||||
image: aterfax/pbs-client:{{ pbs_client_version }}
|
||||
container_name: pbs-client
|
||||
restart: unless-stopped
|
||||
hostname: {{ pbs_backup_id }}
|
||||
environment:
|
||||
- PBS_ENDPOINT={{ pbs_server_host }}:{{ pbs_server_port }}
|
||||
- PBS_DATASTORE={{ pbs_datastore }}
|
||||
- PBS_API_KEY_NAME={{ pbs_username }}
|
||||
- PBS_API_KEY_SECRET={{ pbs_password }}
|
||||
- PBS_PASSWORD={{ pbs_password }}
|
||||
- PBS_ENCRYPTION_PASSWORD={{ pbs_encryption_password | default(pbs_password) }}
|
||||
- PBS_REPOSITORY={{ pbs_username }}@{{ pbs_server_host }}:{{ pbs_server_port }}:{{ pbs_datastore }}
|
||||
- PBS_BACKUP_ID={{ pbs_backup_id }}
|
||||
- CRON_SCHEDULE={{ pbs_backup_schedule }}
|
||||
- PBS_BACKUP_CMD_APPEND_EXTRA_OPTS=--crypt-mode=none
|
||||
- PRUNE_KEEP_LAST={{ pbs_keep_last | default(3) }}
|
||||
- PRUNE_KEEP_DAILY={{ pbs_keep_daily | default(7) }}
|
||||
- PRUNE_KEEP_WEEKLY={{ pbs_keep_weekly | default(4) }}
|
||||
- PRUNE_KEEP_MONTHLY={{ pbs_keep_monthly | default(6) }}
|
||||
- CRON_BACKUP_ONLY=1
|
||||
volumes:
|
||||
- {{ pbs_client_data_dir }}/state:/root/.config/proxmox-backup
|
||||
{% for path in pbs_backup_paths %}
|
||||
- "{{ path }}:/backups/{{ path | basename | replace(' ', '_') }}:ro"
|
||||
{% endfor %}
|
||||
tmpfs:
|
||||
- /tmp
|
||||
mem_limit: {{ pbs_memory_limit }}
|
||||
@@ -0,0 +1,111 @@
|
||||
# Cilium: one owner
|
||||
|
||||
## The rule
|
||||
|
||||
Cilium is owned by **Flux**, through `infrastructure/cilium/helmrelease.yaml`.
|
||||
Talos provides the conditions for a CNI and nothing else. Never both.
|
||||
|
||||
Talos-only would also be a valid answer — it is the simpler one, and it keeps a
|
||||
cold rebuild self-forming. It is rejected here only because it costs Flux's
|
||||
reconciliation, image automation and value ergonomics for the one component
|
||||
most likely to need a careful, reviewed change.
|
||||
|
||||
## Where we are
|
||||
|
||||
Both own it, which is the state this document exists to end.
|
||||
|
||||
- 22 Cilium objects are listed in the `talos-bootstrap-manifests-inventory`
|
||||
ConfigMap: both DaemonSets, the operator Deployment, both ConfigMaps, the
|
||||
ServiceAccounts, the ClusterRoles/Bindings and the `cilium-secrets`
|
||||
namespace. That is the whole installation, not a fragment.
|
||||
- The same objects carry `meta.helm.sh/release-name: cilium`.
|
||||
- `cilium-config` has three field managers: `helm-controller`, `talos` and
|
||||
`kubectl-patch`.
|
||||
|
||||
The consequences are not theoretical. The seed sat at Cilium 1.19.2 for twelve
|
||||
days while the chart was on 1.20.1, and `upgrade-k8s --dry-run` shows that a
|
||||
Kubernetes upgrade in that state downgrades the CNI and switches off socket
|
||||
load balancing. Separately, `enable-bgp-control-plane` and the two
|
||||
native-routing CIDRs exist in no file at all — applied by hand, so a cold
|
||||
rebuild would have come up with BGP off while the peering CRDs in git made it
|
||||
look configured.
|
||||
|
||||
Nothing fails when these drift. That is the whole problem.
|
||||
|
||||
## Target state
|
||||
|
||||
**Talos** keeps exactly this, in `talos/patches/cluster.yaml`, and gains nothing
|
||||
else:
|
||||
|
||||
```yaml
|
||||
cluster:
|
||||
network:
|
||||
cni:
|
||||
name: none
|
||||
proxy:
|
||||
disabled: true
|
||||
```
|
||||
|
||||
No `inlineManifests` entry for cilium. `talos/patches/cilium-cni.yaml` is
|
||||
deleted.
|
||||
|
||||
**Flux** owns everything else. `helmrelease.yaml` is the single source for
|
||||
every Cilium setting, including the three that were hand-patched.
|
||||
|
||||
**A cold rebuild** installs Cilium once, by hand, between `talosctl bootstrap`
|
||||
and the Flux phase, from the same HelmRelease values. This is the cost of the
|
||||
decision and it is deliberate: the cluster no longer forms itself from the
|
||||
machine config alone. Given `bootstrap.sh talos` is already dead and Step 2 of
|
||||
RUNBOOK-cold-rebuild.md is already a manual procedure, the marginal cost is one
|
||||
documented command.
|
||||
|
||||
## Transition
|
||||
|
||||
The order matters, because Talos prunes what its inventory used to list and no
|
||||
longer does — removing the manifest naively deletes the CNI.
|
||||
|
||||
1. **Track the untracked settings first.** `bgpControlPlane.enabled`,
|
||||
`ipv4NativeRoutingCIDR`, `ipv6NativeRoutingCIDR` into the HelmRelease. Note
|
||||
this is not a pure change of ownership: enabling bgpControlPlane through the
|
||||
chart also emits five companion keys the hand-patch never set, so Cilium has
|
||||
been running on its own defaults for router-id allocation. Verify the BGP
|
||||
sessions after Flux applies it.
|
||||
|
||||
2. **Remove cilium from `cluster.inlineManifests`.** This has no immediate
|
||||
effect. Evidence: Talos was upgraded to 1.13.9 four days ago and Cilium
|
||||
stayed at 1.20.1 — an OS upgrade does not reconcile bootstrap manifests.
|
||||
Only `upgrade-k8s` does.
|
||||
|
||||
3. **Prove the prune before running it.** `upgrade-k8s --dry-run
|
||||
--manifests-no-prune` and confirm the 22 objects are not listed for
|
||||
deletion. `NoPrune` is honoured by the dry run as well as the apply
|
||||
(`pkg/cluster/kubernetes/talos_managed.go`), so this is a real test and not
|
||||
a reading of the flag description.
|
||||
|
||||
4. **Run `upgrade-k8s --manifests-no-prune`.** The inventory is rewritten
|
||||
without Cilium and the objects survive. Helm is then sole owner. This is the
|
||||
same run that takes Kubernetes 1.35.2 → 1.36.3.
|
||||
|
||||
5. **Verify** the inventory lists no Cilium, the agents did not restart, and
|
||||
the BGP sessions are still established.
|
||||
|
||||
6. **Delete `talos/patches/cilium-cni.yaml`** and add the install step to
|
||||
RUNBOOK-cold-rebuild.md.
|
||||
|
||||
## Not part of this, and not a defect either
|
||||
|
||||
The cluster runs `routingMode: tunnel` (the chart default, set nowhere) while
|
||||
also advertising pod CIDRs over BGP. That is not one layer too many — the two
|
||||
carry different directions:
|
||||
|
||||
- **VXLAN** carries pod-to-pod between nodes, directly, node to node.
|
||||
- **BGP** gives the UDM routes into the pod CIDRs so traffic from the LAN can
|
||||
reach pods. That path goes through the router by definition.
|
||||
|
||||
Dropping the encapsulation in favour of native routing would not simplify
|
||||
anything; it would push inter-node pod traffic onto the underlay path and
|
||||
through the UDM, replacing a direct node-to-node hop with a router hop.
|
||||
|
||||
`routingMode` being unset is still worth knowing — the current behaviour is a
|
||||
default nobody chose rather than a decision anyone recorded — but the default
|
||||
is the right one here, and this document is not the place to revisit it.
|
||||
@@ -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
|
||||
@@ -198,8 +203,7 @@ talosctl gen secrets --from-controlplane-config cp.yaml -o secrets.yaml
|
||||
talosctl gen config homelab https://k8s.renner.uno:6443 \
|
||||
--with-secrets secrets.yaml --output-types controlplane \
|
||||
--config-patch @talos/patches/cluster.yaml \
|
||||
--config-patch @<cilium additive half> \
|
||||
--config-patch @talos/patches/registry-mirror.yaml \
|
||||
--config-patch @talos/patches/containerd-layer-retention.yaml \
|
||||
--config-patch @talos/patches/k8s-N.yaml -o install.yaml
|
||||
```
|
||||
|
||||
@@ -208,9 +212,11 @@ Confirm `cluster.id` matches the running cluster before applying.
|
||||
Strip the generated `HostnameConfig` document — it collides with
|
||||
`machine.network.hostname` from the per-node patch. `bootstrap.sh` does the same.
|
||||
|
||||
`cilium-cni.yaml` cannot be passed whole: its `$patch: delete` document fails
|
||||
against a base with no `inlineManifests` (issue #581). Use only the additive
|
||||
second document.
|
||||
There is no CNI patch to pass any more. Talos supplies only `cni: none` and
|
||||
`proxy: disabled`; Cilium is installed from its HelmRelease values, which is
|
||||
Step 2b of RUNBOOK-cold-rebuild.md. A node converted into an existing cluster
|
||||
gets its CNI from the Cilium DaemonSet already running there, so nothing extra
|
||||
is needed here.
|
||||
|
||||
Validate:
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -136,11 +138,18 @@ in `talos/patches/k8s-N.yaml`.
|
||||
|
||||
## Step 2 — talos: form the cluster + get kubeconfig
|
||||
|
||||
**Do not use `kubernetes/bootstrap.sh talos`.** That phase cannot generate a
|
||||
config: it passes `talos/patches/cilium-cni.yaml` whole, and that file's
|
||||
`$patch: delete` document fails against a base with no `inlineManifests`
|
||||
(#581). Its other phases — `flux`, `wait-apps` — are still used, in Steps 3, 5
|
||||
and 6; only config generation is broken.
|
||||
`kubernetes/bootstrap.sh talos` does this, including the CNI install that used
|
||||
to be a hand-typed step. It was unusable for months because it passed
|
||||
`cilium-cni.yaml` whole and the file's `$patch: delete` document failed against
|
||||
a fresh config (#581); that file no longer exists, so the failure is gone with
|
||||
it.
|
||||
|
||||
Verified as far as it can be without wiping a cluster: `talosctl gen config`
|
||||
with exactly the patches the script passes produces a config carrying
|
||||
`cni: none` and `proxy: disabled` that passes `talosctl validate --mode metal`.
|
||||
The apply-and-bootstrap half has not been exercised since the rewrite. Read the
|
||||
manual procedure below before running the script, so you know what it is doing
|
||||
and can take over at any step if it stops.
|
||||
|
||||
The procedure below is what actually works, and is the same one
|
||||
`RUNBOOK-baremetal-conversion.md` uses.
|
||||
@@ -160,17 +169,17 @@ for N in 1 2 3; do
|
||||
talosctl gen config homelab https://k8s.renner.uno:6443 \
|
||||
--with-secrets secrets.yaml --output-types controlplane \
|
||||
--config-patch @talos/patches/cluster.yaml \
|
||||
--config-patch @<cilium additive half> \
|
||||
--config-patch @talos/patches/registry-mirror.yaml \
|
||||
--config-patch @talos/patches/containerd-layer-retention.yaml \
|
||||
--config-patch @talos/patches/k8s-$N.yaml -o install-k8s-$N.yaml
|
||||
done
|
||||
```
|
||||
|
||||
Two things the generator gets wrong for this repo, both of which will bite
|
||||
silently:
|
||||
There is no CNI patch here any more. Talos supplies `cni: none` and
|
||||
`proxy: disabled` from `cluster.yaml` and nothing else; Cilium is installed in
|
||||
Step 2b below. See PROPOSAL-cilium-single-owner.md.
|
||||
|
||||
One thing the generator gets wrong for this repo, and it bites silently:
|
||||
|
||||
- **`cilium-cni.yaml` cannot be passed whole** — extract only its additive
|
||||
second document. See issue #581.
|
||||
- **Strip the generated `HostnameConfig`** — it collides with
|
||||
`machine.network.hostname` from the per-node patch.
|
||||
|
||||
@@ -209,8 +218,53 @@ talosctl -n k8s-1.srv.renner.uno bootstrap
|
||||
talosctl -n k8s-1.srv.renner.uno kubeconfig .
|
||||
```
|
||||
|
||||
Ends when all three nodes are `Ready`. `scripts/import-cluster-access.sh` is the
|
||||
supported way to get the kubeconfig into place afterwards.
|
||||
`scripts/import-cluster-access.sh` is the supported way to get the kubeconfig
|
||||
into place afterwards.
|
||||
|
||||
The nodes will **not** reach `Ready` here. Talos is configured with `cni: none`,
|
||||
so there is no pod network until Step 2b — expect `NotReady` with
|
||||
`container runtime network not ready` and carry on.
|
||||
|
||||
## Step 2b — cilium: install the CNI
|
||||
|
||||
Nothing schedules without it, Flux included, so this comes before Step 3. Talos
|
||||
no longer carries a rendered copy of Cilium; `helmrelease.yaml` is the only
|
||||
place it is configured, and this installs from those same values so the cluster
|
||||
comes up on what git describes rather than on a second, drifting copy. See
|
||||
PROPOSAL-cilium-single-owner.md for why it works this way.
|
||||
|
||||
`bootstrap.sh talos` already did this. What follows is the same thing by hand,
|
||||
for when the script stopped partway or you are not using it.
|
||||
|
||||
Every input is read out of `helmrelease.yaml`. Nothing here is a version or a
|
||||
URL you have to keep in step with anything — copy the block as it stands:
|
||||
|
||||
```sh
|
||||
HR=kubernetes/infrastructure/cilium/helmrelease.yaml
|
||||
|
||||
helm repo add cilium "$(yq -r 'select(.kind == "HelmRepository") | .spec.url' "$HR")"
|
||||
helm repo update cilium
|
||||
|
||||
yq 'select(.kind == "HelmRelease") | .spec.values' "$HR" > /tmp/cilium-values.yaml
|
||||
|
||||
helm install cilium cilium/cilium \
|
||||
--version "$(yq -r 'select(.kind == "HelmRelease") | .spec.chart.spec.version' "$HR")" \
|
||||
--namespace kube-system \
|
||||
-f /tmp/cilium-values.yaml \
|
||||
--set prometheus.serviceMonitor.enabled=false
|
||||
```
|
||||
|
||||
`--version` takes the HelmRelease's range verbatim; helm resolves it to the same
|
||||
release Flux would pick, so the two cannot diverge. That is the point of writing
|
||||
it this way — a pinned version in a runbook is a copy nobody maintains, and the
|
||||
next rebuild installs something the HelmRelease will immediately fight.
|
||||
|
||||
The one literal is `serviceMonitor`, disabled for this install only: its CRD
|
||||
arrives with the monitoring stack much later and the object would fail to apply.
|
||||
Flux adds the real one once it owns the release. Nothing else is overridden,
|
||||
which is what lets helm-controller adopt this release rather than replace it.
|
||||
|
||||
Ends when all three nodes are `Ready`.
|
||||
|
||||
## Step 3 — flux: bootstrap GitOps **from the GitHub mirror** (break-glass)
|
||||
|
||||
|
||||
@@ -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,7 @@ 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
|
||||
|
||||
@@ -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
|
||||
@@ -1,31 +0,0 @@
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: file
|
||||
namespace: apps
|
||||
labels:
|
||||
app.kubernetes.io/name: file
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-cluster
|
||||
# Backend serves HTTPS with a self-signed cert; re-encrypt + skip
|
||||
# verify via the shared insecure-backend ServersTransport (see
|
||||
# apps/insecure-backend-serverstransport.yaml).
|
||||
traefik.ingress.kubernetes.io/service.serversscheme: https
|
||||
traefik.ingress.kubernetes.io/service.serverstransport: insecure-backend@kubernetescrd
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
rules:
|
||||
- host: file.renner.uno
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: file
|
||||
port:
|
||||
number: 7001
|
||||
tls:
|
||||
- secretName: file-tls
|
||||
hosts:
|
||||
- file.renner.uno
|
||||
@@ -1,18 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: file
|
||||
namespace: apps
|
||||
labels:
|
||||
app.kubernetes.io/name: file
|
||||
spec:
|
||||
# Caddy reverse-proxied https://nas.renner.uno:7001. nas.renner.uno
|
||||
# is a CNAME -> nas-1.renner.uno owned by UniFi DNS, k8s-gateway
|
||||
# forwards srv lookups so this resolves cluster-side.
|
||||
type: ExternalName
|
||||
externalName: nas.renner.uno
|
||||
ports:
|
||||
- name: https
|
||||
port: 7001
|
||||
targetPort: 7001
|
||||
protocol: TCP
|
||||
@@ -18,15 +18,18 @@ metadata:
|
||||
#
|
||||
# Opaque-label map (maintainer reference only — NOT served publicly):
|
||||
# services group infrastructure group
|
||||
# Web Service 1 = Authentik Infrastructure 1 = PBS 2 (pbs2.renner.uno)
|
||||
# Web Service 1 = Authentik Infrastructure 1 = retired (was PBS 2)
|
||||
# Web Service 2 = Gitea Infrastructure 2 = retired (was Proxmox VE)
|
||||
# Web Service 3 = Immich Infrastructure 3 = UDM DNS (10.2.40.1)
|
||||
# Web Service 4 = Woodpecker CI Infrastructure 4 = Mosquitto MQTT broker
|
||||
# Web Service 5 = Grafana
|
||||
# Web Service 6 = Home Assistant
|
||||
# Web Service 7 = Paperless
|
||||
# Web Service 7 = retired (was the NAS file UI; never Paperless, which
|
||||
# this map claimed and which nothing here checks)
|
||||
# Web Service 8 = Audiobookshelf
|
||||
# Web Service 9 = Music Assistant
|
||||
# Web Service 10 = TRMNL
|
||||
# Web Service 11 = Paperless (gate-level only, see its entry)
|
||||
data:
|
||||
config.yaml: |
|
||||
storage:
|
||||
@@ -121,18 +124,10 @@ data:
|
||||
conditions:
|
||||
- "[STATUS] == any(200, 301, 302)"
|
||||
|
||||
- name: Web Service 7
|
||||
group: services
|
||||
url: https://file.renner.uno
|
||||
interval: 60s
|
||||
ui:
|
||||
hide-hostname: true
|
||||
hide-url: true
|
||||
hide-port: true
|
||||
hide-conditions: true
|
||||
hide-errors: true
|
||||
conditions:
|
||||
- "[STATUS] == any(200, 301, 302)"
|
||||
# Removed 2026-08-28: the NAS file UI (file.renner.uno). Its Ingress and
|
||||
# ExternalName Service were deleted in #696 because they pointed at
|
||||
# nas-1, which is powered off; this check outlived them and had been
|
||||
# reporting failure on the public page since.
|
||||
|
||||
- name: Web Service 8
|
||||
group: services
|
||||
@@ -178,6 +173,29 @@ data:
|
||||
conditions:
|
||||
- "[STATUS] == any(200, 301, 302)"
|
||||
|
||||
# Paperless sits entirely behind the Authentik gate — every path,
|
||||
# including /api/, answers 302 to unauthenticated callers, so there is no
|
||||
# endpoint that proves the app rather than the gate. This check is
|
||||
# therefore reachability only: DNS, TLS, Traefik and the outpost. It
|
||||
# stays green if paperless itself is dead behind a live gate.
|
||||
#
|
||||
# Closing that blind spot needs an in-cluster probe of the paperless
|
||||
# ClusterIP, which crosses a namespace boundary and so needs a
|
||||
# CiliumNetworkPolicy first — the monitoring probes get away without one
|
||||
# only because their targets share a namespace with the prober.
|
||||
- name: Web Service 11
|
||||
group: services
|
||||
url: https://paperless.renner.uno
|
||||
interval: 60s
|
||||
ui:
|
||||
hide-hostname: true
|
||||
hide-url: true
|
||||
hide-port: true
|
||||
hide-conditions: true
|
||||
hide-errors: true
|
||||
conditions:
|
||||
- "[STATUS] == any(200, 301, 302)"
|
||||
|
||||
# Removed 2026-05-21: Jellyfin (media.renner.uno), Wiki (wiki.renner.uno),
|
||||
# InfluxDB (influx.renner.uno) — hostnames NXDOMAIN, no Ingress; services
|
||||
# no longer deployed. Re-add when/if they come back.
|
||||
@@ -191,20 +209,11 @@ data:
|
||||
# NoData). Monitor the offsite PBS from the offsite side or via a
|
||||
# check that doesn't depend on this cluster's DNS path.
|
||||
|
||||
- name: Infrastructure 1
|
||||
group: infrastructure
|
||||
url: https://pbs2.renner.uno:8007
|
||||
interval: 60s
|
||||
client:
|
||||
insecure-skip-verify: true
|
||||
ui:
|
||||
hide-hostname: true
|
||||
hide-url: true
|
||||
hide-port: true
|
||||
hide-conditions: true
|
||||
hide-errors: true
|
||||
conditions:
|
||||
- "[STATUS] == any(200, 301, 302)"
|
||||
# Removed 2026-08-28: PBS 2 (pbs2.renner.uno:8007) — it has no clients
|
||||
# left. Its two were the PVE cluster, retired to bare-metal Talos, and
|
||||
# nas-1, powered off and dropped from the repo. Checking the liveness of
|
||||
# a backup server that nothing backs up to reports on nothing. Re-add
|
||||
# alongside the first host that is declared to back up to it.
|
||||
|
||||
- name: Infrastructure 3
|
||||
group: infrastructure
|
||||
|
||||
@@ -5,6 +5,12 @@ metadata:
|
||||
namespace: apps
|
||||
labels:
|
||||
app: gatus
|
||||
annotations:
|
||||
# Gatus reads its config once at startup and never re-reads the file, so
|
||||
# without this a ConfigMap change reconciles cleanly and then does nothing:
|
||||
# git and the running checks drift apart silently, with no failed resource
|
||||
# to notice. Adding a check would not take effect either.
|
||||
configmap.reloader.stakater.com/reload: gatus-config
|
||||
spec:
|
||||
replicas: 1
|
||||
# RWO PVC (`gatus-data`, ceph-block) — RollingUpdate's default 25% surge
|
||||
@@ -22,9 +28,30 @@ spec:
|
||||
labels:
|
||||
app: gatus
|
||||
spec:
|
||||
# The upstream image is FROM scratch with no USER, so without this the
|
||||
# pod runs as root and trips PodSecurity restricted on four counts.
|
||||
#
|
||||
# The uid is numeric because a scratch image carries no /etc/passwd for
|
||||
# the kubelet to resolve a name against. fsGroup is what makes the switch
|
||||
# survivable: the only write path is the sqlite store on the RWO volume
|
||||
# (/data/gatus.db, see the storage stanza in the ConfigMap), and fsGroup
|
||||
# both chowns the existing volume and leaves the directory group-writable
|
||||
# so sqlite can create its -wal and -journal siblings next to the db.
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 65532
|
||||
runAsGroup: 65532
|
||||
fsGroup: 65532
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: gatus
|
||||
image: twinproduction/gatus:v5.36.0 # {"$imagepolicy": "flux-system:gatus"}
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
name: http
|
||||
|
||||
@@ -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:
|
||||
@@ -210,6 +209,28 @@ spec:
|
||||
- name: vscode
|
||||
containerPort: 8080
|
||||
protocol: TCP
|
||||
# /healthz answers 200 whenever the HTTP server is up; its body
|
||||
# reports session heartbeat state, which is not health — an idle
|
||||
# editor reports "expired" and is perfectly fine. So this asserts
|
||||
# that code-server is serving, not that anyone is using it.
|
||||
#
|
||||
# Liveness matters here more than elsewhere in this pod: code-server
|
||||
# shares the Home Assistant config volume, and a wedged editor is
|
||||
# otherwise invisible until someone tries to open it.
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: vscode
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 5
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: vscode
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /config
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
# Shared Traefik ServersTransport for ingresses that re-encrypt to a
|
||||
# backend serving HTTPS with a self-signed / hostname-mismatched cert:
|
||||
#
|
||||
# apps/file — NAS web UI on a self-signed cert
|
||||
#
|
||||
# Replaces the nginx `backend-protocol: HTTPS` + `proxy-ssl-verify: off`
|
||||
# annotation pair. Each Ingress that wants this behaviour sets
|
||||
# traefik.ingress.kubernetes.io/service.serversscheme: https
|
||||
# traefik.ingress.kubernetes.io/service.serverstransport: insecure-backend
|
||||
#
|
||||
# (bare name — kubernetesIngress provider resolves ServersTransport in
|
||||
# the same namespace as the Ingress; the `<ns>-<name>@kubernetescrd`
|
||||
# qualified form is for IngressRoute CRDs only).
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: ServersTransport
|
||||
metadata:
|
||||
name: insecure-backend
|
||||
namespace: apps
|
||||
spec:
|
||||
insecureSkipVerify: true
|
||||
@@ -1,10 +1,8 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- insecure-backend-serverstransport.yaml
|
||||
- gatus/
|
||||
- external-dns/
|
||||
- file/
|
||||
- gitea/
|
||||
- ical-proxy/
|
||||
# woodpecker/ moved to its own dedicated `woodpecker` Kustomization
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
---
|
||||
# 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"
|
||||
# 32k because Home Assistant's Assist agent measured 18910 tokens
|
||||
# for a single request: ~12k of exposed-entity overview plus ~7k of
|
||||
# base instructions and tool schemas, whose parameters enumerate
|
||||
# every exposed name, area and floor. At 16384 the request was
|
||||
# refused outright.
|
||||
#
|
||||
# --ctx-size is per slot, not the total: /props reports
|
||||
# n_ctx_slot equal to this value while running four slots, where a
|
||||
# total would have divided down to 4096. Doubling it alone would
|
||||
# therefore double the KV cache, so the slot count halves in step
|
||||
# and the allocation stays where it is today — 24979 MiB of the
|
||||
# 32Gi limit, most of it the 19.5 GiB of weights.
|
||||
#
|
||||
# Two slots is what is actually in use: one holds Home Assistant's
|
||||
# prompt, one holds Paperless's. A third caller would queue rather
|
||||
# than evict either, which is the better failure mode given a cold
|
||||
# prefill of this prompt costs around two minutes.
|
||||
- --ctx-size
|
||||
- "32768"
|
||||
- --parallel
|
||||
- "2"
|
||||
# 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
|
||||
@@ -0,0 +1,36 @@
|
||||
# Scrapes CrowdSec. Until this landed, nothing did: `up{namespace="crowdsec"}`
|
||||
# returned zero series and Prometheus held no cs_* metric at all, so the
|
||||
# detection side of the ingress path was invisible — including whether the
|
||||
# agents were reading logs and whether any bouncer was pulling decisions.
|
||||
#
|
||||
# One monitor for both services. They carry the same shape (a `metrics` port on
|
||||
# 6060) and differ only in `app`, so a matchExpressions selector keeps this to
|
||||
# a single object rather than two near-identical ones:
|
||||
#
|
||||
# crowdsec-service the LAPI — decisions, bouncer + machine requests
|
||||
# crowdsec-agent-service the agents — acquisition, parsers, scenarios
|
||||
#
|
||||
# The crowdsec-ns CiliumNetworkPolicy already admits the monitoring namespace
|
||||
# without a port restriction, so no policy change is needed.
|
||||
#
|
||||
# `release: kube-prometheus-stack` is what the chart's Prometheus selects on.
|
||||
apiVersion: monitoring.coreos.com/v1
|
||||
kind: ServiceMonitor
|
||||
metadata:
|
||||
name: crowdsec
|
||||
namespace: crowdsec
|
||||
labels:
|
||||
app.kubernetes.io/name: crowdsec
|
||||
release: kube-prometheus-stack
|
||||
spec:
|
||||
selector:
|
||||
matchExpressions:
|
||||
- key: app
|
||||
operator: In
|
||||
values:
|
||||
- crowdsec-service
|
||||
- crowdsec-agent-service
|
||||
endpoints:
|
||||
- port: metrics
|
||||
path: /metrics
|
||||
interval: 30s
|
||||
@@ -4,14 +4,15 @@ kind: Kustomization
|
||||
# come from the kube-prometheus-stack chart. Flux applies this Kustomization
|
||||
# only after `monitoring` is Ready (chart installed → CRDs present).
|
||||
resources:
|
||||
- prometheusrule-blackbox.yaml
|
||||
- prometheusrule-ceph.yaml
|
||||
- prometheusrule-ci-openbao.yaml
|
||||
- prometheusrule-cilium.yaml
|
||||
- prometheusrule-disk-wear.yaml
|
||||
- prometheusrule-hetzner-backup.yaml
|
||||
- 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
|
||||
@@ -22,6 +23,7 @@ resources:
|
||||
- prometheusrule-cnpg-backup.yaml
|
||||
- alloy-servicemonitor.yaml
|
||||
- cnpg-podmonitors.yaml
|
||||
- crowdsec-servicemonitor.yaml
|
||||
- home-assistant-servicemonitor.yaml
|
||||
- immich-servicemonitor.yaml
|
||||
- loki-servicemonitor.yaml
|
||||
|
||||
@@ -49,6 +49,28 @@ spec:
|
||||
# ports outside the hairpin's 80/443 — real WAN path.
|
||||
- http://proxy-1.renner.uno:9000/health
|
||||
- http://proxy-2.renner.uno:9000/health
|
||||
# Woodpecker. Ungated, root returns 200 with no redirect. Every
|
||||
# scheduled job in this repo runs here — the alert-monitor cron, the
|
||||
# nightly scans, the deploy targets — so an outage here is silent by
|
||||
# construction: the thing that would have told you is the thing that
|
||||
# is down.
|
||||
- https://ci.renner.uno
|
||||
# OpenBao. Probes the health API rather than the UI on purpose: a
|
||||
# SEALED instance still serves the UI (307 -> /ui/ -> 200) while every
|
||||
# ExternalSecret in the cluster fails, which is exactly the
|
||||
# green-while-broken shape this file warns about elsewhere.
|
||||
#
|
||||
# standbyok=true because the endpoint answers 200 only on the ACTIVE
|
||||
# node and 429 on a standby — the external route currently lands on a
|
||||
# standby, so the bare path would be permanently red. With the
|
||||
# parameter, 200 means initialized, unsealed and serving, on whichever
|
||||
# node answers; 503 sealed and 501 uninitialized both stay red.
|
||||
- https://openbao.renner.uno/v1/sys/health?standbyok=true
|
||||
# ical-proxy serves an unauthenticated /health that answers 200 with
|
||||
# {"status":"healthy"}. Unlike the gated hostnames this is real app
|
||||
# health, not reachability — the root path returns 404 by design, since
|
||||
# the service only answers on calendar paths.
|
||||
- https://ical-proxy.renner.uno/health
|
||||
# APP-HEALTH probes for the gated monitoring UIs, from the correct
|
||||
# in-cluster vantage (monitoring-vantage-point-rule): the gate is
|
||||
# only on the ingress, so the ClusterIP /-/healthy endpoints are
|
||||
@@ -61,6 +83,11 @@ spec:
|
||||
# external reachability probe above; it is not the health vantage.)
|
||||
- http://kube-prometheus-stack-alertmanager.monitoring.svc.cluster.local:9093/-/healthy
|
||||
- http://kube-prometheus-stack-prometheus.monitoring.svc.cluster.local:9090/-/healthy
|
||||
# Loki has no external vantage at all: its ingress requires auth on
|
||||
# every path, so an outside probe can only ever report 401. In-cluster
|
||||
# /ready answers 200 unauthenticated and is in this namespace, so the
|
||||
# same intra-namespace allowance the two above rely on covers it.
|
||||
- http://loki.monitoring.svc.cluster.local:3100/ready
|
||||
# WAN-path probes of the public edge BY PROXY IP. The name-based
|
||||
# home.renner.uno probe above resolves in-cluster (CoreDNS/k8s_gateway)
|
||||
# and never leaves the LAN — the vantage-point blind spot that let the
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
apiVersion: monitoring.coreos.com/v1
|
||||
kind: PrometheusRule
|
||||
metadata:
|
||||
name: blackbox
|
||||
namespace: monitoring
|
||||
labels:
|
||||
# kube-prometheus-stack's Prometheus resource selects on this label;
|
||||
# without it the rule is created but never loaded into Prometheus.
|
||||
release: kube-prometheus-stack
|
||||
spec:
|
||||
groups:
|
||||
- name: blackbox.rules
|
||||
rules:
|
||||
# Catch-all for every blackbox-http target that has no rule of its own.
|
||||
# Before this existed, a target could be probed, graphed and completely
|
||||
# unalerted — which is how file.renner.uno served 502 for months.
|
||||
#
|
||||
# THE EXCLUSION LIST AND THE DEDICATED RULES ARE ONE INVARIANT: an
|
||||
# instance belongs here only while a rule elsewhere covers it, and
|
||||
# adding a dedicated rule means adding its instance here. Get it wrong
|
||||
# in one direction and the alert fires twice; wrong in the other and it
|
||||
# never fires at all. Currently excluded:
|
||||
#
|
||||
# fsn1.your-objectstorage.com HetznerBackupEndpointDown (critical)
|
||||
# netbird.renner.uno prometheusrule-netbird-availability
|
||||
# proxy-1/2:9000/health same file, relay redundancy
|
||||
# ci.renner.uno WoodpeckerDown
|
||||
# openbao .../sys/health OpenBaoUnavailable
|
||||
#
|
||||
# Warning, not critical, on purpose: this rule cannot know what a given
|
||||
# target means. A target whose loss deserves a critical page gets its
|
||||
# own rule — and lands in the list above. That is what keeps the
|
||||
# exclusion list from being arbitrary.
|
||||
- alert: BlackboxProbeFailing
|
||||
expr: probe_success{job="blackbox-http", instance!~"https://fsn1\\.your-objectstorage\\.com|https://netbird\\.renner\\.uno|http://proxy-[12]\\.renner\\.uno:9000/health|https://ci\\.renner\\.uno|https://openbao\\.renner\\.uno/v1/sys/health.*"} == 0
|
||||
for: 10m
|
||||
annotations:
|
||||
summary: "Blackbox probe of {{ $labels.instance }} has been failing for 10 minutes"
|
||||
description: >-
|
||||
The endpoint is not answering as expected. For the Authentik-gated
|
||||
hostnames this is a reachability check covering DNS, TLS, Traefik
|
||||
and the gate outpost — it says nothing about the app behind the
|
||||
gate, which the internal /-/healthy probes cover separately.
|
||||
labels:
|
||||
severity: warning
|
||||
# The check that the checks are running. Every rule above reads
|
||||
# probe_success; if the series stop being produced at all, none of them
|
||||
# can fire and the silence looks exactly like health.
|
||||
- alert: BlackboxProbesAbsent
|
||||
expr: absent(probe_success{job="blackbox-http"})
|
||||
for: 15m
|
||||
annotations:
|
||||
summary: "No blackbox-http probe results are reaching Prometheus"
|
||||
description: >-
|
||||
Either the blackbox exporter is down or the Probe objects stopped
|
||||
being discovered. Every endpoint alert depends on this job, so
|
||||
they are all blind until it returns.
|
||||
labels:
|
||||
severity: critical
|
||||
@@ -0,0 +1,47 @@
|
||||
apiVersion: monitoring.coreos.com/v1
|
||||
kind: PrometheusRule
|
||||
metadata:
|
||||
name: ci-openbao
|
||||
namespace: monitoring
|
||||
labels:
|
||||
# kube-prometheus-stack's Prometheus resource selects on this label;
|
||||
# without it the rule is created but never loaded into Prometheus.
|
||||
release: kube-prometheus-stack
|
||||
spec:
|
||||
groups:
|
||||
- name: ci-openbao.rules
|
||||
rules:
|
||||
- alert: WoodpeckerDown
|
||||
# Probe target defined in probes-blackbox.yaml. Root, ungated, 200.
|
||||
expr: probe_success{job="blackbox-http", instance="https://ci.renner.uno"} == 0
|
||||
for: 5m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "Woodpecker is not answering on ci.renner.uno"
|
||||
description: >-
|
||||
Every scheduled job in this repo runs here: the alert-monitor
|
||||
cron that files these issues, the nightly scans, and every deploy
|
||||
target. Nothing else reports this outage, because the component
|
||||
that would have reported it is the one that is down. Alertmanager
|
||||
itself is unaffected, which is why this alert exists.
|
||||
- alert: OpenBaoUnavailable
|
||||
# Probes /v1/sys/health?standbyok=true — 200 on any unsealed node,
|
||||
# 503 sealed, 501 uninitialized. The UI stays up when sealed, so a
|
||||
# root probe would not catch this.
|
||||
#
|
||||
# Matched by prefix rather than the full string: the target carries a
|
||||
# query parameter, and an exact match would break silently if the
|
||||
# instance label ever encodes it differently.
|
||||
expr: probe_success{job="blackbox-http", instance=~"https://openbao\\.renner\\.uno/v1/sys/health.*"} == 0
|
||||
for: 5m
|
||||
labels:
|
||||
severity: critical
|
||||
annotations:
|
||||
summary: "OpenBao is sealed, uninitialized or unreachable"
|
||||
description: >-
|
||||
Every ExternalSecret in the cluster resolves through OpenBao.
|
||||
Existing Secrets keep working, so nothing breaks at once — the
|
||||
damage shows up at the next refresh interval or the next pod that
|
||||
needs a fresh credential. Check whether it is sealed before
|
||||
assuming a network fault.
|
||||
@@ -17,11 +17,12 @@ spec:
|
||||
# drains on recovery) and Velero backups fail. Replaces the old
|
||||
# GarageDown alert now that backups live on Hetzner, not nas-1.
|
||||
#
|
||||
# CNPG has no metric-based archiving alert yet — its pod metrics
|
||||
# are not scraped (enablePodMonitor=false). Enabling CNPG
|
||||
# PodMonitors for a granular "archiving falling behind" alert is
|
||||
# a follow-up. Velero's own VeleroBackupValidationFailure /
|
||||
# VeleroBackupStale rules already cover backup-level failures.
|
||||
# The granular counterpart is CNPGWalArchiveBacklog in
|
||||
# prometheusrule-cnpg-backup.yaml, reading the instance collector
|
||||
# scraped by cnpg-podmonitors.yaml: this alert says the target is
|
||||
# unreachable, that one says archiving is falling behind whatever the
|
||||
# cause. Velero's VeleroBackupValidationFailure / VeleroBackupStale
|
||||
# cover backup-level failures.
|
||||
expr: probe_success{instance="https://fsn1.your-objectstorage.com"} == 0
|
||||
for: 10m
|
||||
labels:
|
||||
|
||||
@@ -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."
|
||||
@@ -0,0 +1,163 @@
|
||||
{
|
||||
"annotations": {"list": []},
|
||||
"description": "CrowdSec: Erkennung im Cluster, Durchsetzung am NetBird-Edge",
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 1,
|
||||
"id": null,
|
||||
"links": [],
|
||||
"panels": [
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "prometheus"},
|
||||
"description": "Aktive Ban-Entscheidungen in der LAPI, ganz überwiegend aus den CAPI-Community-Blocklisten. Das ist die Menge, die ein scharfgeschalteter Bouncer durchsetzt.",
|
||||
"fieldConfig": {
|
||||
"defaults": {"color": {"mode": "thresholds"}, "mappings": [], "thresholds": {"mode": "absolute", "steps": [{"color": "text", "value": null}]}, "unit": "short"},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {"h": 4, "w": 6, "x": 0, "y": 0},
|
||||
"id": 1,
|
||||
"options": {"colorMode": "value", "graphMode": "area", "justifyMode": "auto", "orientation": "auto", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "textMode": "auto"},
|
||||
"targets": [{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "sum(cs_active_decisions)", "refId": "A"}],
|
||||
"title": "Aktive Decisions",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "prometheus"},
|
||||
"description": "Bouncer, die in den letzten 10 Minuten Decisions abgeholt haben. Fällt das unter die erwartete Zahl, holt ein Bouncer nichts mehr — und setzt damit einen veralteten Stand durch, ohne dass irgendetwas ausfällt.",
|
||||
"fieldConfig": {
|
||||
"defaults": {"color": {"mode": "thresholds"}, "mappings": [], "thresholds": {"mode": "absolute", "steps": [{"color": "red", "value": null}, {"color": "green", "value": 1}]}, "unit": "short"},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {"h": 4, "w": 6, "x": 6, "y": 0},
|
||||
"id": 2,
|
||||
"options": {"colorMode": "value", "graphMode": "none", "justifyMode": "auto", "orientation": "auto", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "textMode": "auto"},
|
||||
"targets": [{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "count(sum by (bouncer) (rate(cs_lapi_bouncer_requests_total[10m])) > 0)", "refId": "A"}],
|
||||
"title": "Bouncer, die abholen",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "prometheus"},
|
||||
"description": "Vom Agent gelesene Logzeilen pro Sekunde. Steht das auf 0, detektiert CrowdSec nichts mehr — der häufigste stille Ausfall, weil alles andere weiterläuft.",
|
||||
"fieldConfig": {
|
||||
"defaults": {"color": {"mode": "thresholds"}, "mappings": [], "thresholds": {"mode": "absolute", "steps": [{"color": "red", "value": null}, {"color": "green", "value": 0.01}]}, "unit": "reqps"},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {"h": 4, "w": 6, "x": 12, "y": 0},
|
||||
"id": 3,
|
||||
"options": {"colorMode": "value", "graphMode": "area", "justifyMode": "auto", "orientation": "auto", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "textMode": "auto"},
|
||||
"targets": [{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "sum(rate(cs_filesource_hits_total[5m]))", "refId": "A"}],
|
||||
"title": "Gelesene Logzeilen/s",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "prometheus"},
|
||||
"description": "Szenario-Overflows der letzten 24 h — also lokal erkannte Angriffe, im Gegensatz zu den von CAPI übernommenen.",
|
||||
"fieldConfig": {
|
||||
"defaults": {"color": {"mode": "thresholds"}, "mappings": [], "thresholds": {"mode": "absolute", "steps": [{"color": "text", "value": null}]}, "unit": "short"},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {"h": 4, "w": 6, "x": 18, "y": 0},
|
||||
"id": 4,
|
||||
"options": {"colorMode": "value", "graphMode": "area", "justifyMode": "auto", "orientation": "auto", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "textMode": "auto"},
|
||||
"targets": [{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "sum(increase(cs_bucket_overflowed_total[24h]))", "refId": "A"}],
|
||||
"title": "Lokale Overflows (24 h)",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "prometheus"},
|
||||
"description": "Abholrate je Bouncer. Der Firewall-Bouncer auf den Proxies fragt alle 10 s. Die LAPI legt bei jeder neuen beobachteten Client-Adresse einen Eintrag `<name>@<ip>` an, und beobachtet wird die Pod-IP des netbird-ingress-Peers — deshalb erscheint ein logischer Bouncer hier mehrfach und nach Pod-Rollen unter neuen Namen.",
|
||||
"fieldConfig": {
|
||||
"defaults": {"color": {"mode": "palette-classic"}, "custom": {"axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "drawStyle": "line", "fillOpacity": 10, "gradientMode": "none", "hideFrom": {"legend": false, "tooltip": false, "viz": false}, "lineInterpolation": "linear", "lineWidth": 1, "pointSize": 5, "scaleDistribution": {"type": "linear"}, "showPoints": "never", "spanNulls": false, "stacking": {"group": "A", "mode": "none"}, "thresholdsStyle": {"mode": "off"}}, "mappings": [], "thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]}, "unit": "reqps"},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 4},
|
||||
"id": 5,
|
||||
"options": {"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "right", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
|
||||
"targets": [{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "sum by (bouncer) (rate(cs_lapi_bouncer_requests_total[5m]))", "legendFormat": "{{bouncer}}", "refId": "A"}],
|
||||
"title": "Bouncer-Abholrate",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "prometheus"},
|
||||
"description": "Aktive Decisions nach Grund. `http:scan` dominiert und stammt aus den CAPI-Blocklisten.",
|
||||
"fieldConfig": {
|
||||
"defaults": {"color": {"mode": "palette-classic"}, "custom": {"axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "drawStyle": "line", "fillOpacity": 20, "gradientMode": "none", "hideFrom": {"legend": false, "tooltip": false, "viz": false}, "lineInterpolation": "linear", "lineWidth": 1, "pointSize": 5, "scaleDistribution": {"type": "linear"}, "showPoints": "never", "spanNulls": false, "stacking": {"group": "A", "mode": "normal"}, "thresholdsStyle": {"mode": "off"}}, "mappings": [], "thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]}, "unit": "short"},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 4},
|
||||
"id": 6,
|
||||
"options": {"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "right", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
|
||||
"targets": [{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "sum by (reason) (cs_active_decisions)", "legendFormat": "{{reason}}", "refId": "A"}],
|
||||
"title": "Aktive Decisions nach Grund",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "prometheus"},
|
||||
"description": "Welche Szenarien lokal auslösen. Das ist das, was CrowdSec selbst aus den Traefik-Logs erkennt.",
|
||||
"fieldConfig": {
|
||||
"defaults": {"color": {"mode": "palette-classic"}, "custom": {"axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "drawStyle": "bars", "fillOpacity": 70, "gradientMode": "none", "hideFrom": {"legend": false, "tooltip": false, "viz": false}, "lineInterpolation": "linear", "lineWidth": 1, "pointSize": 5, "scaleDistribution": {"type": "linear"}, "showPoints": "never", "spanNulls": false, "stacking": {"group": "A", "mode": "normal"}, "thresholdsStyle": {"mode": "off"}}, "mappings": [], "thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]}, "unit": "short"},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 12},
|
||||
"id": 7,
|
||||
"options": {"legend": {"calcs": ["sum"], "displayMode": "table", "placement": "right", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
|
||||
"targets": [{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "sum by (name) (increase(cs_bucket_overflowed_total[1h]))", "legendFormat": "{{name}}", "refId": "A"}],
|
||||
"title": "Szenario-Overflows (je Stunde)",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "prometheus"},
|
||||
"description": "Geparste gegen nicht geparste Zeilen. Ein Anstieg bei `ko` heißt meist, dass sich ein Logformat geändert hat und der Parser nicht mehr greift — CrowdSec liest dann weiter, erkennt aber nichts mehr.",
|
||||
"fieldConfig": {
|
||||
"defaults": {"color": {"mode": "palette-classic"}, "custom": {"axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "drawStyle": "line", "fillOpacity": 10, "gradientMode": "none", "hideFrom": {"legend": false, "tooltip": false, "viz": false}, "lineInterpolation": "linear", "lineWidth": 1, "pointSize": 5, "scaleDistribution": {"type": "linear"}, "showPoints": "never", "spanNulls": false, "stacking": {"group": "A", "mode": "none"}, "thresholdsStyle": {"mode": "off"}}, "mappings": [], "thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]}, "unit": "reqps"},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 12},
|
||||
"id": 8,
|
||||
"options": {"legend": {"calcs": ["lastNotNull"], "displayMode": "list", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
|
||||
"targets": [
|
||||
{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "sum(rate(cs_parser_hits_ok_total[5m]))", "legendFormat": "geparst", "refId": "A"},
|
||||
{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "sum(rate(cs_parser_hits_ko_total[5m]))", "legendFormat": "nicht geparst", "refId": "B"}
|
||||
],
|
||||
"title": "Parser",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "prometheus"},
|
||||
"description": "Whitelist-Treffer nach Grund. Nützlich beim Scharfschalten: was hier zählt, wäre sonst ein Kandidat für einen Ban gewesen.",
|
||||
"fieldConfig": {
|
||||
"defaults": {"color": {"mode": "palette-classic"}, "custom": {"axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "drawStyle": "line", "fillOpacity": 10, "gradientMode": "none", "hideFrom": {"legend": false, "tooltip": false, "viz": false}, "lineInterpolation": "linear", "lineWidth": 1, "pointSize": 5, "scaleDistribution": {"type": "linear"}, "showPoints": "never", "spanNulls": false, "stacking": {"group": "A", "mode": "none"}, "thresholdsStyle": {"mode": "off"}}, "mappings": [], "thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]}, "unit": "reqps"},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 20},
|
||||
"id": 9,
|
||||
"options": {"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "right", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
|
||||
"targets": [{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "sum by (reason) (rate(cs_node_wl_hits_ok_total[5m]))", "legendFormat": "{{reason}}", "refId": "A"}],
|
||||
"title": "Whitelist-Treffer",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "prometheus"},
|
||||
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 20},
|
||||
"id": 10,
|
||||
"options": {
|
||||
"code": {"language": "plaintext", "showLineNumbers": false, "showMiniMap": false},
|
||||
"content": "## Was dieses Dashboard NICHT zeigt\n\nAlle Panels stammen aus der LAPI und den Agents im Cluster. Die **Bouncer auf proxy-1 und proxy-2 exportieren eigene Metriken**, die hier fehlen:\n\n- `fw_bouncer_active_decisions` — was tatsächlich in den nftables-Sets steht\n- `fw_bouncer_dropped_packets` / `_bytes` — was wirklich verworfen wurde\n- `lapi_requests_total` / `_failures_total` — aus Sicht des Bouncers\n\nSie liegen auf `127.0.0.1:60601` der Proxy-Hosts. Die Hosts sind öffentlich und haben bisher keinerlei Metrik-Pfad; den Port ins Internet zu öffnen, nur um ein Scrape-Ziel zu bekommen, wäre der falsche Tausch.\n\n**Konsequenz:** „Bouncer-Abholrate\" oben zeigt, dass ein Bouncer *fragt* — nicht, dass er die Antwort auch angewandt hat. Solange der Modus `dry-run` ist, wendet er ohnehin nichts an.\n\nEin Scrape-Pfad für die Hosts ist eigene Arbeit und Voraussetzung dafür, dass „was wird wirklich geblockt\" beantwortbar wird."
|
||||
},
|
||||
"title": "Blinder Fleck",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"refresh": "30s",
|
||||
"schemaVersion": 38,
|
||||
"style": "dark",
|
||||
"tags": ["homelab", "crowdsec", "security"],
|
||||
"templating": {"list": []},
|
||||
"time": {"from": "now-6h", "to": "now"},
|
||||
"timepicker": {},
|
||||
"timezone": "",
|
||||
"title": "CrowdSec Overview",
|
||||
"uid": "crowdsec-overview",
|
||||
"version": 1,
|
||||
"weekStart": ""
|
||||
}
|
||||
@@ -38,6 +38,9 @@ configMapGenerator:
|
||||
- name: dashboard-blackbox-overview
|
||||
files:
|
||||
- blackbox-overview.json
|
||||
- name: dashboard-crowdsec-overview
|
||||
files:
|
||||
- crowdsec-overview.json
|
||||
- name: dashboard-disk-health
|
||||
files:
|
||||
- disk-health.json
|
||||
|
||||
@@ -55,7 +55,7 @@ spec:
|
||||
# stdlib only; Flux keeps the tag in lockstep via the shared image
|
||||
# policy) — no extra dependency.
|
||||
- name: reconcile-publish-ip
|
||||
image: ghcr.io/music-assistant/server:2.10.0 # {"$imagepolicy": "flux-system:music-assistant"}
|
||||
image: ghcr.io/music-assistant/server:2.10.1 # {"$imagepolicy": "flux-system:music-assistant"}
|
||||
command: ["/app/venv/bin/python", "/scripts/reconcile.py"]
|
||||
volumeMounts:
|
||||
- name: data
|
||||
@@ -65,7 +65,7 @@ spec:
|
||||
readOnly: true
|
||||
containers:
|
||||
- name: music-assistant
|
||||
image: ghcr.io/music-assistant/server:2.10.0 # {"$imagepolicy": "flux-system:music-assistant"}
|
||||
image: ghcr.io/music-assistant/server:2.10.1 # {"$imagepolicy": "flux-system:music-assistant"}
|
||||
env:
|
||||
- name: LOG_LEVEL
|
||||
value: info
|
||||
|
||||
@@ -24,6 +24,22 @@ spec:
|
||||
- name: wyoming
|
||||
containerPort: 10400
|
||||
protocol: TCP
|
||||
# Wyoming is raw TCP with no HTTP surface and no health verb, so
|
||||
# accepting a connection is all that can be asserted from outside the
|
||||
# process. Weaker than an HTTP probe, but the previous state was none
|
||||
# at all: a dead server stayed Ready and kept being routed to.
|
||||
livenessProbe:
|
||||
tcpSocket:
|
||||
port: wyoming
|
||||
initialDelaySeconds: 60
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 5
|
||||
readinessProbe:
|
||||
tcpSocket:
|
||||
port: wyoming
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
|
||||
@@ -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.
|
||||
@@ -70,6 +70,27 @@ spec:
|
||||
- name: consume
|
||||
mountPath: /home/scanner
|
||||
subPath: scanner
|
||||
# FTP has no health verb, so the control port accepting a connection
|
||||
# is what can be asserted. It is enough for the failure that matters:
|
||||
# if vsftpd dies the scanner cannot deposit anything and nothing else
|
||||
# in the chain notices, because paperless only ever sees an empty
|
||||
# consume directory — which is indistinguishable from nobody having
|
||||
# scanned.
|
||||
#
|
||||
# Only the control port is probed. The passive range is negotiated
|
||||
# per transfer and is not listening at rest.
|
||||
livenessProbe:
|
||||
tcpSocket:
|
||||
port: ftp-ctrl
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 5
|
||||
readinessProbe:
|
||||
tcpSocket:
|
||||
port: ftp-ctrl
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
resources:
|
||||
requests:
|
||||
cpu: 20m
|
||||
|
||||
@@ -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:
|
||||
@@ -120,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
|
||||
@@ -155,21 +205,52 @@ spec:
|
||||
- name: bridge-cert
|
||||
mountPath: /shared
|
||||
readOnly: true
|
||||
# TCP probes — HTTP probes against /api/ flooded the
|
||||
# `paperless.auth` logger every 10 s with "No authentication
|
||||
# provided from private IP …". With ENABLE_HTTP_REMOTE_USER
|
||||
# the middleware logs every unauth API request, and there's
|
||||
# no public health endpoint to probe. TCP confirms gunicorn
|
||||
# is bound; that's the same liveness signal we'd get from
|
||||
# /api/ minus the noise.
|
||||
# /accounts/login/ rather than /api/ or a TCP connect.
|
||||
#
|
||||
# /api/ is unusable as a probe target: with ENABLE_HTTP_REMOTE_USER
|
||||
# the remote-user middleware logs "No authentication provided from
|
||||
# private IP …" for every unauthenticated API request, so probing it
|
||||
# floods `paperless.auth`. Measured: four requests to /api/ produce
|
||||
# four log lines, four to /accounts/login/ produce none — the
|
||||
# middleware only covers the API surface.
|
||||
#
|
||||
# A TCP connect avoids the noise but only proves gunicorn accepted a
|
||||
# socket. /accounts/login/ renders a real Django view, so URL
|
||||
# routing, settings and templating all have to work. It does not
|
||||
# prove Postgres is healthy; nothing cheap here does.
|
||||
#
|
||||
# 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:
|
||||
tcpSocket:
|
||||
httpGet:
|
||||
path: /accounts/login/
|
||||
port: http
|
||||
initialDelaySeconds: 120
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 10
|
||||
readinessProbe:
|
||||
tcpSocket:
|
||||
httpGet:
|
||||
path: /accounts/login/
|
||||
port: http
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
@@ -208,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:
|
||||
|
||||
@@ -34,6 +34,32 @@ spec:
|
||||
- name: wyoming
|
||||
containerPort: 10300
|
||||
protocol: TCP
|
||||
# Wyoming is raw TCP with no HTTP surface and no health verb, so
|
||||
# accepting a connection is all that can be asserted from outside the
|
||||
# process — weaker than an HTTP probe, but the previous state was
|
||||
# none at all.
|
||||
#
|
||||
# A startupProbe rather than a long liveness delay: this container
|
||||
# runs with --retrain-on-start, and retraining against the Home
|
||||
# Assistant entity list takes as long as it takes. The startup gate
|
||||
# allows up to 10 minutes (60 x 10s) and only then hands over to
|
||||
# liveness, so a slow retrain cannot be mistaken for a hang, and a
|
||||
# genuine hang after startup is still caught within a minute.
|
||||
startupProbe:
|
||||
tcpSocket:
|
||||
port: wyoming
|
||||
periodSeconds: 10
|
||||
failureThreshold: 60
|
||||
livenessProbe:
|
||||
tcpSocket:
|
||||
port: wyoming
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 5
|
||||
readinessProbe:
|
||||
tcpSocket:
|
||||
port: wyoming
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
volumeMounts:
|
||||
- name: models
|
||||
mountPath: /models
|
||||
|
||||
@@ -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
|
||||
@@ -29,6 +29,27 @@ spec:
|
||||
- name: wyoming
|
||||
containerPort: 10300
|
||||
protocol: TCP
|
||||
# Wyoming is a raw TCP protocol with no HTTP surface and no health
|
||||
# verb, so accepting a connection is the only thing that can be
|
||||
# asserted from outside the process. That is weaker than an HTTP
|
||||
# probe but strictly better than the previous state of none at all,
|
||||
# where a dead server stayed Ready and Home Assistant kept routing
|
||||
# speech to it.
|
||||
#
|
||||
# The liveness delay is generous because the model is loaded at
|
||||
# startup from the data volume.
|
||||
livenessProbe:
|
||||
tcpSocket:
|
||||
port: wyoming
|
||||
initialDelaySeconds: 90
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 5
|
||||
readinessProbe:
|
||||
tcpSocket:
|
||||
port: wyoming
|
||||
initialDelaySeconds: 20
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /data
|
||||
|
||||
@@ -94,6 +94,31 @@ spec:
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: woodpecker-secret
|
||||
ports:
|
||||
# WOODPECKER_HEALTHCHECK defaults to true and serves :3000, so this
|
||||
# port already existed; declaring it just lets the probe name it.
|
||||
- name: health
|
||||
containerPort: 3000
|
||||
protocol: TCP
|
||||
# /healthz answers 500 ONLY when a running workflow has passed its own
|
||||
# timeout plus a one-hour buffer (agent/state.go, State.Healthy). An
|
||||
# idle agent always answers 200.
|
||||
#
|
||||
# So this catches a wedged build holding a slot, which is otherwise
|
||||
# invisible — and it does NOT catch an agent that has lost its server
|
||||
# connection, which reports healthy while quietly taking no work. Do
|
||||
# not read a green probe here as "CI is fine".
|
||||
#
|
||||
# Liveness only: the agent fronts no Service, so readiness would gate
|
||||
# nothing. The upstream condition is already debounced by that hour,
|
||||
# which is why the thresholds here are short.
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: health
|
||||
initialDelaySeconds: 20
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 5
|
||||
resources:
|
||||
requests:
|
||||
cpu: 25m
|
||||
|
||||
+340
-54
@@ -8,6 +8,9 @@ set -euo pipefail
|
||||
# ./bootstrap.sh talos # Talos apply + K8s bootstrap + kubeconfig (writes access-staging/)
|
||||
# ./bootstrap.sh flux # reads access-staging/, installs flux, waits for infrastructure
|
||||
# ./bootstrap.sh wait-apps # waits for apps/authentik/paperless to reconcile
|
||||
# ./bootstrap.sh vmtest # throwaway QEMU VMs, then the talos phase
|
||||
# # against them unchanged. Needs root for the
|
||||
# # provisioner and leaves nothing behind.
|
||||
#
|
||||
# Flux git source (break-glass): the in-cluster Gitea that Flux normally pulls
|
||||
# from is itself Flux-managed, so a cold rebuild cannot pull from it until the
|
||||
@@ -21,6 +24,13 @@ set -euo pipefail
|
||||
|
||||
TALOS_DIR="$(cd "$(dirname "$0")/talos" && pwd)"
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
# Cilium's single source of truth. Talos ships `cni: none` (talos/patches/
|
||||
# cluster.yaml) and nothing else, so the CNI has to be installed here, between
|
||||
# `talosctl bootstrap` and Flux — Flux's own controllers are pods and cannot
|
||||
# reconcile the network they need in order to start. Everything the install
|
||||
# needs is read out of this file at run time so there is no second copy of a
|
||||
# version or a value to keep in step. See PROPOSAL-cilium-single-owner.md.
|
||||
CILIUM_HR="$REPO_ROOT/kubernetes/infrastructure/cilium/helmrelease.yaml"
|
||||
ACCESS_STAGING="$REPO_ROOT/kubernetes/access-staging"
|
||||
NODES=(k8s-1 k8s-2 k8s-3)
|
||||
# No dedicated workers since the k8s-edge VM was retired (2026-07-02) —
|
||||
@@ -33,6 +43,24 @@ DNS_SERVER="10.2.40.1" # UDM (old PVE DNS VIP 10.2.40.53 decommissioned)
|
||||
# Cluster API endpoint — Talos VIP (10.2.40.60) elected via etcd, fronted by
|
||||
# UniFi DNS A record k8s.renner.uno. Defined in talos/patches/k8s-{1,2,3}.yaml.
|
||||
K8S_API_HOST="k8s.renner.uno"
|
||||
# Which site patch the nodes get: talos/patches/site-$SITE.yaml. The three
|
||||
# variables above and this one are everything the vmtest phase has to change in
|
||||
# order to run this same code against throwaway VMs — if that list ever grows,
|
||||
# the test is drifting away from the thing it claims to exercise.
|
||||
SITE="${SITE:-homelab}"
|
||||
# Where this script writes the cluster access files. These are the paths
|
||||
# talosctl and kubectl read from anyway, so unset they resolve to exactly the
|
||||
# old behaviour. Set, the whole run stays out of $HOME — which is what lets the
|
||||
# vmtest phase drive a throwaway cluster without replacing the operator's
|
||||
# production access with a VM's.
|
||||
TALOSCONFIG_PATH="${TALOSCONFIG:-$HOME/.talos/config}"
|
||||
KUBECONFIG_PATH="${KUBECONFIG:-$HOME/.kube/config}"
|
||||
case "$KUBECONFIG_PATH" in
|
||||
*:*) echo "KUBECONFIG holds multiple paths; set it to a single file" >&2; exit 1 ;;
|
||||
esac
|
||||
# Addresses to apply to, in the order of NODES. Empty means resolve the node
|
||||
# FQDNs, which is right for real hardware on a network that has DNS for it.
|
||||
NODE_ADDRS=()
|
||||
GITEA_HOST="gitea.renner.uno"
|
||||
GITEA_OWNER="gitea_admin"
|
||||
GITEA_REPO="homelab"
|
||||
@@ -44,6 +72,19 @@ FLUX_SOURCE="${FLUX_SOURCE:-gitea}"
|
||||
GITHUB_OWNER="${GITHUB_OWNER:-konairius}"
|
||||
GITHUB_REPO="${GITHUB_REPO:-Homelab}"
|
||||
FLUX_PATH="kubernetes/clusters/homelab"
|
||||
# Branch Flux reconciles from, and that `flux bootstrap` commits its own sync
|
||||
# manifests into. master for a real rebuild.
|
||||
#
|
||||
# Overridable so the phase can be rehearsed without writing to master — point it
|
||||
# at a throwaway branch, which has to exist already: `flux bootstrap` clones the
|
||||
# branch and fails with `couldn't find remote ref` rather than creating it.
|
||||
#
|
||||
# git push origin origin/master:refs/heads/rehearsal-1
|
||||
# FLUX_BRANCH=rehearsal-1 ./bootstrap.sh flux
|
||||
#
|
||||
# Nothing else about the phase changes, which is the point. Delete the branch
|
||||
# afterwards.
|
||||
FLUX_BRANCH="${FLUX_BRANCH:-master}"
|
||||
SOPS_KEY="$HOME/.config/sops/age/keys.txt"
|
||||
|
||||
PHASE="${1:-all}"
|
||||
@@ -78,43 +119,145 @@ phase_runs() {
|
||||
[[ "$PHASE" == "all" || "$PHASE" == "$1" ]]
|
||||
}
|
||||
|
||||
# Provision throwaway QEMU VMs, then run the Talos phase below against
|
||||
# them unchanged. Deliberately not a copy of that phase: it sets the site
|
||||
# variables and falls through, because a rehearsal that runs different code
|
||||
# proves less than one that does not. Everything it changes is listed here — if
|
||||
# that list grows, the test is drifting away from what it claims to exercise.
|
||||
#
|
||||
# What it cannot cover, and what therefore still has to be true on hardware:
|
||||
# Ceph (the provisioner gives extra disks to workers only), the mgmt and v30iot
|
||||
# VLANs and the Layer2 VIP (one flat NIC per VM), and Flux (its own phase).
|
||||
#
|
||||
# Needs root for the QEMU provisioner (CNI and KVM), and only for that step.
|
||||
if [[ "$PHASE" == "vmtest" ]]; then
|
||||
echo -e "${YELLOW}=== Phase: VM rebuild test ===${NC}"
|
||||
command -v talosctl >/dev/null || fail "talosctl not found"
|
||||
command -v yq >/dev/null || fail "yq not found"
|
||||
# The node patches are the list; the VM count follows from them rather than
|
||||
# being stated twice. vm-1 is the control plane, the rest are the workers
|
||||
# that carry an OSD device — see the comment on NODES below for why.
|
||||
VM_CP_NODES=(vm-1)
|
||||
VM_WORKER_NODES=(vm-2 vm-3 vm-4)
|
||||
VM_CPS=${#VM_CP_NODES[@]}
|
||||
VM_WORKER_COUNT=${#VM_WORKER_NODES[@]}
|
||||
for n in "${VM_CP_NODES[@]}" "${VM_WORKER_NODES[@]}"; do
|
||||
[[ -f "$TALOS_DIR/patches/$n.yaml" ]] || fail "$TALOS_DIR/patches/$n.yaml not found"
|
||||
done
|
||||
[[ -f "$TALOS_DIR/patches/site-qemu.yaml" ]] || fail "site-qemu.yaml not found"
|
||||
|
||||
# Talos version and Image Factory schematic are read out of the installer
|
||||
# image in cluster.yaml. Naming them here would be a second copy to keep in
|
||||
# step, and the day they diverged the test would quietly stop booting the
|
||||
# image a rebuild installs.
|
||||
VM_IMAGE=$(yq -r '.machine.install.image' "$TALOS_DIR/patches/cluster.yaml")
|
||||
VM_SCHEMATIC=${VM_IMAGE#*metal-installer/}
|
||||
VM_SCHEMATIC=${VM_SCHEMATIC%%:*}
|
||||
VM_VERSION=${VM_IMAGE##*:}
|
||||
[[ ${#VM_SCHEMATIC} -eq 64 && "$VM_VERSION" == v* ]] \
|
||||
|| fail "could not read a schematic and version out of '$VM_IMAGE'"
|
||||
|
||||
# sun_path caps a unix socket path at 108 bytes and the provisioner puts a
|
||||
# qemu monitor socket under the state directory, so this has to stay short.
|
||||
# A path under the repo or a long mktemp dir silently fails to boot.
|
||||
VM_STATE=/tmp/bootstrap-vmtest
|
||||
VM_CLUSTER=bootstrap-vmtest
|
||||
VM_DIR=$(mktemp -d)
|
||||
TALOSCTL_BIN=$(command -v talosctl)
|
||||
|
||||
# Hard isolation from the production cluster: talosctl and kubectl both
|
||||
# honour these, and the phase below writes to them rather than to $HOME.
|
||||
export TALOSCONFIG="$VM_DIR/talosconfig"
|
||||
export KUBECONFIG="$VM_DIR/kubeconfig"
|
||||
TALOSCONFIG_PATH="$TALOSCONFIG"
|
||||
KUBECONFIG_PATH="$KUBECONFIG"
|
||||
|
||||
cleanup_vmtest() {
|
||||
echo -n "Destroying throwaway VMs... "
|
||||
sudo -E "$TALOSCTL_BIN" cluster destroy --name "$VM_CLUSTER" --state "$VM_STATE" >/dev/null || true
|
||||
# destroy removes the cluster's own directory but leaves the state root.
|
||||
sudo rm -rf "$VM_STATE"
|
||||
rm -rf "$VM_DIR"
|
||||
echo "done"
|
||||
}
|
||||
trap cleanup_vmtest EXIT
|
||||
|
||||
step 0 "Creating $VM_CPS control plane and $VM_WORKER_COUNT workers in maintenance mode..."
|
||||
# maintenance means the provisioner applies no configuration of its own, so
|
||||
# every byte of machine config below comes from talos/patches.
|
||||
sudo -E "$TALOSCTL_BIN" cluster create qemu \
|
||||
--name "$VM_CLUSTER" \
|
||||
--state "$VM_STATE" \
|
||||
--presets iso,maintenance \
|
||||
--controlplanes "$VM_CPS" \
|
||||
--workers "$VM_WORKER_COUNT" \
|
||||
--talos-version "$VM_VERSION" \
|
||||
--schematic-id "$VM_SCHEMATIC" \
|
||||
--memory-controlplanes 2560MiB \
|
||||
--memory-workers 4GiB \
|
||||
--cpus-controlplanes 2 \
|
||||
--cpus-workers 2 \
|
||||
--disks virtio:10GiB,virtio:8GiB \
|
||||
--talosconfig-destination "$TALOSCONFIG" \
|
||||
|| fail "could not create the throwaway VMs"
|
||||
sudo chown -R "$(id -u):$(id -g)" "$VM_STATE" "$TALOSCONFIG"
|
||||
ok
|
||||
|
||||
# The provisioner hands out its CIDR sequentially from .2; .1 is the
|
||||
# gateway and the load balancer it puts in front of the API. If that ever
|
||||
# changes, the apply loop below reports which address stopped answering
|
||||
# rather than failing silently.
|
||||
# One control plane and three workers, not three control planes: the
|
||||
# provisioner gives the second disk only to workers ("disks after the first
|
||||
# one are added only to worker machines"), and an OSD needs it. Three of
|
||||
# them because the CephCluster runs three mons with allowMultiplePerNode
|
||||
# false over pools that are replica 3 across failureDomain host.
|
||||
NODES=("${VM_CP_NODES[@]}")
|
||||
WORKERS=("${VM_WORKER_NODES[@]}")
|
||||
ALL_NODES=("${NODES[@]}" "${WORKERS[@]}")
|
||||
NODE_ADDRS=()
|
||||
for i in $(seq 0 $(( ${#ALL_NODES[@]} - 1 ))); do
|
||||
NODE_ADDRS+=("10.5.0.$(( i + 2 ))")
|
||||
done
|
||||
SITE=qemu
|
||||
K8S_API_HOST=10.5.0.1
|
||||
VMTEST=1
|
||||
|
||||
# Run the real phase, not a reimplementation of it.
|
||||
PHASE=talos
|
||||
fi
|
||||
|
||||
if phase_runs talos; then
|
||||
echo -e "${YELLOW}=== Phase: Talos bootstrap ===${NC}"
|
||||
command -v talosctl >/dev/null || fail "talosctl not found"
|
||||
command -v kubectl >/dev/null || fail "kubectl not found"
|
||||
command -v helm >/dev/null || fail "helm not found (needed to install the CNI)"
|
||||
command -v yq >/dev/null || fail "yq not found (needed to read the Cilium HelmRelease)"
|
||||
[[ -f "$TALOS_DIR/patches/cluster.yaml" ]] || fail "cluster.yaml patch not found"
|
||||
[[ -f "$TALOS_DIR/patches/cilium-cni.yaml" ]] || fail "cilium-cni.yaml patch not found"
|
||||
[[ -f "$TALOS_DIR/patches/registry-mirror.yaml" ]] || fail "registry-mirror.yaml patch not found"
|
||||
[[ -f "$TALOS_DIR/patches/containerd-layer-retention.yaml" ]] || fail "containerd-layer-retention.yaml patch not found"
|
||||
[[ -f "$CILIUM_HR" ]] || fail "cilium helmrelease not found at $CILIUM_HR"
|
||||
|
||||
step 0 "Generating Talos configs..."
|
||||
TALOS_GEN_DIR=$(mktemp -d)
|
||||
talosctl gen config homelab "https://${K8S_API_HOST}:6443" \
|
||||
--output "$TALOS_GEN_DIR" \
|
||||
--force
|
||||
# Strip the auto-generated HostnameConfig docs (conflicts with machine.network.hostname in per-node patches)
|
||||
for f in "$TALOS_GEN_DIR"/controlplane.yaml "$TALOS_GEN_DIR"/worker.yaml; do
|
||||
awk '
|
||||
BEGIN { doc="" }
|
||||
/^---[ \t]*$/ {
|
||||
if (doc !~ /\nkind: HostnameConfig\n/ && doc !~ /^kind: HostnameConfig\n/) printf "%s", doc
|
||||
doc=$0 "\n"; next
|
||||
}
|
||||
{ doc = doc $0 "\n" }
|
||||
END {
|
||||
if (doc !~ /\nkind: HostnameConfig\n/ && doc !~ /^kind: HostnameConfig\n/) printf "%s", doc
|
||||
}
|
||||
' "$f" > "$f.tmp" && mv "$f.tmp" "$f"
|
||||
done
|
||||
mkdir -p ~/.talos
|
||||
cp "$TALOS_GEN_DIR/talosconfig" ~/.talos/config
|
||||
mkdir -p "$(dirname "$TALOSCONFIG_PATH")"
|
||||
cp "$TALOS_GEN_DIR/talosconfig" "$TALOSCONFIG_PATH"
|
||||
|
||||
step 0 "Applying Talos configs to nodes..."
|
||||
apply_node_config() {
|
||||
local node=$1 base_config=$2
|
||||
local node=$1 base_config=$2 fixed_addr=${3:-}
|
||||
local fqdn="${node}.${DOMAIN}"
|
||||
echo " $fqdn:"
|
||||
echo " ${fixed_addr:-$fqdn}:"
|
||||
for i in $(seq 1 60); do
|
||||
node_ip=$(resolve_node_ip "$fqdn")
|
||||
# A fixed address skips DNS. Real hardware has a name to resolve;
|
||||
# a throwaway VM has an address and nothing else.
|
||||
if [[ -n "$fixed_addr" ]]; then
|
||||
node_ip="$fixed_addr"
|
||||
else
|
||||
node_ip=$(resolve_node_ip "$fqdn")
|
||||
fi
|
||||
if [[ -z "$node_ip" ]]; then
|
||||
echo " attempt $i/60: DNS returned no IP"
|
||||
sleep 10
|
||||
@@ -123,73 +266,216 @@ if phase_runs talos; then
|
||||
if err=$(talosctl apply-config --insecure -n "$node_ip" \
|
||||
--file "$base_config" \
|
||||
--config-patch "@$TALOS_DIR/patches/cluster.yaml" \
|
||||
--config-patch "@$TALOS_DIR/patches/cilium-cni.yaml" \
|
||||
--config-patch "@$TALOS_DIR/patches/registry-mirror.yaml" \
|
||||
--config-patch "@$TALOS_DIR/patches/site-${SITE}.yaml" \
|
||||
--config-patch "@$TALOS_DIR/patches/containerd-layer-retention.yaml" \
|
||||
--config-patch "@$TALOS_DIR/patches/${node}.yaml" 2>&1); then
|
||||
echo " applied via $node_ip ${GREEN}(OK)${NC}"
|
||||
echo -e " applied via $node_ip ${GREEN}(OK)${NC}"
|
||||
return 0
|
||||
fi
|
||||
echo " attempt $i/60: $node_ip - ${err//$'\n'/ | }"
|
||||
sleep 10
|
||||
done
|
||||
fail "Timeout waiting for $fqdn"
|
||||
fail "Timeout waiting for ${fixed_addr:-$fqdn}"
|
||||
}
|
||||
|
||||
for node in "${NODES[@]}"; do
|
||||
apply_node_config "$node" "$TALOS_GEN_DIR/controlplane.yaml"
|
||||
done
|
||||
for node in "${WORKERS[@]}"; do
|
||||
apply_node_config "$node" "$TALOS_GEN_DIR/worker.yaml"
|
||||
# One loop over ALL_NODES rather than one per role, so NODE_ADDRS can be a
|
||||
# single list in the same order. Control planes come first in ALL_NODES,
|
||||
# which is what decides the base config each node gets.
|
||||
for idx in "${!ALL_NODES[@]}"; do
|
||||
if [[ $idx -lt ${#NODES[@]} ]]; then
|
||||
base="$TALOS_GEN_DIR/controlplane.yaml"
|
||||
else
|
||||
base="$TALOS_GEN_DIR/worker.yaml"
|
||||
fi
|
||||
apply_node_config "${ALL_NODES[$idx]}" "$base" "${NODE_ADDRS[$idx]:-}"
|
||||
done
|
||||
|
||||
echo -n "Configuring talosctl... "
|
||||
talosctl config endpoint "${NODES[@]/%/.${DOMAIN}}"
|
||||
talosctl config node "${NODES[0]}.${DOMAIN}"
|
||||
# `gen config` leaves talosconfig endpoints empty; they are the Talos API
|
||||
# addresses and unrelated to cluster.controlPlane.endpoint.
|
||||
if [[ ${#NODE_ADDRS[@]} -gt 0 ]]; then
|
||||
talosctl config endpoint "${NODE_ADDRS[@]}"
|
||||
talosctl config node "${NODE_ADDRS[0]}"
|
||||
else
|
||||
talosctl config endpoint "${NODES[@]/%/.${DOMAIN}}"
|
||||
talosctl config node "${NODES[0]}.${DOMAIN}"
|
||||
fi
|
||||
ok
|
||||
|
||||
step 0 "Waiting for Talos cluster formation..."
|
||||
for i in $(seq 1 30); do
|
||||
members=$(talosctl get members 2>/dev/null || true)
|
||||
missing=0
|
||||
for node in "${ALL_NODES[@]}"; do
|
||||
grep -q "$node" <<<"$members" || missing=1
|
||||
step 0 "Waiting for every node to come back configured..."
|
||||
# Asks each machine directly, by address, and expects it to answer with the
|
||||
# hostname its patch assigns. That is what has to be true before etcd is
|
||||
# bootstrapped: the config took, the node rebooted into it, and the machine
|
||||
# at that address is the one we think it is.
|
||||
#
|
||||
# This used to wait on `talosctl get members`, which was wrong twice over.
|
||||
# It queried through the configured endpoint list rather than each node, so
|
||||
# apid refused it with `PermissionDenied: no request forwarding` — the same
|
||||
# thing that broke the bootstrap call. And membership comes from the
|
||||
# discovery service, because the Kubernetes registry is disabled: a cold
|
||||
# rebuild would have been waiting on discovery.talos.dev being reachable,
|
||||
# before the cluster it is trying to rebuild even exists.
|
||||
#
|
||||
# Each node installs to disk and reboots first, so the window is generous.
|
||||
for idx in "${!ALL_NODES[@]}"; do
|
||||
want="${ALL_NODES[$idx]}"
|
||||
addr="${NODE_ADDRS[$idx]:-${want}.${DOMAIN}}"
|
||||
echo -n " $want ($addr)... "
|
||||
node_err=""
|
||||
for i in $(seq 1 60); do
|
||||
got=$(talosctl -n "$addr" -e "$addr" get hostname \
|
||||
-o jsonpath='{.spec.hostname}' 2>"$TALOS_GEN_DIR/hostname.err") || true
|
||||
node_err=$(cat "$TALOS_GEN_DIR/hostname.err")
|
||||
[[ "$got" == "$want" ]] && break
|
||||
sleep 10
|
||||
if [[ $i -eq 60 ]]; then
|
||||
echo
|
||||
[[ -n "$node_err" ]] && echo " last talosctl error: $node_err" >&2
|
||||
fail "$addr never came back as $want (last answer: '${got:-none}')"
|
||||
fi
|
||||
done
|
||||
[[ $missing -eq 0 ]] && break
|
||||
sleep 5
|
||||
[[ $i -eq 30 ]] && fail "Talos cluster didn't form"
|
||||
ok
|
||||
done
|
||||
|
||||
echo -n "Bootstrapping Kubernetes... "
|
||||
talosctl bootstrap 2>/dev/null || true
|
||||
# -n AND -e on one control plane, deliberately. etcd bootstrap is the one
|
||||
# call apid refuses to forward: reached through any other node it fails with
|
||||
# `PermissionDenied: no request forwarding`. Without them talosctl picks an
|
||||
# endpoint from the configured list — with more than one control plane, or a
|
||||
# list that includes workers, that is a coin flip.
|
||||
if [[ ${#NODE_ADDRS[@]} -gt 0 ]]; then
|
||||
bootstrap_target="${NODE_ADDRS[0]}"
|
||||
else
|
||||
bootstrap_target="${NODES[0]}.${DOMAIN}"
|
||||
fi
|
||||
# Re-running against an already-bootstrapped cluster is not an error and is
|
||||
# the reason for `|| true` — but that used to swallow every other reason as
|
||||
# well, and the run carried on to fail somewhere further downstream with no
|
||||
# trace of the actual cause. Anything that is not "already exists" is shown.
|
||||
if ! bootstrap_err=$(talosctl bootstrap -n "$bootstrap_target" -e "$bootstrap_target" 2>&1); then
|
||||
grep -qi 'already\|AlreadyExists' <<<"$bootstrap_err" \
|
||||
|| echo -e "\n${YELLOW}bootstrap reported:${NC} ${bootstrap_err//$'\n'/ | }"
|
||||
fi
|
||||
ok
|
||||
|
||||
echo -n "Getting kubeconfig... "
|
||||
kubeconfig_err=""
|
||||
for i in $(seq 1 30); do
|
||||
if talosctl kubeconfig ~/.kube/config --force 2>/dev/null; then
|
||||
sed -i "s|server:.*|server: https://${K8S_API_HOST}:6443|" ~/.kube/config
|
||||
mkdir -p "$(dirname "$KUBECONFIG_PATH")"
|
||||
if kubeconfig_err=$(talosctl kubeconfig "$KUBECONFIG_PATH" --force 2>&1); then
|
||||
sed -i "s|server:.*|server: https://${K8S_API_HOST}:6443|" "$KUBECONFIG_PATH"
|
||||
break
|
||||
fi
|
||||
sleep 5
|
||||
[[ $i -eq 30 ]] && fail "Couldn't get kubeconfig"
|
||||
# Held back while retrying — the first attempts fail by design — but
|
||||
# printed if it never works, because that message is the diagnosis.
|
||||
[[ $i -eq 30 ]] && { echo; echo "$kubeconfig_err" >&2; fail "Couldn't get kubeconfig"; }
|
||||
done
|
||||
ok
|
||||
|
||||
echo -n "Waiting for the Kubernetes API to answer... "
|
||||
# Having a kubeconfig is not the same as having an apiserver. `talosctl
|
||||
# kubeconfig` succeeds as soon as Talos can hand out credentials, which is
|
||||
# before kube-apiserver is serving on the endpoint the file points at — so
|
||||
# the very next command could fail with an EOF against a port nothing is
|
||||
# listening on yet. On hardware the gap usually closes on its own; on a
|
||||
# scratch cluster it does not, which is how this was found.
|
||||
# `get --raw /version` needs the server; `kubectl version` would answer from
|
||||
# the client alone and pass against nothing at all.
|
||||
api_err=""
|
||||
for i in $(seq 1 60); do
|
||||
api_err=$(kubectl get --raw /version 2>&1) && break
|
||||
sleep 5
|
||||
if [[ $i -eq 60 ]]; then
|
||||
echo "$api_err" >&2
|
||||
fail "Kubernetes API at https://${K8S_API_HOST}:6443 never answered"
|
||||
fi
|
||||
done
|
||||
ok
|
||||
|
||||
step 0 "Installing Cilium (CNI)..."
|
||||
# Must run before the node-Ready wait below: with `cni: none` the kubelet
|
||||
# reports NotReady until a CNI is present, so that loop would time out.
|
||||
#
|
||||
# Chart repo, version constraint and values all come out of the HelmRelease
|
||||
# so this cannot drift from what Flux will reconcile minutes later. helm
|
||||
# accepts the semver range verbatim and resolves it the same way Flux does.
|
||||
#
|
||||
# serviceMonitor is the one override, and it is a bootstrap constraint
|
||||
# rather than a value: its CRD arrives with the monitoring stack much
|
||||
# later, and the object would fail to apply. Nothing else is overridden,
|
||||
# which is what lets helm-controller adopt this release instead of
|
||||
# replacing it.
|
||||
CILIUM_VALUES=$(mktemp)
|
||||
yq 'select(.kind == "HelmRelease") | .spec.values' "$CILIUM_HR" > "$CILIUM_VALUES"
|
||||
grep -q 'policyMapMax' "$CILIUM_VALUES" \
|
||||
|| fail "extracted Cilium values look wrong (no policyMapMax) — check $CILIUM_HR"
|
||||
|
||||
helm repo add cilium "$(yq -r 'select(.kind == "HelmRepository") | .spec.url' "$CILIUM_HR")" >/dev/null
|
||||
helm repo update cilium >/dev/null
|
||||
helm upgrade --install cilium cilium/cilium \
|
||||
--version "$(yq -r 'select(.kind == "HelmRelease") | .spec.chart.spec.version' "$CILIUM_HR")" \
|
||||
--namespace kube-system \
|
||||
-f "$CILIUM_VALUES" \
|
||||
--set prometheus.serviceMonitor.enabled=false \
|
||||
--wait --timeout 10m
|
||||
rm -f "$CILIUM_VALUES"
|
||||
ok
|
||||
|
||||
step 0 "Waiting for K8s nodes..."
|
||||
expected=${#ALL_NODES[@]}
|
||||
nodes_err=""
|
||||
for i in $(seq 1 60); do
|
||||
ready=$(kubectl get nodes --no-headers 2>/dev/null | grep -c "Ready" || true)
|
||||
# The STATUS column, matched whole. `grep -c Ready` counted NotReady
|
||||
# too — the substring is in it — so this waited for nodes to register
|
||||
# rather than to become ready, and passed while every one of them was
|
||||
# still NotReady.
|
||||
nodes_out=$(kubectl get nodes --no-headers 2>"$TALOS_GEN_DIR/nodes.err") || true
|
||||
nodes_err=$(cat "$TALOS_GEN_DIR/nodes.err")
|
||||
ready=$(awk '$2 == "Ready"' <<<"$nodes_out" | grep -c . || true)
|
||||
if [[ "$ready" -eq "$expected" ]]; then
|
||||
break
|
||||
fi
|
||||
sleep 5
|
||||
[[ $i -eq 60 ]] && fail "Not all nodes ready (got $ready/$expected)"
|
||||
if [[ $i -eq 60 ]]; then
|
||||
echo
|
||||
[[ -n "$nodes_err" ]] && echo "last kubectl error: $nodes_err" >&2
|
||||
echo "${nodes_out:- (no nodes listed)}" >&2
|
||||
fail "Not all nodes ready (got $ready/$expected)"
|
||||
fi
|
||||
done
|
||||
|
||||
# Stash configs for subsequent phases / export-access step
|
||||
mkdir -p "$ACCESS_STAGING"
|
||||
cp ~/.talos/config "$ACCESS_STAGING/talosconfig"
|
||||
cp ~/.kube/config "$ACCESS_STAGING/kubeconfig"
|
||||
echo -e "${GREEN}Talos phase complete.${NC} Access files in $ACCESS_STAGING/"
|
||||
# Stash configs for subsequent phases / export-access step. Skipped under
|
||||
# vmtest: those are a throwaway cluster's credentials, and writing them here
|
||||
# would leave the flux phase pointed at VMs that no longer exist.
|
||||
if [[ "${VMTEST:-0}" == "1" ]]; then
|
||||
# Nodes being Ready says nothing about storage. The CephCluster picks
|
||||
# OSD nodes by a label out of the machine config and their device by the
|
||||
# partition label RawVolumeConfig creates, so assert both here — running
|
||||
# it by hand afterwards races the teardown, and a test that does not
|
||||
# check its own premise is worth little.
|
||||
step 0 "Checking the OSD prerequisites Ceph selects on..."
|
||||
for idx in $(seq "${#NODES[@]}" $(( ${#ALL_NODES[@]} - 1 ))); do
|
||||
vm_node="${ALL_NODES[$idx]}"
|
||||
vm_addr="${NODE_ADDRS[$idx]}"
|
||||
vols=$(talosctl -n "$vm_addr" -e "$vm_addr" get discoveredvolumes 2>&1) \
|
||||
|| fail "could not read volumes from $vm_node ($vm_addr): ${vols//$'\n'/ | }"
|
||||
awk '$NF == "r-ceph-osd" {found=1} END {exit !found}' <<<"$vols" \
|
||||
|| fail "$vm_node has no r-ceph-osd partition — RawVolumeConfig did not take"
|
||||
kubectl get node "$vm_node" \
|
||||
-o jsonpath='{.metadata.labels.storage\.renner\.uno/ceph-osd}' \
|
||||
| grep -q true \
|
||||
|| fail "$vm_node is missing the storage.renner.uno/ceph-osd label"
|
||||
echo -e " $vm_node: partition and label ${GREEN}(OK)${NC}"
|
||||
done
|
||||
|
||||
echo -e "${GREEN}VM rebuild test passed.${NC} ${#ALL_NODES[@]} nodes Ready on the repo's own config."
|
||||
else
|
||||
mkdir -p "$ACCESS_STAGING"
|
||||
cp "$TALOSCONFIG_PATH" "$ACCESS_STAGING/talosconfig"
|
||||
cp "$KUBECONFIG_PATH" "$ACCESS_STAGING/kubeconfig"
|
||||
echo -e "${GREEN}Talos phase complete.${NC} Access files in $ACCESS_STAGING/"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Helper: load staged access files into home dir (for later CI steps)
|
||||
@@ -228,7 +514,7 @@ if phase_runs flux; then
|
||||
flux bootstrap github \
|
||||
--owner="$GITHUB_OWNER" \
|
||||
--repository="$GITHUB_REPO" \
|
||||
--branch=master \
|
||||
--branch="$FLUX_BRANCH" \
|
||||
--path="$FLUX_PATH" \
|
||||
--components-extra=image-reflector-controller,image-automation-controller \
|
||||
--personal \
|
||||
@@ -239,7 +525,7 @@ if phase_runs flux; then
|
||||
--hostname="$GITEA_HOST" \
|
||||
--owner="$GITEA_OWNER" \
|
||||
--repository="$GITEA_REPO" \
|
||||
--branch=master \
|
||||
--branch="$FLUX_BRANCH" \
|
||||
--path="$FLUX_PATH" \
|
||||
--components-extra=image-reflector-controller,image-automation-controller \
|
||||
--personal \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -33,6 +33,13 @@ spec:
|
||||
remediation:
|
||||
retries: 3
|
||||
values:
|
||||
# THIS IS THE ONLY PLACE CILIUM IS CONFIGURED. Talos used to carry a
|
||||
# rendered copy as a bootstrap inline manifest, so every setting existed
|
||||
# two or three times and nothing enforced agreement — see
|
||||
# PROPOSAL-cilium-single-owner.md for what that cost. Talos now provides
|
||||
# only `cni: none` and `proxy: disabled`; a cold rebuild installs Cilium
|
||||
# from these values with helm before Flux takes over.
|
||||
#
|
||||
# Rolls the agent DaemonSet when cilium-config changes. Cilium reads
|
||||
# datapath options once at agent start and the chart puts no config
|
||||
# checksum on the pod template, so without this a changed ConfigMap leaves
|
||||
@@ -50,8 +57,6 @@ spec:
|
||||
# and every identity costs one entry per allowed (direction, proto, port)
|
||||
# in every endpoint's policy map, cluster-wide. Nothing selects on these
|
||||
# labels in policy, so they buy identity churn and nothing else.
|
||||
# Keep in lockstep with kubernetes/talos/patches/cilium-cni.yaml and
|
||||
# kubernetes/talos/cilium.yaml.
|
||||
labels: '!woodpecker-ci\.org/.* !step$'
|
||||
ipv6:
|
||||
enabled: true
|
||||
@@ -76,10 +81,6 @@ spec:
|
||||
# cilium_bpf_map_pressure. Sized at map creation, so it takes an agent
|
||||
# restart.
|
||||
#
|
||||
# THREE COPIES of this key exist — here, talos/patches/cilium-cni.yaml
|
||||
# and talos/cilium.yaml. The latter two are what a cold rebuild installs
|
||||
# from. Nothing enforces agreement; grep bpf-policy-map-max before
|
||||
# changing it.
|
||||
policyMapMax: 65536
|
||||
# VLAN ids the eBPF datapath is allowed to pass. Without this it drops
|
||||
# VLAN-tagged frames, accounted as "VLAN traffic disallowed by VLAN
|
||||
@@ -140,3 +141,28 @@ spec:
|
||||
enabled: true
|
||||
externalIPs:
|
||||
enabled: true
|
||||
# BGP to the UDM. The peering itself has always been in git as CRDs in
|
||||
# post-crds/cilium-bgp.yaml — but the switch that starts the control plane
|
||||
# was not, and neither were the CIDRs below. They were applied by hand and
|
||||
# held by the `kubectl-patch` field manager on cilium-config. Server-side
|
||||
# apply left them alone, so nothing broke and nothing noticed; a cold
|
||||
# rebuild would have come up with BGP off while the CRDs said otherwise.
|
||||
#
|
||||
# This is not a free change of ownership. Enabling bgpControlPlane through
|
||||
# the chart also emits companion keys the hand-patch never set —
|
||||
# bgp-secrets-namespace, bgp-router-id-allocation-mode and -ip-pool,
|
||||
# enable-bgp-control-plane-status-report, enable-bgp-legacy-origin-attribute
|
||||
# — so Cilium has been running on its own defaults for those. Router-id
|
||||
# allocation is the one to watch: if the chart's default disagrees with
|
||||
# Cilium's, the sessions re-establish.
|
||||
bgpControlPlane:
|
||||
enabled: true
|
||||
# Masquerading exception, not a routing statement: pod traffic to these
|
||||
# ranges leaves unmasqueraded, so LAN services see real pod IPs. That only
|
||||
# works because BGP advertises the pod CIDRs, which is what gives the UDM a
|
||||
# route back. The two settings are one decision and have to move together.
|
||||
#
|
||||
# Pod CIDRs are 10.244.0.0/16 and fd10:244::/48 (talos/patches/cluster.yaml);
|
||||
# the supernets here cover the LAN as well, which is the point.
|
||||
ipv4NativeRoutingCIDR: 10.0.0.0/8
|
||||
ipv6NativeRoutingCIDR: fd00::/8
|
||||
|
||||
@@ -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,51 @@ 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
|
||||
# netbird.renner.uno — management, signal, relay and TURN.
|
||||
#
|
||||
# Not optional once a bouncer is armed. Its chain sits on the INPUT
|
||||
# hook, the proxy hosts run no other firewall, and there is therefore
|
||||
# no conntrack rule accepting established flows ahead of it. A ban on
|
||||
# this address would drop the RETURN traffic of the proxies' own
|
||||
# outbound control connection and take them off the mesh — with no
|
||||
# inbound attack involved at all.
|
||||
#
|
||||
# Checked when this was added: none of these addresses appear in the
|
||||
# current decision set. That is the state today, not a guarantee —
|
||||
# these are rented addresses on a hoster whose ranges do end up on
|
||||
# community blocklists.
|
||||
- 162.55.50.97/32
|
||||
- 2a01:4f8:c2c:4579::1/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
|
||||
@@ -78,6 +123,24 @@ spec:
|
||||
|
||||
lapi:
|
||||
replicas: 1
|
||||
service:
|
||||
# Backend wiring for the public LAPI Ingress (lapi-ingress.yaml).
|
||||
#
|
||||
# These belong on the SERVICE, not on the Ingress: under the Kubernetes
|
||||
# Ingress provider `traefik.ingress.kubernetes.io/service.*` are service
|
||||
# annotations, and on an Ingress they are silently ignored. The symptom
|
||||
# is Traefik speaking plaintext to a TLS-only backend, which the LAPI
|
||||
# answers with "Client sent an HTTP request to an HTTPS server." and a
|
||||
# 400 — an error that looks like a malformed request rather than a
|
||||
# scheme mismatch.
|
||||
#
|
||||
# With tls.enabled the LAPI serves HTTPS on 8080 and offers no
|
||||
# plaintext port, so https here is mandatory rather than a hardening
|
||||
# choice. The ServersTransport carries the chart's own CA, since that
|
||||
# certificate is self-signed.
|
||||
annotations:
|
||||
traefik.ingress.kubernetes.io/service.serversscheme: https
|
||||
traefik.ingress.kubernetes.io/service.serverstransport: crowdsec-crowdsec-lapi@kubernetescrd
|
||||
# Far below the chart default, which reserves half a core per LAPI for a
|
||||
# workload that draws single-digit millicores — enough to leave the
|
||||
# cluster unschedulable with one node out. Only the reservation shrinks;
|
||||
@@ -86,10 +149,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 +189,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
|
||||
|
||||
@@ -2,4 +2,6 @@ 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,85 @@
|
||||
# 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
|
||||
rootCAs:
|
||||
- secret: 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.
|
||||
#
|
||||
# The backend scheme and ServersTransport are NOT set here. Under the
|
||||
# Kubernetes Ingress provider, `traefik.ingress.kubernetes.io/service.*`
|
||||
# are SERVICE annotations — on an Ingress they are silently ignored, and
|
||||
# Traefik then talks plaintext to a TLS-only backend. They live on the
|
||||
# chart's Service instead, via lapi.service.annotations in helmrelease.yaml.
|
||||
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
|
||||
@@ -33,3 +33,16 @@ spec:
|
||||
crds: CreateReplace
|
||||
remediation:
|
||||
retries: 3
|
||||
values:
|
||||
# The chart ships these disabled, so the controller ran with no probe of
|
||||
# any kind: a wedged reconciler stayed Ready and every ExternalSecret would
|
||||
# simply stop refreshing, which surfaces only when a consumer needs a
|
||||
# credential that has since rotated.
|
||||
#
|
||||
# Enabling them also starts the health server — the chart derives
|
||||
# --health-probe-bind-address from these values, and without them the
|
||||
# controller exposes nothing but its metrics port.
|
||||
livenessProbe:
|
||||
enabled: true
|
||||
readinessProbe:
|
||||
enabled: true
|
||||
|
||||
@@ -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
|
||||
+1
-2
@@ -1,5 +1,4 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- service.yaml
|
||||
- ingress.yaml
|
||||
- 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/
|
||||
|
||||
@@ -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:
|
||||
@@ -183,3 +176,15 @@ apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: woodpecker
|
||||
---
|
||||
# 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.
|
||||
#
|
||||
# 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: 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,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
|
||||
@@ -1,11 +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:
|
||||
- externalsecret.yaml
|
||||
- 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
|
||||
@@ -0,0 +1,128 @@
|
||||
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
|
||||
# Home Assistant's llama.cpp conversation agent calls the chat model only —
|
||||
# it has no use for the embedding endpoint, so unlike paperless above this
|
||||
# grant does not extend to llama-embed.
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": home-assistant
|
||||
app.kubernetes.io/name: home-assistant
|
||||
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 }
|
||||
@@ -167,6 +167,18 @@ spec:
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": influxdb
|
||||
# The cluster's own chat model, for the llama.cpp conversation agent that
|
||||
# backs Assist. Scoped to the one pod and port rather than the ai namespace
|
||||
# as a whole, so this grant cannot widen if something else is placed there —
|
||||
# the embedding endpoint next to it is deliberately not included.
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": ai
|
||||
app.kubernetes.io/name: llama-server
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "8080"
|
||||
protocol: TCP
|
||||
# traefik for east-west OIDC roundtrips that go through the
|
||||
# public hostname via k8s_gateway.
|
||||
- toEndpoints:
|
||||
|
||||
@@ -24,4 +24,5 @@ resources:
|
||||
- woodpecker-ns.yaml
|
||||
- trmnl-ns.yaml
|
||||
- wordpress-ns.yaml
|
||||
- ai-ns.yaml
|
||||
- allow-cilium-health.yaml
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -118,43 +118,76 @@ spec:
|
||||
# simultaneously — every PG inactive until they return. Binds any change
|
||||
# that rewrites the OSD deployment spec, not just version upgrades.
|
||||
osdMaxUpdatesInParallel: 1
|
||||
useAllNodes: false
|
||||
# Which machines carry storage is declared by the machines, as the node
|
||||
# label storage.renner.uno/ceph-osd (talos/patches/k8s-*.yaml), and read
|
||||
# here through placement.osd.nodeAffinity below. Listing hostnames here
|
||||
# instead said the same thing in a second place and tied this file to three
|
||||
# specific boxes: a cluster built anywhere else got no OSDs at all, however
|
||||
# its machines were equipped.
|
||||
#
|
||||
# useAllNodes only means "do not keep a node list here"; nodeAffinity is
|
||||
# what restricts OSDs, and without it this would be every node.
|
||||
useAllNodes: true
|
||||
useAllDevices: false
|
||||
nodes:
|
||||
- name: k8s-1
|
||||
devices:
|
||||
- fullpath: /dev/disk/by-id/nvme-SAMSUNG_MZQLB960HAJR-00007_S437NX0M400426
|
||||
- name: k8s-2
|
||||
devices:
|
||||
- fullpath: /dev/disk/by-id/nvme-SAMSUNG_MZQLB960HAJR-00007_S437NX0M403161
|
||||
- name: k8s-3
|
||||
devices:
|
||||
- fullpath: /dev/disk/by-id/nvme-SAMSUNG_MZQLB960HAJR-00007_S437NF0M501176
|
||||
# Two forms while the production drives are migrated:
|
||||
#
|
||||
# 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.
|
||||
# by-id/nvme-SAMSUNG… the three MS-01 drives as they are today:
|
||||
# bluestore written straight onto the raw disk,
|
||||
# with no partition table and therefore no
|
||||
# partition label to match. Matching by model
|
||||
# rather than by the three individual serials it
|
||||
# replaced — a drive swap is no longer a repo
|
||||
# edit. It cannot match the Kingston system disk
|
||||
# or the Crucial that carries EPHEMERAL.
|
||||
#
|
||||
# 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-ceph-osd|id/nvme-SAMSUNG_MZQLB960HAJR)"
|
||||
config:
|
||||
osdsPerDevice: "1"
|
||||
# Highest built-in class: kubelet evicts these last and the kernel gives
|
||||
# them the lowest oom_score_adj.
|
||||
priorityClassNames:
|
||||
all: system-node-critical
|
||||
# requests == limits gives Guaranteed QoS. CPU limits are deliberately NOT
|
||||
# set — Ceph daemons burst during recovery and a limit would throttle them.
|
||||
# Requests are what a daemon actually uses; limits stay where they were, as
|
||||
# burst headroom. The two were equal, for Guaranteed QoS, and the gap was
|
||||
# large enough to matter on machines where memory is the scarce resource:
|
||||
# measured against the running cluster, mon sat at ~420Mi against a 2Gi
|
||||
# request, mgr at ~650Mi against 4Gi, and the OSDs at ~2.2Gi against 4Gi —
|
||||
# about 17GiB reserved cluster-wide and never used. Only the requests moved.
|
||||
#
|
||||
# Guaranteed QoS is given up deliberately. What it bought was eviction order,
|
||||
# and priorityClassNames below already pins these to system-node-critical,
|
||||
# which the kubelet weighs first.
|
||||
#
|
||||
# CPU limits are deliberately NOT set — Ceph daemons burst during recovery and
|
||||
# a limit would throttle them.
|
||||
resources:
|
||||
mon:
|
||||
requests:
|
||||
cpu: 500m
|
||||
memory: 2Gi
|
||||
memory: 512Mi
|
||||
limits:
|
||||
memory: 2Gi
|
||||
osd:
|
||||
# The OSD request is not simply "what it uses": an OSD grows its cache
|
||||
# towards Ceph's own osd_memory_target, which defaults to 4Gi, so the
|
||||
# measured 2.2Gi is a floor and the 4Gi limit is what that tunable implies.
|
||||
# Lower the limit without lowering osd_memory_target and the OSD gets
|
||||
# OOM-killed once the cache fills.
|
||||
requests:
|
||||
cpu: 500m
|
||||
memory: 4Gi
|
||||
memory: 2560Mi
|
||||
limits:
|
||||
memory: 4Gi
|
||||
mgr:
|
||||
requests:
|
||||
cpu: 250m
|
||||
memory: 4Gi
|
||||
memory: 1Gi
|
||||
limits:
|
||||
memory: 4Gi
|
||||
placement:
|
||||
@@ -162,3 +195,16 @@ spec:
|
||||
tolerations:
|
||||
- effect: NoSchedule
|
||||
key: node-role.kubernetes.io/control-plane
|
||||
# The other half of selecting OSD nodes by label: with useAllNodes: true
|
||||
# this is the only thing keeping OSDs off machines that carry no OSD drive.
|
||||
# Removing it does not fall back to the old node list — it puts OSDs
|
||||
# everywhere.
|
||||
osd:
|
||||
nodeAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
nodeSelectorTerms:
|
||||
- matchExpressions:
|
||||
- key: storage.renner.uno/ceph-osd
|
||||
operator: In
|
||||
values:
|
||||
- "true"
|
||||
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user