Compare commits

...
Author SHA1 Message Date
claude-botandClaude Opus 5 32b5cacb37 fix(llama-server): one name, since a second cannot win the id
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
#732 reversed the alias order on the theory that llama-server reports the last
entry as the `id`. It does not, and the endpoint still advertised
id="chat" after the rollout.

server-models.cpp keeps aliases in a std::set, so they are sorted and the first
becomes the id. "chat" sorts before "qwen3.6-35b-a3b" in either flag order, so
the descriptive name could never win while "chat" was configured.

Dropping it costs nothing, which the previous two attempts missed. A second name
would only earn its place as a stable handle for machine consumers, and there is
nothing to stabilise: a single-model server ignores the request's `model` field
entirely. Verified twice against the live endpoint — "voellig-erfundenes-modell-
xyz" and "irgendein-quatsch-123" both returned normal completions reporting
"model":"chat". No consumer config can break on a model swap, alias or not.

The e2e check is rewritten accordingly: asserting a substring appears in the
response cannot see which name holds the id, so it now asserts there is exactly
one name and no second one competing for it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 17:32:35 +02:00
k.renner b846b7fa26 Merge pull request 'fix(llama-server): descriptive name as the advertised id, plus the AI e2e suite' (#732) from llama-alias-order into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#732
2026-09-01 15:28:23 +00:00
claude-botandClaude Opus 5 b46635d94e fix(llama-server): put the descriptive name last so it becomes the id
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
llama-server reports the LAST entry of --alias as the model's `id` and lists the
rest under `aliases`. With `qwen3.6-35b-a3b,chat` the endpoint therefore
advertises:

  "id":"chat","aliases":["chat","qwen3.6-35b-a3b"]

which shows `chat` in a picker — the opposite of what #729 intended. Reversing
the list makes the descriptive name the id and keeps `chat` available.

The e2e check missed this because it asserted the string appeared anywhere in
the response, and every configured name appears in `aliases` whatever the order.
It now asserts the id specifically.

Also records what the endpoint does with these names: nothing routes on them. A
single-model server ignores the request's `model` field and answers to any
string — confirmed live against "voellig-erfundenes-modell-xyz", which returned
a completion reporting "model":"chat". The names exist to be advertised on
/v1/models, which is what a client reads to build its picker.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 17:26:14 +02:00
claude-botandClaude Opus 5 b11cff0f10 test(e2e): assert the alias where it is observable, not by posting to it
The check posted a completion under the descriptive model name and asserted a
reply came back. That proves nothing: a single-model llama-server ignores the
request's `model` field entirely. Confirmed against the live endpoint — it
answers to "voellig-erfundenes-modell-xyz" and reports "model":"chat" — so the
check passed whether or not the alias existed.

/v1/models is the only place the alias is observable, and it is what a client
reads to populate a picker, so both names are asserted there instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 17:25:37 +02:00
claude-botandClaude Opus 5 b4a1224804 test(e2e): regression suite for the local AI endpoint
Every defect this stack shipped so far passed manifest validation and a 200
status code, and was only visible by using the endpoint. So the checks assert
behaviour, not liveness:

  - the embedding model is absent from the chat endpoint, and /v1/embeddings is
    not reachable through the ingress. It used to share the port, appeared in
    the chat picker, and answered chat completions with fluent nonsense rather
    than an error, because --embeddings does not refuse them.
  - a completion returns non-empty content and finish_reason "stop". With
    thinking on by default the model spent its whole budget on
    reasoning_content and returned an empty answer inside a 200 response, which
    a status-code assertion passes.
  - the answer is coherent (knows Paris). llama-bench measures throughput and
    would report excellent numbers for a backend emitting garbage.
  - both the descriptive id and the `chat` handle route to the model.
  - llama-embed serves a vector, reached from the monitoring namespace that
    ai-ns.yaml admits, since that Service has no ingress by design.
  - the Deployment still holds its gpu.intel.com/i915 allocation. Without it the
    pod runs happily on CPU and only a throughput measurement would notice.

Lives in e2e-test.sh rather than scripts/ci/: these need a running model, and
the scripts/ci suites are contractually offline. It therefore runs on the daily
and weekly crons via e2e.yml and on the manual e2e-test target.

Two helpers added alongside the existing ones: llm_chat POSTs through the
ingress, helper_post POSTs from inside e2e-helper for Services with no ingress.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 17:25:37 +02:00
k.renner a43717c3a4 Merge pull request 'feat(scripts): capability eval to compare models on our own workloads' (#731) from ai-eval into master
Reviewed-on: k.renner/homelab#731
2026-09-01 15:23:26 +00:00
k.renner e740e9b444 Merge pull request 'feat(netbird): drop the cluster-side backup of the controller VPS' (#730) from remove-netbird-backup into master
ci/woodpecker/push/deploy-netbird-server Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#730
2026-09-01 14:57:18 +00:00
k.renner 10ed11c260 Merge pull request 'fix(llama-server): name the model, keep chat as a machine handle' (#729) from llama-model-alias into master
Reviewed-on: k.renner/homelab#729
2026-09-01 14:57:10 +00:00
claude-botandClaude Opus 5 7c41a21f88 feat(netbird): drop the cluster-side backup of the controller VPS
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Backing up an external server is that server's own business. The pull path
made the cluster responsible for it and, worse, put a root key on the VPS
that the cluster held — a standing inbound grant that existed only to move
a SQLite file.

Removed cluster-side: the netbird-backup namespace and everything in it
(the nightly CronJob, the netbird-backup-ssh ExternalSecret, the 5Gi
ceph-block PVC), the namespace's entry in the OpenBao ClusterSecretStore
allow-list, the netbird-backup-daily Velero Schedule, and the
NetbirdVpsBackupStale / NetbirdVpsBackupNeverRan alerts.

Flux prunes the PVC, which destroys the 14 retained dumps (2026-08-15
through 08-28). That is intended. Velero's existing offsite copies age out
on their own under the schedule's 720h TTL; no cleanup is needed there.

Removed VPS-side: the dump script, and the forced-command key authorising
it. Deleting the two tasks that placed them would only stop ansible
managing them and leave both live on the host, so they are replaced with
state: absent tasks that actually revoke them. Those two can go once the
role has run against every host that ever carried them.

The trmnl-daily schedule's stagger comment pointed at netbird-backup-daily
for its slot; it now points at apps-daily, which is the schedule that
actually precedes it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 16:55:53 +02:00
claude-botandClaude Opus 5 f26d84e869 fix(llama-server): name the model, keep chat as a machine handle
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
`chat` as the sole alias hid which model was answering — in the picker, in
logs, and in any response body — and would have been ambiguous the moment a
second chat model existed.

--alias takes a comma-separated list, so both properties are available at once:
qwen3.6-35b-a3b is what callers see and says what is actually running, while
chat stays valid as a stable handle for Home Assistant, Paperless and CI, whose
config should not need an edit on the next model swap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 16:55:01 +02:00
14 changed files with 140 additions and 285 deletions
@@ -1,36 +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 (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
# NetBird sets no journal_mode on the store (its DSN carries only
# _busy_timeout and cache=shared), so store.db uses the default rollback
# journal, where the management container's write lock blocks a reader
# outright rather than letting it through as WAL would. Two things follow.
#
# .timeout arms a busy handler on the source connection, which is the
# contended side; the destination is a fresh file in $tmp that nothing else
# touches. Without it sqlite3 uses its 0 ms default and gives up the moment
# it cannot take the lock. 30 s sits well inside the puller's 600 s
# activeDeadlineSeconds.
#
# VACUUM INTO rather than .backup: the shell's .backup copies 100 pages per
# sqlite3_backup_step(), and SQLite restarts the copy whenever the source is
# written between steps, so a steady writer drags it out for as long as the
# writes keep coming. VACUUM INTO reads one transaction in a single pass and
# is documented as an alternative to the backup API for exactly this. It
# also compacts, so the dump is smaller than the live file.
sqlite3 -cmd '.timeout 30000' /opt/netbird/management/store.db \
"VACUUM INTO '$tmp/store.db'"
tar -C "$tmp" -czf - store.db
+12 -19
View File
@@ -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:
+17 -5
View File
@@ -90,12 +90,24 @@ spec:
# to it server-side; the WebUI is same-origin and unaffected.
- --cors-origins
- localhost
# Stable name for the API, so consumers ask for "chat" rather than
# the full repo string. Without it the model id is the value of
# --hf-repo, and every consumer's config would break the next time
# the model is swapped.
# 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
- chat
- 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"
@@ -13,7 +13,6 @@ resources:
- prometheusrule-loki.yaml
- prometheusrule-mariadb-backup.yaml
- prometheusrule-netbird-availability.yaml
- prometheusrule-netbird-backup.yaml
- prometheusrule-netbird-ingress.yaml
- prometheusrule-openbao.yaml
- prometheusrule-velero.yaml
@@ -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."
+109
View File
@@ -524,6 +524,115 @@ 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.
# ---------------------------------------------------------------------------
# llm_chat: POST a chat completion through the ingress. Body lands in
# /tmp/llm_body for the caller to assert against. Generous --max-time: the
# model runs on an iGPU at ~12 tok/s, so a short answer still takes seconds.
llm_chat() {
local model="$1" prompt="$2" max="${3:-64}" out body code payload
payload=$(jq -nc --arg m "$model" --arg p "$prompt" --argjson n "$max" \
'{model:$m,messages:[{role:"user",content:$p}],max_tokens:$n,temperature:0}')
out=$(curl -sk --resolve "llm.renner.uno:443:$LB_IP" --max-time 300 \
-H 'Content-Type: application/json' -d "$payload" \
-w $'\n__CODE__:%{http_code}' \
"https://llm.renner.uno/v1/chat/completions" 2>&1)
code=${out##*__CODE__:}
body=${out%$'\n'__CODE__:*}
printf 'code=%s body=%s\n' "$code" "${body:0:300}"
[[ "$code" == "200" ]] || return 1
printf '%s' "$body" > /tmp/llm_body
}
export -f llm_chat
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
echo "=== Local AI inference ==="
check "llama-server /health returns 200" \
http_code "https://llm.renner.uno/health" 200
# The chat model is named for what it is, so a picker and a log line say which
# model answered. `chat` stays valid alongside it as a stable handle, so a
# consumer's config survives the next model swap.
# 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" \
http_body "https://llm.renner.uno/v1/models" '"id":"qwen3.6-35b-a3b"'
# 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 ClusterIP-only Service; if it ever reappears
# here, it is selectable again and this must fail.
check "embedding model absent from chat endpoint" \
bash -c '! curl -sk --resolve "llm.renner.uno:443:$LB_IP" --max-time 15 \
"https://llm.renner.uno/v1/models" | grep -qi "embed"'
check "embeddings not exposed through the ingress" \
http_code "https://llm.renner.uno/v1/embeddings" 501
# 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 chat "Nenne die Hauptstadt von Frankreich." 64 &&
jq -e ".choices[0].message.content | length > 0" /tmp/llm_body >/dev/null'
check "chat completion stops rather than truncating" \
bash -c 'jq -e ".choices[0].finish_reason == \"stop\"" /tmp/llm_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/llm_body | grep -qi paris'
# 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 'test "$(curl -sk --resolve "llm.renner.uno:443:$LB_IP" --max-time 15 \
"https://llm.renner.uno/v1/models" | jq -r ".data[0].aliases | length")" = "1"'
# 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
@@ -18,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:
@@ -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
@@ -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