Compare commits

..
Author SHA1 Message Date
claude-botandClaude Opus 5 d04b09310f feat(monitoring): add a disk health dashboard
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
Throughput, IOPS, mean operation latency, utilisation, queue depth and
controller temperature for all nine NVMe drives, plus Ceph's own OSD latency for
comparison against the block layer.

Every panel joins node_disk_* to node_nvme_info and node_uname_info rather than
plotting the device name. That is not decoration: nvme0 is the Kingston system
disk on k8s-1 and k8s-3 and the Crucial on k8s-2, so a legend keyed on the
device compares different hardware across nodes and does so silently. The
inventory table resolves the OSD column through ceph_disk_occupation for the
same reason — it follows Rook instead of restating a device list that goes stale
the next time a drive is swapped.

Every expression was run against Prometheus before being written into the JSON,
which is also how the caveats in the panel descriptions were found. Latency is
accumulated-time over completed-operations, so it is a mean with no tail and it
vanishes while a device is idle: absence of traffic, not absence of a problem.
Utilisation near 100% on NVMe does not mean saturation the way it does on a
spindle, which is why queue depth shares that panel.

Temperature is the only SMART-adjacent value present, because it is the only one
the kernel publishes through sysfs. Wear, written-bytes totals, power-on hours
and media errors need an NVMe admin ioctl and no exporter here produces them
(#549). The dashboard says so in a panel rather than letting a green screen imply
a healthy drive.

That gap is worse than #549 describes, and the note records why. The Crucial P3
drives were retired from Ceph because they were worn, and now carry the
EPHEMERAL volume — so the drives with the least endurance left are the ones
holding container state, and they are the ones nothing watches. The measured
write latency already separates them from the Samsung OSDs by more than an order
of magnitude.

Also fixes the add-a-dashboard instructions above the generator, whose third
step told the reader to tick a row off ../DASHBOARD-MIGRATION.md. That file does
not exist anywhere in the repo, so the step could not be followed.

Refs #549

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 14:02:11 +02:00
k.renner dd050d811d Merge pull request 'chore(ci): bump ci-claude image to 2.1.247' (#666) from ci-image/claude-2.1.247 into master
ci/woodpecker/push/ci-claude-refresh Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
ci/woodpecker/cron/e2e Pipeline was successful
Reviewed-on: k.renner/homelab#666
2026-08-27 11:51:32 +00:00
claude-bot c4bb35173d fix(ci): derive the ci-claude consumer list instead of maintaining it
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
review-bot found that this bump reached five of seven consumers: e2e.yml and
ci-scripts-test.yml stayed on the old version. Verified — both still pinned the
previous tag on the bump branch while the other five moved.

The cause is worth more than the symptom. ci-claude-refresh.yml carried the
consumer list twice, hardcoded, under the instruction "keep this list in sync
with the consumers". It was not in sync, and nothing could ever notice: the two
missing files were invisible to the bump that would have raised them and to the
guard that checks the pins agree. They were not drifting slowly, they were
unreachable — no future run would have touched them either.

So both copies are now derived by grepping .woodpecker for a version-pinned
reference. A list that must be maintained by hand is the defect; deriving it
removes the failure mode rather than correcting one instance of it.

The pattern deliberately matches a digit after the tag, which is what keeps this
file out of its own consumer set — its steps run ci-claude:latest, which never
needs rewriting. That makes the exclusion load-bearing, so it is spelled out in
a comment: a literal version anywhere in this file, prose included, would add it
to the set and let the sed rewrite it. The tag-scheme note at the top now says
<claude-version> for exactly that reason.

An empty result is a hard stop. Without it the following grep would be invoked
with no file arguments, read stdin, and hang rather than fail — a pipeline that
never finishes is worse than one that exits non-zero.

The generated PR body no longer claims a fixed "5 pipelines"; it counts.

Both files are also bumped here rather than left for a follow-up, and the order
matters: adding them to the consumer set without bumping them would make the
next refresh see two disagreeing tags and abort with "refusing to guess". That
guard is well built — it simply never saw these two files.

Refs #666
2026-08-27 13:19:41 +02:00
claude-bot a9df3ea9ce chore(ci): bump ci-claude image to 2.1.247
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
2026-08-27 11:12:42 +00:00
k.renner 2549535961 Merge pull request 'ci(kaniko): move the remaining eight builds to the maintained fork' (#665) from kaniko-fork-rest into master
ci/woodpecker/push/ci-claude-refresh Pipeline was successful
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#665
2026-08-27 11:11:22 +00:00
claude-botandClaude Opus 5 60f231f839 ci(kaniko): move the remaining eight builds to the maintained fork
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
ci/woodpecker/push/build-ci-images Pipeline was successful
Pipeline 6861 answered the question the staged first step was there to ask.
build-alpine-jq ran on the fork's v1.25.18, unpacked the rootfs, executed the
RUN, and pushed both destinations to the Gitea registry at digest a430c9c6 —
while the seven untouched kaniko steps stayed green in the same run. The
executor behaves the same and the registry auth path is unaffected, so the rest
can follow.

This moves the other seven steps here plus the one in ci-claude-refresh.yml.
Nothing but the image: lines change; no flags, no commands, no credentials.
That leaves zero gcr.io/kaniko-project references in the pipelines.

The per-step note from the first commit is gone. It described a migration state
that no longer exists, and a comment that survives its own situation is how the
velero schedule ended up claiming there was no CNPG cluster in a namespace that
had one. The durable half moved to the file header, where it explains why the
kaniko image is not Google's and why the pin is past the version their repo
ended on.

Also corrects the admission policy's comment, which cited
gcr.io/kaniko-project/executor as the image its exemption was verified against.
Worth being precise about what did not change: the policy matches on the buildx
plugin image only and never on kaniko, so the swap needed no policy edit — which
is exactly why the stale image reference in the prose was easy to miss.

Closes #552

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 13:03:18 +02:00
k.renner b03d0652c2 Merge pull request 'ci(kaniko): move alpine-jq to the maintained fork' (#664) from kaniko-replacement into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: k.renner/homelab#664
2026-08-27 10:58:49 +00:00
claude-botandClaude Opus 5 4e040c5a2d ci(kaniko): move alpine-jq to the maintained fork
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
#552 concluded that kaniko is terminal and that the nine build steps need
migrating to buildah or BuildKit. The premise is wrong. Google archived
GoogleContainerTools/kaniko on 2025-06-03, but the original authors forked it to
chainguard-dev/kaniko and kept shipping: releases run to v1.25.18, well past the
v1.24.0 the Google repo ended on. Upstream moved, it did not die.

So this is an image swap, not a migration. Same /kaniko/executor, same flags,
nothing else in the step changes. The fork publishes no binaries of its own, so
the image comes from gitlab-ci-utils, which builds it from that fork for amd64
and arm64 with versioned tags — the generic :debug tag exists too and is
deliberately not used, since every other image reference here is pinned.

Verified by pulling the image and running it rather than by reading its README:
/kaniko/executor is at the same path and reports v1.25.18, /bin/sh is a symlink
into busybox, and printf, base64, tr, mkdir and date are all present — the
utilities the auth and tag lines depend on. Anonymous pull works, so no registry
credential is needed for it.

Only this one step moves. A change to this file rebuilds every step regardless,
so a single run puts the new executor on alpine-jq beside seven unchanged kaniko
builds and shows both in the same log. alpine-jq is the smallest context here,
one FROM and one RUN, and the remaining seven plus ci-claude-refresh.yml follow
once this is green.

Two counts in #552 are also wrong and worth correcting there: the pin was
v1.23.2-debug everywhere, not v1.24.0-debug — #551 bumped alpine, not kaniko —
and there are eight steps in this file plus one in ci-claude-refresh.yml, so
nine invocations rather than ten.

Refs #552

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 12:54:24 +02:00
k.renner 900bc73635 Merge pull request 'docs: propose separating the four traffic classes onto their own VLANs' (#663) from proposal-traffic-classes into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#663
2026-08-27 10:26:58 +00:00
k.renner a65109fee0 Merge pull request 'docs(runbook): drop the claim that a cold rebuild needs console access' (#662) from runbook-remote-rebuild-possible into master
Reviewed-on: k.renner/homelab#662
2026-08-27 10:26:30 +00:00
k.renner 477b79cdc6 Merge pull request 'fix: [alert] VeleroBackupFailed (velero) (#657)' (#659) from alert-fix/issue-657 into master
Reviewed-on: k.renner/homelab#659
2026-08-27 10:26:03 +00:00
claude-botandClaude Opus 5 f51eb8bf35 docs(runbook): drop the claim that a cold rebuild needs console access
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
The rewrite earlier today stated, at the top and again in known gaps, that the
rebuild cannot be driven remotely: management is a tagged VLAN, a maintenance
mode node cannot configure VLANs, so it comes up unreachable and every machine
needs a keyboard.

That is wrong. A maintenance-mode node DHCPs untagged and is reachable at that
address — it is not on management, but it answers, and that is precisely the
address `apply-config --insecure` targets in Step 2. All three bare-metal
conversions were driven that way.

The claim came from #576 and was carried into this document without being
checked against what had actually happened days earlier in this repo. It is the
worst place to put an unverified constraint: a disaster-recovery runbook is read
when there is no capacity to discover that a stated prerequisite is fiction, and
"you need physical access to three machines" is exactly the kind of statement
that changes whether someone attempts a recovery at all.

What survives is narrower and true: MS-01 has no IPMI and nothing here does
PXE, so booting from USB is physical. Everything after the boot is remote.

Also removes the follow-on error from the first correction, which claimed the
switch port change on 2026-08-27 had fixed a reachability problem. There was no
reachability problem to fix. That change is real and useful — a maintenance mode
node now lands in management rather than the fallback network — but it did not
turn an impossible rebuild into a possible one.

#576 still carries the original claim and is flagged in known gaps as wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 11:25:50 +02:00
issue-fix-bot 205d7835c3 fix: [alert] VeleroBackupFailed (velero) (#657)
ci/woodpecker/pr/ci-manifests-test Pipeline is pending
ci/woodpecker/pr/ci-scripts-test Pipeline is pending
ci/woodpecker/pr/pr-review Pipeline is pending
2026-08-27 07:46:20 +00:00
14 changed files with 374 additions and 60 deletions
+1 -1
View File
@@ -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.218
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.247
environment:
CLAUDE_CODE_OAUTH_TOKEN:
from_secret: claude_code_oauth_token
+19 -8
View File
@@ -1,6 +1,17 @@
# Builds the CI tooling images (and the secret-rotator runtime image) with
# kaniko and pushes them to the Gitea container registry.
#
# The kaniko image is NOT Google's. Google archived
# GoogleContainerTools/kaniko on 2025-06-03, but the original authors forked
# it to chainguard-dev/kaniko and kept releasing — the pin here is well past
# the v1.24.0 the Google repo ended on. The fork ships no binaries or images
# of its own, so these come from gitlab-ci-utils, which builds that fork for
# amd64 and arm64 under versioned tags. A generic `:debug` tag exists there
# and is deliberately unused; everything else in this repo is pinned too.
#
# The executor path, flags and busybox layout are unchanged from Google's
# image, which is why the swap touched nothing but the `image:` lines (#552).
#
# Credential: ci_images_token — a PAT scoped to `package: read+write` and
# NOTHING else (verified: 403 on /user/repos, /admin/users and the issues
# API; 200 on the /v2/ registry endpoints kaniko uses). It replaces the
@@ -25,7 +36,7 @@ when:
steps:
- name: build-ansible
image: gcr.io/kaniko-project/executor:v1.23.2-debug
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
directory: ci/images/ansible
depends_on: []
# Only rebuild when THIS image's source (or the pipeline file) changed, or
@@ -53,7 +64,7 @@ steps:
--destination gitea.renner.uno/gitea_admin/ci-ansible:latest
- name: build-k8s
image: gcr.io/kaniko-project/executor:v1.23.2-debug
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
directory: ci/images/k8s
depends_on: []
when:
@@ -77,7 +88,7 @@ steps:
--destination gitea.renner.uno/gitea_admin/ci-k8s:latest
- name: build-tofu
image: gcr.io/kaniko-project/executor:v1.23.2-debug
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
directory: ci/images/tofu
depends_on: []
when:
@@ -108,7 +119,7 @@ steps:
# Runtime image, not CI tooling: a static musl binary in a FROM-scratch image,
# used as the base for secret-rotation CronJobs. Replaces per-job inline Python.
- name: build-secret-rotator
image: gcr.io/kaniko-project/executor:v1.23.2-debug
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
directory: secret-rotator
depends_on: []
when:
@@ -137,7 +148,7 @@ steps:
# container, but nothing about it is zigbee-specific. Exists so tools come from
# the image rather than an `apk add` on every container start.
- name: build-alpine-jq
image: gcr.io/kaniko-project/executor:v1.23.2-debug
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
directory: ci/images/alpine-jq
depends_on: []
when:
@@ -166,7 +177,7 @@ steps:
# critical path of every node rebuild. The download now happens here, once,
# where the sha256 is checked — see the Containerfile for the full rationale.
- name: build-cni-plugins
image: gcr.io/kaniko-project/executor:v1.23.2-debug
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
directory: ci/images/cni-plugins
depends_on: []
when:
@@ -195,7 +206,7 @@ steps:
# every paperless pod start. The openssl CLI is genuinely absent from the alpine
# base, so this one needs an image rather than a busybox substitute.
- name: build-alpine-openssl
image: gcr.io/kaniko-project/executor:v1.23.2-debug
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
directory: ci/images/alpine-openssl
depends_on: []
when:
@@ -224,7 +235,7 @@ steps:
# Ansible converge. Not a ci-* image -- it is a runtime artifact -- but it
# builds the same way and lands in the same registry so the audit sees it.
- name: build-heating-can
image: gcr.io/kaniko-project/executor:v1.23.2-debug
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
directory: ci/images/heating-can
depends_on: []
when:
+25 -18
View File
@@ -18,7 +18,7 @@
# consumer pins to ci-claude:<version> and open a PR. A human
# reviews and merges it; nothing auto-merges.
#
# TAG SCHEME — deliberately version tags (ci-claude:2.1.218), NOT the dated
# TAG SCHEME — deliberately version tags (ci-claude:<claude-version>), NOT the dated
# tags the other ci-* images use. The tag encodes the thing that changes here
# (the Claude version), which makes "did it change?" a trivial, race-free
# string compare and keeps the weekly cron from opening no-op PRs. The other
@@ -84,14 +84,24 @@ steps:
- |
set -eu
mkdir -p "$$CI_WORKSPACE/.ci-refresh"
# Files that pin the image. Keep this list in sync with the consumers.
CONSUMERS="
.woodpecker/security-scan.yml
.woodpecker/alert-monitor.yml
.woodpecker/update-audit.yml
.woodpecker/pr-review.yml
.woodpecker/issue-fix.yml
"
# Files that pin the image, DISCOVERED rather than listed. This used to
# be a hardcoded list carrying the instruction "keep this in sync with
# the consumers" — and it was not: e2e.yml and ci-scripts-test.yml were
# missing, so they sat on a version no bump could ever reach. A list
# that must be maintained by hand is the bug; deriving it removes the
# failure mode instead of correcting one instance of it.
#
# Matches a version-pinned reference only, which is what excludes this
# file: its own steps run ci-claude:latest, and :latest never needs
# rewriting. That exclusion is load-bearing — do not write a literal
# ci-claude version anywhere in THIS file, not even in prose, or it
# adds itself to the consumer set and the sed below rewrites it. The
# tag-scheme note at the top says <claude-version> for that reason.
CONSUMERS=$$(grep -rlE 'ci-claude:[0-9]' .woodpecker)
# An empty list would make the grep below read stdin and hang forever
# rather than fail, so it is a hard stop.
[ -n "$$CONSUMERS" ] || { echo "No ci-claude consumers found — refusing to continue."; exit 1; }
echo "consumers: $$CONSUMERS"
NEW=$$(npm view @anthropic-ai/claude-code version)
case "$$NEW" in
[0-9]*.[0-9]*.[0-9]*) : ;;
@@ -130,7 +140,7 @@ steps:
# --build-arg to the resolved version (not "latest") guarantees the tag and
# the image content agree even if a new release lands mid-run.
- name: build
image: gcr.io/kaniko-project/executor:v1.23.2-debug
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
directory: ci/images/claude
depends_on: [decide]
environment:
@@ -176,13 +186,10 @@ steps:
echo "Branch $$BRANCH already exists — a PR for $$NEW is already open. Nothing to do."
exit 0
fi
CONSUMERS="
.woodpecker/security-scan.yml
.woodpecker/alert-monitor.yml
.woodpecker/update-audit.yml
.woodpecker/pr-review.yml
.woodpecker/issue-fix.yml
"
# Same discovery as the decide step — see the comment there for why this
# is not a maintained list.
CONSUMERS=$$(grep -rlE 'ci-claude:[0-9]' .woodpecker)
[ -n "$$CONSUMERS" ] || { echo "No ci-claude consumers found — refusing to continue."; exit 1; }
# Exact-string swap of the current tag for the new one, consumers only.
for f in $$CONSUMERS; do
sed -i "s#ci-claude:$$CUR#ci-claude:$$NEW#g" "$$f"
@@ -198,7 +205,7 @@ steps:
{
printf 'Automated bump of the ci-claude agent image from `%s` to `%s`.\n\n' "$$CUR" "$$NEW"
printf 'The refresh pipeline built and pushed `ci-claude:%s` (and `:latest`) from the\n' "$$NEW"
printf 'latest published `@anthropic-ai/claude-code`. This rewires the 5 pipelines that\n'
printf 'latest published `@anthropic-ai/claude-code`. This rewires the %s pipelines that\n' "$$(printf '%s\n' $$CONSUMERS | grep -c .)"
printf 'run the image so CI stops running a stale Claude Code.\n\n'
printf 'Merging is a human decision — review, then merge to roll it out.\n'
} > /tmp/pr-body.md
+1 -1
View File
@@ -17,6 +17,6 @@ when:
steps:
- name: model-selection-tests
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.218
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.247
commands:
- sh scripts/ci/test-model-selection.sh
+1 -1
View File
@@ -25,7 +25,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.218
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.247
commands:
- sh scripts/ci/test-model-selection.sh
+1 -1
View File
@@ -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.218
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.247
environment:
CLAUDE_CODE_OAUTH_TOKEN:
from_secret: claude_code_oauth_token
+1 -1
View File
@@ -65,7 +65,7 @@ when:
steps:
- name: pr-review-agent
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.218
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.247
environment:
CLAUDE_CODE_OAUTH_TOKEN:
from_secret: claude_code_oauth_token
+1 -1
View File
@@ -42,7 +42,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.218
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.247
environment:
CLAUDE_CODE_OAUTH_TOKEN:
from_secret: claude_code_oauth_token
+1 -1
View File
@@ -30,7 +30,7 @@ when:
steps:
- name: update-audit-agent
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.218
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.247
environment:
CLAUDE_CODE_OAUTH_TOKEN:
from_secret: claude_code_oauth_token
+21 -17
View File
@@ -11,11 +11,14 @@ are no VMs to recreate, and OpenTofu owns only DNS, object storage and UniFi.
Step 1 is therefore two unrelated halves — a `tofu apply` for the platform
resources, and physically booting three machines from install media.
**Read this first: the rebuild cannot be done remotely today.** Management is a
tagged VLAN, and a node in maintenance mode has no VLAN configuration — it can
only DHCP untagged, so it comes up unreachable. MS-01 has no IPMI. Every node
therefore needs a keyboard and a monitor until #576 puts VLAN 40 native on the
I226-V port. Plan for physical access before starting.
**Someone has to be at the machines to boot them from USB** — MS-01 has no IPMI
and nothing here does PXE. Everything after that is remote.
A node in maintenance mode configures no VLANs and DHCPs untagged, so it comes
up on whatever untagged network its ports carry — not on management, which is
a tagged VLAN on the X710 trunk. It is reachable there: that is the address
`apply-config --insecure` talks to in Step 2, and it is how all three
bare-metal conversions were driven. Find the leases on the UDM.
## The circular-bootstrap problem this runbook exists to break
@@ -77,9 +80,10 @@ These must exist outside the cluster or the rebuild cannot complete:
Proxmox credentials — there is no Proxmox any more.
- [ ] **Install media** written and to hand: the Image Factory ISO named in
Step 1, on a USB stick, for all three machines.
- [ ] **Physical access to all three nodes** — keyboard and monitor. Not
optional today: see the note at the top. Budget for standing in front of
the machines, not for driving this from a desk.
- [ ] **Someone at the machines to boot them from USB.** No IPMI, no PXE. A
monitor is useful for reading boot errors but is not required to find the
node afterwards — it DHCPs into management on its own (see the note at
the top). Everything past the boot is remote.
## Step 1a — tofu: DNS, object storage, UniFi
@@ -105,9 +109,9 @@ https://factory.talos.dev/image/0f6fb8bd8bb53013e9743b7c7b099d96fb9c32fd2464ced3
```
Write it to a USB stick and boot each MS-01 from it. Each comes up in
maintenance mode with a DHCP address on the **untagged** network — not on
management, because that is a tagged VLAN a maintenance-mode node does not
configure. Read the address off the console; that is what Step 2 talks to.
maintenance mode with a DHCP address on an untagged network — not on
management, which is tagged. Read the leases off the UDM; those addresses are
what Step 2 applies the first config to.
The schematic `0f6fb8bd…` carries `net.ifnames=1` — without it the predictable
interface names that `talos/patches/k8s-{1,2,3}.yaml` match their VLAN parents
@@ -326,12 +330,12 @@ it: repo → **Settings → Repository → Mirror Settings** in Gitea, or
in-cluster commits made **after** Flux/Gitea broke are not on the mirror
(Gitea couldn't push them if Gitea itself was down). This is the intended
recovery point, but be aware of it when reconciling drift.
- **The rebuild cannot be driven remotely.** Management is a tagged VLAN and a
maintenance-mode node cannot configure VLANs, so a freshly booted machine is
unreachable until its config is applied — and MS-01 has no IPMI. Every node
needs a keyboard and a monitor. #576 fixes this by putting VLAN 40 native on
the I226-V port; until then, physical access is a hard prerequisite rather
than a convenience.
- **Booting is physical; the rest is not.** MS-01 has no IPMI and nothing here
does PXE, so someone has to put a USB stick in three machines. From there it
is remote — a maintenance-mode node DHCPs untagged and is reachable at that
address, which is how all three bare-metal conversions were driven.
#576 claims a freshly booted node "comes up unreachable and must be installed
from a console". That is not what happens and has never been observed here.
- **`bootstrap.sh talos` is dead** — it cannot generate a config (#581), and
Step 2 no longer uses it. Its `flux` and `wait-apps` phases are still used in
Steps 3, 5 and 6 and work; do not read this as the whole script being gone.
@@ -0,0 +1,270 @@
{
"annotations": {"list": []},
"description": "NVMe throughput, latency and temperature per node. Every panel joins node_disk_* to node_nvme_info and node_uname_info on purpose: device names are NOT stable across nodes (nvme0 is the Kingston system disk on k8s-1 and k8s-3, but the Crucial on k8s-2), so a legend keyed on the device alone compares different hardware. Endurance/wear is deliberately absent — see the note at the bottom and issue #549.",
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 1,
"id": null,
"links": [],
"panels": [
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"description": "Which physical drive is which, resolved live rather than written down. The OSD column comes from ceph_disk_occupation, so it follows Rook instead of restating a device list that would go stale the next time a drive is swapped.",
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"custom": {"align": "auto", "cellOptions": {"type": "auto"}, "inspect": false},
"mappings": [],
"thresholds": {"mode": "absolute", "steps": [{"color": "text", "value": null}]}
},
"overrides": []
},
"gridPos": {"h": 9, "w": 24, "x": 0, "y": 0},
"id": 1,
"options": {"cellHeight": "sm", "footer": {"countRows": false, "fields": "", "reducer": ["sum"], "show": false}, "showHeader": true, "sortBy": [{"desc": false, "displayName": "node"}]},
"targets": [
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"expr": "node_nvme_info * on (instance) group_left(nodename) node_uname_info",
"format": "table",
"instant": true,
"legendFormat": "",
"refId": "A"
}
],
"title": "Drive inventory",
"transformations": [
{
"id": "organize",
"options": {
"excludeByName": {"Time": true, "Value": true, "__name__": true, "cntlid": true, "container": true, "endpoint": true, "instance": true, "job": true, "namespace": true, "pod": true, "service": true, "domainname": true, "machine": true, "release": true, "sysname": true, "version": true, "state": true},
"indexByName": {},
"renameByName": {"nodename": "node", "device": "device", "model": "model", "serial": "serial", "firmware_revision": "firmware"}
}
}
],
"type": "table"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"description": "Read and write throughput per drive.",
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {"axisCenteredZero": false, "axisColorMode": "text", "axisPlacement": "auto", "barAlignment": 0, "drawStyle": "line", "fillOpacity": 10, "gradientMode": "none", "hideFrom": {"legend": false, "tooltip": false, "viz": false}, "lineInterpolation": "smooth", "lineWidth": 2, "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": "Bps"
},
"overrides": []
},
"gridPos": {"h": 9, "w": 12, "x": 0, "y": 9},
"id": 2,
"options": {"legend": {"calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
"targets": [
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"expr": "label_replace(rate(node_disk_read_bytes_total{device=~\"nvme[0-9]+n[0-9]+\"}[$__rate_interval]), \"nvmedev\", \"$1\", \"device\", \"(nvme[0-9]+)n[0-9]+\") * on (instance, nvmedev) group_left(model) label_replace(node_nvme_info, \"nvmedev\", \"$1\", \"device\", \"(nvme[0-9]+)\") * on (instance) group_left(nodename) node_uname_info",
"legendFormat": "read {{nodename}} {{model}}",
"refId": "A"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"expr": "label_replace(rate(node_disk_written_bytes_total{device=~\"nvme[0-9]+n[0-9]+\"}[$__rate_interval]), \"nvmedev\", \"$1\", \"device\", \"(nvme[0-9]+)n[0-9]+\") * on (instance, nvmedev) group_left(model) label_replace(node_nvme_info, \"nvmedev\", \"$1\", \"device\", \"(nvme[0-9]+)\") * on (instance) group_left(nodename) node_uname_info",
"legendFormat": "write {{nodename}} {{model}}",
"refId": "B"
}
],
"title": "Throughput",
"type": "timeseries"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"description": "Completed operations per second. Read together with throughput this separates many small operations from few large ones.",
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {"axisCenteredZero": false, "axisColorMode": "text", "axisPlacement": "auto", "barAlignment": 0, "drawStyle": "line", "fillOpacity": 10, "gradientMode": "none", "hideFrom": {"legend": false, "tooltip": false, "viz": false}, "lineInterpolation": "smooth", "lineWidth": 2, "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": "iops"
},
"overrides": []
},
"gridPos": {"h": 9, "w": 12, "x": 12, "y": 9},
"id": 3,
"options": {"legend": {"calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
"targets": [
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"expr": "label_replace(rate(node_disk_reads_completed_total{device=~\"nvme[0-9]+n[0-9]+\"}[$__rate_interval]), \"nvmedev\", \"$1\", \"device\", \"(nvme[0-9]+)n[0-9]+\") * on (instance, nvmedev) group_left(model) label_replace(node_nvme_info, \"nvmedev\", \"$1\", \"device\", \"(nvme[0-9]+)\") * on (instance) group_left(nodename) node_uname_info",
"legendFormat": "read {{nodename}} {{model}}",
"refId": "A"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"expr": "label_replace(rate(node_disk_writes_completed_total{device=~\"nvme[0-9]+n[0-9]+\"}[$__rate_interval]), \"nvmedev\", \"$1\", \"device\", \"(nvme[0-9]+)n[0-9]+\") * on (instance, nvmedev) group_left(model) label_replace(node_nvme_info, \"nvmedev\", \"$1\", \"device\", \"(nvme[0-9]+)\") * on (instance) group_left(nodename) node_uname_info",
"legendFormat": "write {{nodename}} {{model}}",
"refId": "B"
}
],
"title": "IOPS",
"type": "timeseries"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"description": "Mean service time per operation, derived as accumulated time divided by completed operations. This is an average and will not show a tail — a drive can look healthy here while a small fraction of operations stall. The line disappears while a device is idle, because the denominator is zero; that is absence of traffic, not absence of a problem.",
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {"axisCenteredZero": false, "axisColorMode": "text", "axisPlacement": "auto", "barAlignment": 0, "drawStyle": "line", "fillOpacity": 10, "gradientMode": "none", "hideFrom": {"legend": false, "tooltip": false, "viz": false}, "lineInterpolation": "smooth", "lineWidth": 2, "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": "s"
},
"overrides": []
},
"gridPos": {"h": 9, "w": 12, "x": 0, "y": 18},
"id": 4,
"options": {"legend": {"calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
"targets": [
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"expr": "label_replace(rate(node_disk_read_time_seconds_total{device=~\"nvme[0-9]+n[0-9]+\"}[$__rate_interval]) / rate(node_disk_reads_completed_total{device=~\"nvme[0-9]+n[0-9]+\"}[$__rate_interval]), \"nvmedev\", \"$1\", \"device\", \"(nvme[0-9]+)n[0-9]+\") * on (instance, nvmedev) group_left(model) label_replace(node_nvme_info, \"nvmedev\", \"$1\", \"device\", \"(nvme[0-9]+)\") * on (instance) group_left(nodename) node_uname_info",
"legendFormat": "read {{nodename}} {{model}}",
"refId": "A"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"expr": "label_replace(rate(node_disk_write_time_seconds_total{device=~\"nvme[0-9]+n[0-9]+\"}[$__rate_interval]) / rate(node_disk_writes_completed_total{device=~\"nvme[0-9]+n[0-9]+\"}[$__rate_interval]), \"nvmedev\", \"$1\", \"device\", \"(nvme[0-9]+)n[0-9]+\") * on (instance, nvmedev) group_left(model) label_replace(node_nvme_info, \"nvmedev\", \"$1\", \"device\", \"(nvme[0-9]+)\") * on (instance) group_left(nodename) node_uname_info",
"legendFormat": "write {{nodename}} {{model}}",
"refId": "B"
}
],
"title": "Mean operation latency",
"type": "timeseries"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"description": "Fraction of wall-clock time the device had at least one operation in flight, and the number of operations queued. Utilisation near 100% on an NVMe drive does not mean saturation the way it does on a spindle — these devices serve many operations in parallel — so read it alongside the queue depth.",
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {"axisCenteredZero": false, "axisColorMode": "text", "axisPlacement": "auto", "barAlignment": 0, "drawStyle": "line", "fillOpacity": 10, "gradientMode": "none", "hideFrom": {"legend": false, "tooltip": false, "viz": false}, "lineInterpolation": "smooth", "lineWidth": 2, "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": "percentunit"
},
"overrides": [
{
"matcher": {"id": "byRegexp", "options": "queue .*"},
"properties": [{"id": "unit", "value": "short"}, {"id": "custom.axisPlacement", "value": "right"}]
}
]
},
"gridPos": {"h": 9, "w": 12, "x": 12, "y": 18},
"id": 5,
"options": {"legend": {"calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
"targets": [
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"expr": "label_replace(rate(node_disk_io_time_seconds_total{device=~\"nvme[0-9]+n[0-9]+\"}[$__rate_interval]), \"nvmedev\", \"$1\", \"device\", \"(nvme[0-9]+)n[0-9]+\") * on (instance, nvmedev) group_left(model) label_replace(node_nvme_info, \"nvmedev\", \"$1\", \"device\", \"(nvme[0-9]+)\") * on (instance) group_left(nodename) node_uname_info",
"legendFormat": "busy {{nodename}} {{model}}",
"refId": "A"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"expr": "label_replace(node_disk_io_now{device=~\"nvme[0-9]+n[0-9]+\"}, \"nvmedev\", \"$1\", \"device\", \"(nvme[0-9]+)n[0-9]+\") * on (instance, nvmedev) group_left(model) label_replace(node_nvme_info, \"nvmedev\", \"$1\", \"device\", \"(nvme[0-9]+)\") * on (instance) group_left(nodename) node_uname_info",
"legendFormat": "queue {{nodename}} {{model}}",
"refId": "B"
}
],
"title": "Utilisation and queue depth",
"type": "timeseries"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"description": "Composite temperature reported by each controller, read from hwmon in sysfs. This is the one health value available without an admin ioctl, which is why it is the only SMART-adjacent series on this dashboard.",
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {"axisCenteredZero": false, "axisColorMode": "text", "axisPlacement": "auto", "barAlignment": 0, "drawStyle": "line", "fillOpacity": 10, "gradientMode": "none", "hideFrom": {"legend": false, "tooltip": false, "viz": false}, "lineInterpolation": "smooth", "lineWidth": 2, "pointSize": 5, "scaleDistribution": {"type": "linear"}, "showPoints": "never", "spanNulls": false, "stacking": {"group": "A", "mode": "none"}, "thresholdsStyle": {"mode": "dashed"}},
"mappings": [],
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}, {"color": "orange", "value": 70}, {"color": "red", "value": 80}]},
"unit": "celsius"
},
"overrides": []
},
"gridPos": {"h": 9, "w": 12, "x": 0, "y": 27},
"id": 6,
"options": {"legend": {"calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
"targets": [
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"expr": "label_replace(node_hwmon_temp_celsius{chip=~\"nvme_nvme[0-9]+\", sensor=\"temp1\"}, \"nvmedev\", \"$1\", \"chip\", \"nvme_(nvme[0-9]+)\") * on (instance, nvmedev) group_left(model) label_replace(node_nvme_info, \"nvmedev\", \"$1\", \"device\", \"(nvme[0-9]+)\") * on (instance) group_left(nodename) node_uname_info",
"legendFormat": "{{nodename}} {{model}}",
"refId": "A"
}
],
"title": "Controller temperature",
"type": "timeseries"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"description": "Ceph's own view of the OSDs, for comparison with the block-layer numbers above. A divergence between these and the device latency is the interesting case: it points at Ceph rather than at the hardware.",
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {"axisCenteredZero": false, "axisColorMode": "text", "axisPlacement": "auto", "barAlignment": 0, "drawStyle": "line", "fillOpacity": 10, "gradientMode": "none", "hideFrom": {"legend": false, "tooltip": false, "viz": false}, "lineInterpolation": "smooth", "lineWidth": 2, "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": "ms"
},
"overrides": []
},
"gridPos": {"h": 9, "w": 12, "x": 12, "y": 27},
"id": 7,
"options": {"legend": {"calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
"targets": [
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"expr": "ceph_osd_apply_latency_ms",
"legendFormat": "apply {{ceph_daemon}}",
"refId": "A"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"expr": "ceph_osd_commit_latency_ms",
"legendFormat": "commit {{ceph_daemon}}",
"refId": "B"
}
],
"title": "Ceph OSD latency",
"type": "timeseries"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"gridPos": {"h": 8, "w": 24, "x": 0, "y": 36},
"id": 8,
"options": {
"code": {"language": "plaintext", "showLineNumbers": false, "showMiniMap": false},
"content": "### What this dashboard cannot tell you\n\n**Endurance is missing.** There is no wear, no written-bytes total, no power-on hours and no media-error count here, because no exporter in this cluster produces them. NVMe reports those over an admin ioctl that needs elevated privileges, and temperature is the only health value the kernel publishes through sysfs. Tracked as **#549** — do not read a healthy dashboard as a healthy drive.\n\n**The three roles are not interchangeable.** Each node carries a Kingston OM8PGP41024Q as the Talos system disk, a Samsung PM983 that Rook claims as the Ceph OSD, and a Crucial P3 that was retired from Ceph and now carries the EPHEMERAL volume. The Crucial drives were replaced *because* they were worn, so the drive with the least endurance left is the one holding container state — and it is the one nothing is watching.\n\n**Device names are not identity.** `nvme0` is the Kingston on k8s-1 and k8s-3 and the Crucial on k8s-2. Every panel here resolves the model through `node_nvme_info` for that reason; a query written against a bare device name compares different hardware and will keep doing so silently.",
"mode": "markdown"
},
"title": "Gaps and caveats",
"type": "text"
}
],
"preload": false,
"refresh": "1m",
"schemaVersion": 39,
"tags": ["storage", "nvme", "ceph"],
"templating": {"list": []},
"time": {"from": "now-6h", "to": "now"},
"timepicker": {},
"timezone": "",
"title": "Disk Health",
"uid": "disk-health",
"version": 1,
"weekStart": ""
}
@@ -7,9 +7,12 @@ namespace: monitoring
# the kube-prometheus-stack Grafana sidecar watches for.
#
# To add a dashboard:
# 1. Copy the ported JSON to this directory as <name>.json
# 1. Copy the JSON to this directory as <name>.json
# 2. Add a configMapGenerator entry below referencing the file
# 3. Tick off the row in ../DASHBOARD-MIGRATION.md
#
# There used to be a third step, ticking a row off ../DASHBOARD-MIGRATION.md.
# That file no longer exists anywhere in the repo — the port it tracked is
# finished — so following the instruction was impossible.
#
# Dashboard datasource UIDs must match K8s Grafana datasources:
# - prometheus (kube-prom-stack default, in-cluster)
@@ -35,6 +38,9 @@ configMapGenerator:
- name: dashboard-blackbox-overview
files:
- blackbox-overview.json
- name: dashboard-disk-health
files:
- disk-health.json
- name: dashboard-gitea-overview
files:
- gitea-overview.json
@@ -12,9 +12,11 @@
# * host namespaces + hostPath volumes: denied for every step pod (neither
# kaniko nor the buildx plugin needs them).
# * privileged containers: denied EXCEPT the allow-listed buildx plugin image.
# Verified non-breaking: this repo's own builds use rootless kaniko
# (gcr.io/kaniko-project/executor) which needs none of the above; only the
# external buildx plugin needs privileged, and it is allow-listed by image ref.
# Verified non-breaking: this repo's own builds use rootless kaniko, which needs
# none of the above; only the external buildx plugin needs privileged, and it is
# allow-listed by image ref. The kaniko image moved off gcr.io to the maintained
# chainguard-dev fork in #552 — no policy change was needed, because nothing
# here matches on the kaniko image, only on the buildx one.
#
# ValidatingAdmissionPolicy is GA / in-tree on the cluster's Kubernetes v1.35 —
# no external admission controller (Kyverno/Gatekeeper) is introduced.
@@ -203,16 +203,16 @@ metadata:
labels:
app.kubernetes.io/part-of: homelab-backup
spec:
# Daily at 06:45 local (next free stagger slot). All nine PVCs live
# in the home-assistant namespace regardless of which app/ dir owns
# Daily at 06:45 local (next free stagger slot). The app PVCs live in
# the home-assistant namespace regardless of which app/ dir owns
# their manifest (matter-server, otbr, whisper, speech-to-phrase,
# zone-configurator all set `namespace: home-assistant` via their
# Kustomization, not per-resource): home-assistant-config (.storage
# entity registry, recorder DB, secrets.yaml), esphome-data,
# shelly-manager-data, matter-server-data, otbr-data, whisper-data,
# speech-to-phrase-models, speech-to-phrase-train,
# zone-configurator-config. No CNPG cluster in this namespace, so no
# label exclusion is needed. On restore, home-assistant-config has its
# speech-to-phrase-models, speech-to-phrase-train, zigbee2mqtt-data,
# zwavejs-store, zone-configurator-config. On restore,
# home-assistant-config has its
# own path (the HA pod's `seed` initContainer pulling the native HAOS
# backup from Garage, see the home-assistant Kustomization comment in
# kubernetes/clusters/homelab/home-assistant.yaml); layering a Velero
@@ -229,6 +229,20 @@ spec:
storageLocation: default
ttl: 720h0m0s
snapshotMoveData: true
# The ha-pg CNPG cluster now runs in this namespace (it did not when
# this schedule was written — the old comment here still claimed
# "no CNPG cluster in this namespace"). Its PVC ha-pg-1 carries the
# cnpg.io/cluster label and was being swept in: a crash-consistent
# snapshot of a running Postgres data directory is not a restorable
# backup, the same trap the wordpress-daily comment below describes.
# ha-pg is covered by Barman — daily base backup at 02:50 UTC
# (kubernetes/apps/home-assistant/ha-pg-scheduled-backup.yaml) plus
# continuous WAL archiving — so this exclusion removes a redundant
# 20 GiB upload, matching paperless/authentik/immich/trmnl above.
labelSelector:
matchExpressions:
- key: cnpg.io/cluster
operator: DoesNotExist
---
apiVersion: velero.io/v1
kind: Schedule