Compare commits

...
Author SHA1 Message Date
claude-bot 6ffbc468d7 docs(infra): trim the remaining oversized comments
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
crowdsec, netbird-ingress, woodpecker-ns, san-network-attachment, cilium-bgp
and the openbao rotation kustomization. Comments only; the diff filtered for
non-comment lines is empty and infrastructure/ still builds.

Every instruction survives, because that is the half worth having: do not
switch crowdsec back to password auth, or a wiped LAPI crashloops the agents
and wedges the Kustomization; do not move netbird-ingress off the rootless
image, or socket-LB stops seeing its traffic and the VIPs blackhole; do not
pin the rotators by digest, or a quarterly job fails to pull a manifest that
was garbage-collected months earlier and looks like it simply did not run.

What went is the incident each was learned from, and one duplicate: the
san-network-attachment header re-derived the provider: host argument that
cephcluster.yaml already makes, so it now points there instead.

Two more stale claims found on the way through:

  - woodpecker-ns.yaml described the namespace as holding wp-pg alone with
    'server and agent arriving in stage 2'. Both have been running there for
    some time.
  - cilium-bgp.yaml said each node sources its BGP session from eth0. The
    management interface is mgmt0; eth0 was the VM-era name.

infrastructure/ is now 2553 comment lines.
2026-08-28 06:50:54 +02:00
claude-bot 8c7fd25e03 docs(infra): trim the cilium and traefik helm release comments
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
cilium 86 -> 63 comment lines, traefik 71 -> 59. Comments only; the diff
filtered for non-comment lines is empty and both files still parse.

Cilium keeps everything that is a trap rather than a story: that bpf-policy-map-max
and vlan-bpf-bypass each exist in three files with nothing enforcing agreement,
that both take an agent restart, that a full policy map surfaces as a pod stuck
in sandbox creation, and why only VLAN 30 belongs in the bypass list. What went
is how each was diagnosed — the Hubble traces, the manual agent deletions, the
device-by-device walk through `cilium-dbg status`.

Traefik keeps the version ceiling and the reason it exists, and keeps the
CrowdSec bouncer note as an instruction: do not re-attach until client-IP
visibility is fixed and a LAN whitelist exists. It loses the retelling of the
outage that produced that instruction.

The traefik header was outright wrong and is the reason this file was worth
opening. It described Traefik as running "alongside ingress-nginx during the
migration", called ingress-nginx "the default IngressClass" and Traefik
"opt-in via ingressClassName: traefik". There is one IngressClass in the
cluster and it is traefik; ingress-nginx has no deployment and no class. A
reader following that comment would have added an ingressClassName they do not
need and looked for a controller that is not there.
2026-08-28 06:47:39 +02:00
k.renner 7f11867407 Merge pull request 'docs(netpol): deduplicate the home-assistant policy comments' (#684) from trim-comments-netpol into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#684
2026-08-28 04:45:23 +00:00
claude-bot c3bad3bece docs(netpol): deduplicate the home-assistant policy comments
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
152 comment lines to 125 — a much lighter cut than the previous batches, on
purpose. Most of this file is per-rule security rationale, and that is exactly
the category worth keeping: someone deleting the :8124 rule needs to know it is
the trust anchor for X-Forwarded-For, and someone deleting a gate rule needs to
know it is what stands between an --auth=none editor and the LAN.

What was cut is repetition. Four near-identical paragraphs explained the
Authentik gate pattern once per app, three of them re-deriving that
endpointSelector {} makes a :8080 grant namespace-wide. That warning is now
stated once, at the first :8080 rule, and it is stronger for being in one
place: three apps share that port, so changing any of those rules affects all
three.

The world-ingress and ICMP blocks keep their rules and lose their incident
reports — do not add a blanket fromEntities: world to an endpointSelector {}
policy; ICMP needs an explicit allow once any L4 rule exists and Cilium takes
one type per entry.

Two stale references went with it. Egress-to-world was documented as reaching
"garage S3" and "legacy InfluxDB on VM 108": Garage was retired to nas-1 and
there is no Proxmox left to host VM 108. Both replaced by what the rule
actually grants rather than a list of destinations that has already changed.

Comments only; the diff filtered for non-comment lines is empty, and the file
still passes a server-side dry run.
2026-08-28 06:43:03 +02:00
k.renner 68672d3fb9 Merge pull request 'docs(infra): cut the narration out of cephcluster and the velero schedules' (#683) from trim-comments-infra into master
ci/woodpecker/cron/issue-fix Pipeline was successful
Reviewed-on: k.renner/homelab#683
2026-08-28 04:41:06 +00:00
claude-bot 8ae1cc7187 docs(infra): cut the narration out of cephcluster and the velero schedules
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
Comments only — filtering the diff for non-comment lines returns empty, and
both files still parse and build.

cephcluster.yaml goes from 224 comment lines to 76. Kept: that `ceph config`
retains values which merely disappear from this file, so deleting a line does
not restore a default and a live provider change needs `ceph config rm` first;
that osdMaxUpdatesInParallel and upgradeOSDRequiresHealthyPGs are the only
things keeping a rolling OSD change off all three replicas at once; that
`addressRanges` drags the mons onto host networking and ties them to a DHCP
lease; that devices are named by-id because kernel names move between boots;
that the bluestore deferral settings must not be raised again.

Cut from it: the paxos write-amplification measurement down to the byte, the
history of why mon_data_avail_warn was once 20, and a table of every drive
serial in the fleet sitting three lines above the by-id paths that already
name them.

velero-schedules.yaml loses a per-PVC inventory of the home-assistant namespace
— eleven names, which had already been wrong once by two — in favour of saying
the schedule sweeps the namespace.

Two stale claims went with it. Both files said PVC contents are uploaded to
Garage; the BackupStorageLocation reads bucket renner-uno-k8s-velero at
fsn1.your-objectstorage.com, and Garage was retired to nas-1. And the ha-pg
exclusion still explained itself by quoting the comment it had replaced.
2026-08-28 06:39:44 +02:00
k.renner 633a141bac Merge pull request 'docs(talos): cut the narration out of the machine config patches' (#680) from trim-comments-talos into master
Reviewed-on: k.renner/homelab#680
2026-08-28 04:34:43 +00:00
claude-bot ed86592978 docs(talos): cut the narration out of the machine config patches
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
242 comment lines out, 91 in, across the four hand-written Talos patches. The
diff touches nothing but comments — filtering it for non-comment lines returns
empty.

Kept, because each changes what someone editing that line would do: select
disks by serial or the installer takes the OSD drive; EPHEMERAL volume config
applies only on first provision and is silently ignored afterwards; the
LinkAliasConfig attempt that left a node with no address on any interface;
kube-apiserver not following a node re-IP; the fallback-network links must stay
v6-disabled or the cluster loses IPv6 egress; the installer image is Image
Factory and not the stock one, and its tag must match the version being upgraded
to.

Cut: how each of those was discovered. The four-equal-priority-routes
investigation, the 30 hours of NetBird probes, the image-GC measurement on
k8s-2 down to the percentage point, the per-extension rationale for the
schematic. All of it is in the commit history and the wiki.

Cut as stale rather than merely long, which is the point of doing this at all:

  - k8s-1.yaml opened by saying k8s-2 and k8s-3 are still Proxmox VMs. All
    three have been bare metal since neptune's conversion; DMI reports
    Minisforum on every one.
  - two files justified a disk selector with "Talos and Proxmox do not agree
    on that order". There is no Proxmox.
  - three files carried per-drive wear figures (87%, 81%, 83%) and one an
    exact media-error count. Those are measurements, they move, and a config
    file never gets re-read when they do.
  - the mgmt0 note explained VLAN 40 by way of which address the k8s-1 VM
    used to reach over that port.

One thing removed here has no other home: whether k8s-1's 18 media errors are
historical or ongoing was written up in this file and nowhere else, and #549 is
closed. Tracking it separately rather than losing it.
2026-08-28 06:27:57 +02:00
k.renner a8c2f79f84 Merge pull request 'fix: [alert] NvmeDriveWearHigh (monitoring) (#675)' (#679) from alert-fix/issue-675 into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#679
2026-08-28 04:21:02 +00:00
k.renner 9f10ea793d Merge pull request 'docs: cut the essays out of the manifests I wrote today' (#678) from trim-manifest-comments into master
Reviewed-on: k.renner/homelab#678
2026-08-28 04:20:33 +00:00
issue-fix-bot bdffe1804b fix: [alert] NvmeDriveWearHigh (monitoring) (#675)
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
2026-08-27 19:45:14 +00:00
claude-bot da1482e33f docs: cut the essays out of the manifests I wrote today
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
Config files were carrying investigation write-ups: two ruled-out hypotheses in
the smartctl-exporter securityContext, a twenty-line canonical note on the
Hetzner credential, the history of an alert threshold. That is derivation, not
configuration, and it belongs where it does not rot — the commit message for how
we got here, the wiki for what holds.

The distinction this applies: a comment stays if it changes what someone editing
that line would do — a constraint of the tool, a trap, a pointer to an issue. It
goes if it narrates how a conclusion was reached, restates a measurement, or
justifies a value after the fact. The test is whether the surrounding config
could change and leave the comment quietly wrong.

160 lines out, 30 in. Nothing behavioural changed; both manifests still pass a
server-side dry run.

Alert descriptions were tightened rather than removed. Those are not comments —
they are what someone reads when paged, and an essay is worse there than
anywhere.

This covers only what I added today. The repo has roughly 8600 comment lines
with 88 files over 25, and going further needs the criterion above agreed
first — much of that volume is load-bearing warning rather than narration.
2026-08-27 21:37:20 +02:00
k.renner c6b1aa109b Merge pull request 'refactor(secrets): drop the unused barman-cloud-creds' (#677) from drop-unused-barman-creds into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#677
2026-08-27 19:20:01 +00:00
claude-bot 2f3d952656 refactor(secrets): drop the unused barman-cloud-creds
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
Three ExternalSecrets synced a Secret that nothing reads. Established rather
than assumed, from three directions: every live ObjectStore names
hetzner-s3-creds, no CNPG Cluster uses the legacy inline barmanObjectStore
(all seven run the barman-cloud plugin against an ObjectStore CR), and no pod
in the cluster mounts barman-cloud-creds.

They are almost certainly left from before the plugin migration, when the
inline configuration read a Secret of that name.

Worth stating plainly because it is not merely untidy: seed/{authentik,immich,
paperless}/barman-creds hold a Hetzner S3 credential that is NOT the one in
use. Fingerprint c2c5cf3e against 87954b64 for the live hetzner-s3 path, with
the three copies identical to each other. A valid key with no consumer is
exposure without benefit, so if it is a distinct S3 key it wants revoking at
Hetzner, which only a human can do — deleting our copies does not.

Removing the manifests does NOT remove the objects. All three carry
kustomize.toolkit.fluxcd.io/prune: disabled from the seed->app-KS handoffs, so
Flux skips them during garbage collection by design. The ExternalSecrets, their
Secrets and the three OpenBao paths have to be deleted explicitly after this
merges; git alone leaves them live and orphaned, which is worse than leaving
them declared.

The OpenBao paths deliberately stay until then. Deleting them first would leave
three ExternalSecrets failing to sync against a key that no longer exists, for
no gain.
2026-08-27 21:18:08 +02:00
k.renner 0a87f91116 Merge pull request 'refactor(secrets): one OpenBao path for the one Hetzner S3 credential' (#676) from hetzner-creds-shared-path into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#676
2026-08-27 19:04:23 +00:00
claude-bot c59f0050b3 refactor(secrets): one OpenBao path for the one Hetzner S3 credential
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
#397 asked for the opposite: give woodpecker its own path so a rotation of
paperless's credentials would stop silently changing woodpecker's. The premise
does not hold. There is one Hetzner S3 key, and rotating it at Hetzner changes
it for every consumer no matter how many paths hold a copy. A fifth copy would
have bought nothing and made the separation look real.

Seven ExternalSecrets were reading it from four paths — seed/paperless (four
namespaces), seed/authentik, seed/immich, seed/trmnl. They now all read
infrastructure/hetzner-s3, which sits beside the existing
infrastructure/velero-hetzner.

That the four paths are byte-identical was verified rather than assumed, and
without reading them: SHA-256 over the three fields of each synced Secret
returns the same fingerprint in all seven namespaces. The same check says
infrastructure/velero-hetzner carries the same access key id, in a different
shape — a single `cloud` field holding an AWS credentials file.

Velero is deliberately not folded in. Moving it means templating that INI from
these fields, which is worth doing and not worth bundling with a path swap that
touches every database backup at once.

The reasoning lives once, in the paperless manifest, with the others pointing at
it. Repeating it six times is how six copies drift.

Kept from the note this replaces, because it was right and cost someone an
afternoon: the 403 recorded in #397 was a wrong-path error, not missing
permissions. The mount is `homelab`, so the path is homelab/seed/... —
`bao kv list homelab/seed` works with the identity available here.

homelab/infrastructure/hetzner-s3 now exists, copied from the paperless path
inside OpenBao so the value never left it, and fingerprints identical to its
source. Writes turned out to be possible all along: they fail through
openbao.renner.uno with a response the client cannot parse, and succeed against
the Service. That is worth knowing before the next person concludes, as I first
did, that the identity is read-only.

Closes #397
2026-08-27 21:02:28 +02:00
k.renner 48c46610a6 Merge pull request 'docs(monitoring): stop treating a worn EPHEMERAL drive as a hazard' (#674) from ephemeral-framing into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#674
2026-08-27 18:38:33 +00:00
claude-botandClaude Opus 5 53887dd88f docs(monitoring): stop treating a worn EPHEMERAL drive as a hazard
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
I described the worn drives carrying EPHEMERAL as the dangerous placement,
repeatedly, in the alert text and in the dashboard's caveats. That reads the
volume backwards. EPHEMERAL is Talos's /var and its contents are meant to be
disposable — container images, kubelet state, pod scratch, spegel and Rook's
local state all rebuild. Putting the drives with the least endurance left there
is a sensible destination, not a risk someone should be warned about.

The alert said "a node simply stops when it goes", which is simply wrong. A node
losing /var rebuilds it.

What survives the correction is narrower and checked rather than recalled:
/var/lib/etcd is on that volume too, so losing EPHEMERAL on a control-plane node
costs that member's data. Quorum holds at two of three and the member is removed
and re-added. That is a rebuild, not data loss, and the alert now says so at
that weight.

The fill-level panel keeps its warning but for the right reason: a full /var
stops a node being useful long before anything is lost, and nothing rebalances
it the way Ceph rebalances a full OSD. That is a capacity argument, not an
endurance one.

None of this changes the monitoring — measuring every drive is still right, and
the wear figures are unchanged. It changes what a reader is told the numbers
mean, which is the part that was misleading.

Refs #549

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 20:35:11 +02:00
k.renner c9a10ab015 Merge pull request 'feat(monitoring): put the SMART values on the dashboard' (#672) from disk-dashboard-smart into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#672
2026-08-27 13:27:36 +00:00
claude-botandClaude Opus 5 24b2a0178d feat(monitoring): put the SMART values on the dashboard
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 exporter reports all nine drives now, so the dashboard shows what it
measures: a worst-wear stat in the status row, wear per drive over time, and a
table of the failure-now signals — available spare, media errors, the critical
warning bitfield, power-on time and written bytes.

Every expression was run against the live TSDB and the label names read off real
series rather than a README. That mattered: the exporter labels devices `nvme0`
and carries model and serial on a separate `smartctl_device` info metric, so
each query joins on (node, device) to pull the model in. A legend on the device
name alone would compare different hardware across machines, which is the same
trap the block-layer panels already avoid.

Wear and damage are presented as different things because they are. The wear
percentage is a budget the drive spends and says nothing about tomorrow; spare
exhaustion, media errors and the critical warning mean something is wrong now.
The caveats panel says so, and no longer claims endurance is missing — it was
missing when that text was written and is not any more.

Also fixes the OSD stat, which still rendered two identical numbers side by
side. The commit that fixed it did not make it into #668 before that PR merged,
so the panel went out in the state that prompted the complaint. It now shows
OSDs down alone; up-but-out is a state rather than a fault and Ceph warns about
it separately.

Refs #549

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 15:26:09 +02:00
k.renner b8061ea05d Merge pull request 'fix(monitoring): privileged is the mechanism, not the shortcut' (#671) from smartctl-privileged into master
Reviewed-on: k.renner/homelab#671
2026-08-27 13:19:38 +00:00
claude-botandClaude Opus 5 d0bea0dc15 fix(monitoring): privileged is the mechanism, not the shortcut
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
Third attempt, and the first two were wrong for the same reason: I kept looking
for the gate in the capability and syscall layers, and it was never there.

Attempt one gave the container CAP_SYS_ADMIN and CAP_SYS_RAWIO with everything
else dropped. It found all three drives per node and could open none of them.
Attempt two added seccompProfile: Unconfined, reasoning that EPERM is the
default action for a syscall an unlisted profile blocks and that the kubelet
runs seccompDefault: true. /proc/1/status confirmed both capabilities effective
and a filter loaded, so the theory was at least testable — and removing the
filter changed nothing.

What settled it was a smaller test than either: `head -c 512 /dev/nvme0n1`
inside the container also fails with EPERM. Reading a block device involves no
ioctl, no capability and nothing a seccomp profile filters. The denial is the
device cgroup, which containerd applies to every non-privileged container
regardless of whether /dev is mounted. Mounting /dev makes the nodes visible;
it does not make them usable.

Kubernetes has no per-device allowlist in a Pod spec — no equivalent of
`docker --device`. So the honest position is that `privileged: true` here is
not a lazy shortcut standing in for a narrower setting; it is the only
mechanism the API offers, short of writing a device plugin. The manifest now
says that, and records what was ruled out and how, so the next reader does not
spend the same two rounds in the capability layer.

The /dev mount loses its readOnly flag with it. smartmontools opens NVMe
devices read-write on some paths, and once the container is privileged that
flag suggests a boundary that is not there.

A device plugin advertising the drives as a schedulable resource is the
genuinely narrower answer and is noted as such. It is worth building when more
than one workload needs raw device access; for a single read-only metrics
exporter it is a component to write, deploy and maintain in exchange for the
same access.

Refs #549

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 15:14:32 +02:00
k.renner 8ccf5ac8e5 Merge pull request 'fix(monitoring): unconfine seccomp so smartctl can open the devices' (#670) from smartctl-seccomp into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#670
2026-08-27 13:05:08 +00:00
claude-bot 08b2fe7ef6 fix(monitoring): unconfine seccomp so smartctl can open the devices
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
The exporter deployed cleanly, ran 3/3 with no restarts, answered its liveness
probe, and produced no drive metrics at all. Only smartctl_devices and
smartctl_exporter_build_info reached Prometheus — a green DaemonSet measuring
nothing, which is the failure mode the dashboard's own caveats panel warns
about.

The logs said why: it found all three drives per node and could open none of
them, with 'Smartctl open device: /dev/nvme0 failed: Operation not permitted'.

EPERM rather than EACCES is the tell — it is the default seccomp action for a
syscall a profile does not list. Rather than guess between that and the
capabilities, I read /proc/1/status in the running container: CapEff was
0x220000, so SYS_ADMIN and SYS_RAWIO were both effective, while Seccomp was 2
with one filter loaded. The capabilities were never the problem. The kubelet
runs with seccompDefault: true, so every pod that does not say otherwise gets
RuntimeDefault.

SELinux was ruled out the same way: /sys/fs/selinux/enforce reads 0.

This stays narrower than privileged: true, which would additionally grant every
capability, all host devices and write access to /sys. The tighter option is a
custom seccomp profile allowing exactly the ioctl smartctl issues; that is worth
building if this set of exceptions ever grows, and is not worth it for one pod.

Refs #549
2026-08-27 15:03:34 +02:00
k.renner 04df5da041 Merge pull request 'feat(monitoring): expose NVMe endurance, with the privilege exception it needs' (#669) from smartctl-exporter into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#669
2026-08-27 12:56:00 +00:00
claude-botandClaude Opus 5 e8b217e8a1 feat(monitoring): expose NVMe endurance, with the privilege exception it needs
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
Drive wear has been invisible in every signal this cluster has. `ceph -s` reads
active+clean at any wear level, no Ceph health check fires until a device
actually fails, and the block-layer metrics show throughput and latency but
never endurance. That blind spot is how a set of OSD drives reached 81-87%
before anyone looked (#549, #434).

This closes it for every drive, and it costs a deliberate exception to the
no-privileged-containers rule. The owner made that call explicitly after being
shown the alternative.

The alternative was real and is worth recording. Ceph's devicehealth module
already archives complete smartctl output daily — percentage_used,
power_on_hours, media_errors, critical_warning, available_spare — collected
without any elevated privileges, because the OSD pods hold the device access
already. Exporting that needs no exception at all. It also covers OSDs and
nothing else: three of nine drives here, and the six it misses include the ones
retired from Ceph for wear that now carry EPHEMERAL. Monitoring the healthy
drives while leaving the worn ones dark was not worth a new service.

The requirement comes from the kernel, not from the tool. NVMe health is read
through an admin ioctl on the character device, which is gated on CAP_SYS_ADMIN;
sysfs publishes the controller temperature and nothing more. Swapping
smartctl_exporter for another exporter would not change that, so the header says
so — the thing to re-check one day is whether the kernel grew a sysfs interface,
not whether some other tool asks for less.

The exception is kept to the smallest form that works: no `privileged: true`, no
host namespaces, no hostPID. Root plus CAP_SYS_ADMIN and CAP_SYS_RAWIO, all
other capabilities dropped, a read-only root filesystem, and /dev as the only
host mount — required because Kubernetes cannot hand individual character
devices to a pod without a device plugin. The admission policy that forbids
privileged pods is bound to the woodpecker-ci namespace only, so nothing here
circumvents it; monitoring already enforces pod-security `privileged`.

Two details that would otherwise have made the metrics useless:

The ServiceMonitor relabels the pod's node onto every series. Without it each
sample carries only the DaemonSet pod's IP, which says nothing about which
machine the drive is in and changes on every reschedule — an unattributable wear
figure is most of the value gone.

No --smartctl.device list. The exporter scans and rescans on its own, and a
written-down device list would quietly stop covering a drive the next time one
is replaced. Device names are not stable across these machines in the first
place.

The alert rules deliberately separate wear from damage: a wear percentage is a
budget being spent and warns at 80, while spare exhaustion, a critical-warning
flag or new media errors mean the drive is failing now. Media errors alert on
the increase rather than the total, so a drive that recorded errors long ago
does not alert forever while one that starts failing today does.

The completeness check compares the number of reporting drives against
node-exporter's own NVMe inventory rather than a written-down fleet size, so
adding or removing a drive moves both sides together. A rule that alerts on
nothing looks exactly like a healthy cluster, which is the failure mode worth
guarding.

Dashboard panels follow separately, once the metrics are live and the label
names can be read off the real series rather than guessed from a README.

Closes #549

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 14:42:18 +02:00
k.renner 8c03a6e310 Merge pull request 'feat(monitoring): put the disk health verdict on the disk health dashboard' (#668) from disk-dashboard-status into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#668
2026-08-27 12:25:39 +00:00
claude-bot 1213c771ca fix(monitoring): state mechanisms in the panel text, not the current hardware
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 descriptions I wrote listed the drives fitted today: which model holds which
role, that nvme0 is the system disk on two named nodes and something else on the
third, that a count below three means a drive is gone. All true this afternoon,
none of it true after the next swap — and prose does not get re-read when
hardware changes. That is the same failure this session already found in the
velero schedule comment, in the ci-claude consumer list, and in an instruction
pointing at a deleted file. I built a fresh instance of it while writing about
the others.

The text now describes mechanisms that outlive any particular set of drives, and
points at the inventory table for anything specific. Roles are assigned by serial
in Talos and by by-id path in Rook; a broad `disk.transport == 'nvme'` selector
matches every drive in the box; a drive retired from Ceph is not necessarily out
of service. None of that changes when the hardware does.

The OSD-count panel had the same problem in a worse form, since it was a
threshold rather than a sentence: green at exactly 3. It now reads OSDs down and
out as a difference against ceph_osd_metadata, so growing or shrinking the
cluster does not turn the panel red.

Fixes a claim that was simply false: the inventory description said the OSD
column came from ceph_disk_occupation, and the query never joined it. There was
no such column. It exists now, as a PromQL outer join — the matched half via
group_left, the unmatched half via `unless`, unioned — so drives with no OSD
still appear. Verified against the live TSDB: nine rows, three carrying a
ceph_daemon.

A Grafana `merge` transformation would have been the shorter route and would not
have worked. Merge only combines rows whose shared fields all agree, and the two
queries disagree on `instance` and `job` — node-exporter reports an IP and port
where ceph-mgr reports a node name — so it would have produced twelve rows
instead of nine, silently. Doing the join in PromQL also made it testable before
commit rather than after deploy.

Temperature thresholds are now labelled as generic NVMe limits rather than any
drive's rating, since they are not derived from a datasheet.

Refs #549
2026-08-27 14:23:36 +02:00
claude-botandClaude Opus 5 afbb6c3a95 feat(monitoring): put the disk health verdict on the 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
The first version graphed throughput, latency and temperature but never answered
"is anything wrong". Establishing that still meant querying Prometheus and Ceph
by hand — which is exactly the work a dashboard exists to remove, and which the
next person would have had to repeat.

A status row now leads: Ceph health, OSDs up and in, Ceph capacity used, and the
fullest EPHEMERAL volume across the three nodes. Each expression was run against
the live TSDB before being written in.

EPHEMERAL fill level gets its own graph too. It was the one number checked by
hand that had no home here, and it is the failure that has no safety net: Ceph
rebalances away from a full OSD, nothing rebalances away from a full /var — the
node simply stops.

The caveats panel gains three things found while answering the question:

Ceph does hold wear for the OSDs, in its devicehealth module and not in
Prometheus. The panel names the command rather than the current value, because a
number written into a dashboard is stale the moment it is written. It also
records that the retired Crucial drives were purged from Ceph's device registry,
so their history is not recoverable either — verified by asking for it and
getting ENOENT.

Talos boots with nvme_core.io_timeout=4294967295, so NVMe commands never time
out. A dying drive stalls instead of returning errors, and the latency panel
goes flat rather than spiking. That inverts what a reader would otherwise
conclude from a quiet graph.

Talos kernel logs do not reach Loki — the only log jobs are Kubernetes pods and
a systemd-journal from elsewhere. I found this by searching Loki for NVMe errors,
getting nothing, and checking whether the collector existed before believing the
zero; it did not. talosctl dmesg is the only source, it is a ring buffer, and
this morning's reboots emptied it.

Refs #549

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 14:18:11 +02:00
k.renner 32dc191b18 Merge pull request 'feat(monitoring): add a disk health dashboard' (#667) from disk-dashboard into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#667
2026-08-27 12:06:02 +00:00
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 55ea98c035 docs: answer the direct-routing question from source instead of a test
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 first draft ended on an open question and proposed a two-VM sandbox to
answer it. That was unnecessary: the question is answerable from the Cilium
source and the agent flag reference, and the answer is negative.

installDirectRoute takes its next-hop as a parameter, and nodeUpdate supplies
GetNodeIP(false), which prefers NodeInternalIP. NodeInternalIP comes from the
Kubernetes Node object; --ipv4-node looks like an override but initFromK8s()
runs after initFromConfig() and overwrites it. --direct-routing-device selects
a device, not an address. The same value feeds the VXLAN endpoint in tunnel
mode.

So cluster-internal traffic is welded to the kubelet InternalIP, and the routing
mode does not matter — tunnel sources the VXLAN from it, native uses it as the
next-hop. That deletes the one disruptive, non-rolling step the first draft
contained. Native routing buys nothing for this problem.

Since cluster-internal cannot be moved, the separation has to come from moving
management services off the node IP, and those do not share a mechanism, so each
is checked separately: etcd moves via cluster.etcd.advertisedSubnets, the
apiserver endpoint is already a VIP whose Layer2VIPConfig carries a link field,
the Talos API turned out to answer on every node address, and kubelet cannot
move because it is the node IP.

The result is four VLANs, but the boundary does not fall where the class names
suggest — kubelet stays with cluster-internal. Stated plainly rather than filed
under management and hoped over.

Recommends leaving the node IP where it is and relabelling VLAN 40 as the
cluster VLAN, rather than moving every node's identity to a new VLAN. Same end
state, and the address the cluster is administered through never changes.

Also records that the Ceph pattern is the only mechanism that fully detaches a
workload from the node IP, and that it is too expensive to generalise to
east-west traffic.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 11:49:19 +02:00
claude-botandClaude Opus 5 6a50ab1daa docs: propose separating the four traffic classes onto their own VLANs
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
Issue #576 framed this as "give node management its own NIC, and Ceph public
its own VLAN". That is two of four classes, and one of them turned out to be
solved already — Ceph client I/O riding the pod network is correct, since that
traffic is cluster-internal.

The classes are Ceph replication (done, VLAN 49), cluster-internal,
cluster-external and management. Three of the four share mgmt0, which was never
a decision: until the bare-metal conversion there was one NIC.

Each class gets a VLAN; which interface a VLAN lands on stays a separate and
changeable decision. That ordering means the separation survives recabling and
does not need one port per class.

Three of the four are controllable with documented levers. Management is the
InternalIP and does not move — everything else moves away from it, which is
also the direction that never changes the address the cluster is administered
through. Ingress is CiliumBGPNodeConfigOverride's per-peer localAddress, on a
BGP session that has been running for months. Egress follows the output device
under eBPF masquerading.

Cluster-internal is the one that does not resolve. In tunnel mode VXLAN sources
from the node IP with no documented alternative. Native routing is the way out
and its prerequisite is already met — BGP has been advertising the pod CIDRs to
the UDM all along — but whether nodePort.directRoutingDevice moves the next-hop
address or only the interface is undocumented across the three Cilium pages
checked.

So the proposal ends on a question rather than a plan, and the next step is a
two-interface Talos sandbox, not a cable. If the answer is unfavourable the
design changes, not the wiring.

Records what was measured rather than assumed: the BGP session state, that
Cilium's direct-routing device follows the InternalIP (adding a second address
elsewhere left it on mgmt0), that the override CRD exposes localAddress, and
where Ceph's two address families actually sit.

Also states what the split does not buy. Management shares a 10 Gb link and is
not contended; nothing observed is slow because of this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 11:34:41 +02: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
k.renner d1e072146f Merge pull request 'docs(runbook): rewrite the cold rebuild for bare metal' (#661) from runbook-cold-rebuild-baremetal into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#661
2026-08-27 08:11:24 +00:00
claude-botandClaude Opus 5 4678f73e9f docs(runbook): rewrite the cold rebuild for bare metal
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
Step 1 was "tofu: provision the VMs" and there are no VMs. The OpenTofu stack
has no node definitions left — only the uninstantiated proxmox_vm module beside
DNS, object storage and UniFi — so `tofu apply` provisions nothing and Step 2
then has no cluster to form. The document was half-updated: it knew k8s-1 was
bare metal but still treated k8s-2 and k8s-3 as Proxmox VMs, which stopped
being true at the neptune conversion.

That matters more than an ordinary stale doc, because it is the document
someone reaches for when everything is gone — exactly when there is no capacity
to discover that step one is fiction.

Step 1 splits into two unrelated halves: a tofu apply for platform resources
that do still exist, and physically booting three machines from install media.

Step 2 stops calling `bootstrap.sh talos`, which cannot generate a config
(#581), and carries the procedure that actually works — the one
RUNBOOK-baremetal-conversion.md uses. Both traps it hides are now stated: the
whole cilium-cni.yaml cannot be passed as a patch, and the generated
HostnameConfig collides with the per-node hostname. So is the two-stage apply:
a config referencing an interface that does not exist yet hangs the install at
STAGE: Installing with nothing timing out.

Scoped that claim rather than repeating "bootstrap.sh is dead" wholesale — the
flux and wait-apps phases are still used in Steps 3, 5 and 6 and work. Only
config generation is broken, and saying otherwise would have left the runbook
contradicting itself three sections later.

Adds the constraint that decides whether this is even attemptable remotely, at
the top and again in known gaps: management is a tagged VLAN, a maintenance
mode node cannot configure VLANs, and MS-01 has no IPMI. Every node needs a
keyboard and a monitor until #576 puts VLAN 40 native on the I226-V port. That
was true before today and undocumented.

Also records what changed under the runbook recently: the schematic now carries
i915, Ceph's cluster network is a Multus attachment rather than host
networking, and OpenBao runs three replicas that each need unsealing
individually — a sealed server is neither a voter nor a standby, so doing one
leaves the cluster short of quorum.

Disk verification before install is now explicit. Talos enumerates NVMe in an
order that is not stable across boots, the per-node configs select by serial for
that reason, and the failure mode is the installer taking the Ceph OSD drive.

Closes #660.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 10:09: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
k.renner 5f8c0115b5 Merge pull request 'fix(monitoring): raise VeleroBackupStale from 30h to 60h' (#658) from velero-stale-60h into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#658
2026-08-27 07:36:12 +00:00
claude-botandClaude Opus 5 e2f3d67f79 fix(monitoring): raise VeleroBackupStale from 30h to 60h
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
At 30h a single missed cycle pages critical, and a single missed cycle is the
normal cost of touching the nodes. Velero moves data through pods, so draining
a node kills whatever upload is in flight and the schedule is lost for that day
— it is not retried. Backup windows run 03:15 to 08:45 UTC, so any rolling node
work overlapping them produced a guaranteed critical alert per affected
schedule.

That is noise about planned work, not signal about backups. It fired for eleven
schedules during the bare-metal Talos upgrade and again today.

60h still catches what the rule exists for. Two consecutive failures is no
longer explicable by one maintenance window. Nothing is hidden in the meantime:
VeleroBackupFailed fires on the first failure regardless, so the first cycle is
still visible — only the escalation to critical waits for a second cycle to
confirm it.

Calibration against the current state: the four stale schedules sit at 46.8 to
48.5 hours since their last success, so the new threshold leaves roughly twelve
hours — exactly until today's runs. If those fail too, the alert fires, which
is the intended behaviour rather than a gap.

Verified: the rule passes a server-side dry run, the expression returns nothing
at 60h, and the same query at a lower bound returns the four affected schedules
with their ages — so the selector is known to match rather than merely silent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 09:35:09 +02:00
k.renner 7b0b664a46 Merge pull request 'chore(talos): move to v1.13.9' (#656) from talos-1-13-9 into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#656
2026-08-27 06:57:58 +00:00
claude-botandClaude Opus 5 3abdeb2203 chore(talos): move to v1.13.9
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
Nodes run v1.12.11, which is the last 1.12 patch — v1.12.12 does not exist.
v1.13.9 is current.

The upgrade path is the documented one rather than a shortcut. Migration is
only tested between adjacent minor releases, and the recommendation is to go
to the latest patch of each intermediate minor. 1.12.11 -> 1.13.9 satisfies
both: one minor step, taken from the last patch of the version being left. No
intermediate hop is needed.

Kubernetes is not forced along. Talos 1.13's support matrix lists 1.36, 1.35,
1.34, 1.33, 1.32 and 1.31, and the cluster runs 1.35.2 — so it stays there
until `talosctl upgrade-k8s` moves it as a separate decision. This was the one
open question before the jump and it is answered from the matrix rather than
assumed.

Deprecations in 1.13 were checked against this cluster, not just read:

  .machine.env -> EnvironmentConfig          not used
  .machine.network.kubespan -> KubeSpanConfig not used
  EtcdConfigs/KubeletConfigs protobuf change  no consumer in this repo
  legacy upgrade API -> LifecycleService      still functional; `talosctl
                                              upgrade` keeps working, this
                                              only matters once it is removed

Both the five machine-config files here and the live config on k8s-1 were
checked for the first two, because the running config is generated from a base
this repo only patches and could carry fields the patches never mention.
Neither appears in either.

The removed interactive installer and the dropped `talosctl install` do not
apply — installation here is `talosctl upgrade --image` and Image Factory
media.

Schematic is unchanged; the factory image was verified to exist for the new
tag before committing. The cold-rebuild ISO URL moves with it.

Not applied to any node. Rolling it out is drain -> upgrade -> wait for Ceph
to return to 217 PGs active+clean, one node at a time, as in the 1.12.11 run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 08:56:20 +02:00
k.renner cdebaba2d3 Merge pull request 'chore(rook-ceph): return mon_data_avail_warn to the Ceph default' (#655) from ceph-mon-disk-warn-default into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#655
2026-08-27 06:40:38 +00:00
claude-botandClaude Opus 5 dd311f4b01 chore(rook-ceph): return mon_data_avail_warn to the Ceph default
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 override existed because the mons share the node's /var with containerd,
and on the VMs that was ~60 GB — 30% free measured image and snapshot churn
rather than anything about Ceph, so k8s-2 tripped MON_DISK_LOW whenever the
image cache filled and GC could not reclaim it (#434).

Bare metal removed the premise. /var is now ~931 GB per node at 7-8% used, so
30% free is a real reserve again and the ~106 MB mon database is irrelevant
against it. Reaching 70% would be a genuine signal about the node rather than
noise about the image cache. The comment still described the 60 GB VM /var and
ended with "BARE-METAL MIGRATION: revisit this", which is now done.

Set to 30 rather than deleted, deliberately. `ceph config` keeps a value that
only disappears from this file: `ceph config dump` currently shows
`mon advanced mon_data_avail_warn 20`, and removing the line would leave it
there indefinitely. That is the trap the bluestore settings below already
document, and the one that crash-looped an OSD during the network provider
change when public_network survived being deleted here.

mon_data_avail_crit stays at the 5% default, and CephMonDiskLow in
monitoring-rules still watches /var directly for the real risk, which is the
node filling rather than the mon growing.

kubectl diff against the live cluster shows the value and the generation bump,
nothing else. Verify after it applies with
`ceph config get mon mon_data_avail_warn` — 30 there is the proof the database
entry was overwritten and not merely edited in git.

Supersedes the remaining half of #503, which was closed as overtaken by the
bare-metal migration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 08:37:46 +02:00
k.renner 36e683aee1 Merge pull request 'fix: [alert] TargetDown (default) (#635)' (#644) from alert-fix/issue-635 into master
Reviewed-on: k.renner/homelab#644
2026-08-27 06:35:54 +00:00
k.renner c34b423d38 Merge pull request 'chore(rook-ceph): give the SAN pool the whole /29 now the nodes are off it' (#654) from ceph-san-pool-widen into master
Reviewed-on: k.renner/homelab#654
2026-08-27 06:35:12 +00:00
k.renner 5fd0691ed2 Merge pull request 'fix(talos): stop the VLAN parents autoconfiguring on the fallback network' (#653) from no-ipv6-on-vlan-parents into master
Reviewed-on: k.renner/homelab#653
2026-08-27 06:34:51 +00:00
claude-botandClaude Opus 5 f0f0b0af9d chore(rook-ceph): give the SAN pool the whole /29 now the nodes are off it
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 pool was .4-.6 because the nodes held .1-.3 for as long as Ceph needed
host networking to reach VLAN 49 — three addresses for three OSDs, with no
spare, during the one phase where things were actually moving.

The nodes have since given those addresses up: the link is only a macvlan
parent now and does not need one of its own. The reservation has nothing left
to avoid, so the pool takes .1 through .6.

Six for three is the headroom the migration could not afford. It leaves room
for a leaked allocation to sit until the whereabouts reconciler collects it —
the case where a node is lost hard enough that the CNI DEL never runs — and
room to add a second OSD per node without touching this file.

Keeping the pool inside the /29 rather than moving it somewhere roomier on the
segment stays deliberate. There is no router here, so an address outside the
/29 is unreachable from anything using one inside it, which is what would
break a future rolling change where migrated and un-migrated OSDs still have
to talk to each other. That was nearly the shape of this attachment and it
would have taken every pool down at the first OSD.

Inert for the running cluster: a NetworkAttachmentDefinition is read when a
pod is created, so the three existing OSDs keep .4, .5 and .6 until they next
restart for some other reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 08:03:46 +02:00
claude-botandClaude Opus 5 4c6c23377d fix(talos): stop the VLAN parents autoconfiguring on the fallback network
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 cluster had no IPv6 egress. Pods could not reach the IPv6 internet at all
while IPv4 from the same pod worked normally.

enp2s0f0np0 is nothing but the trunk parent for mgmt0 and ens19, and enp87s0
is unused. Both sit on the untagged fallback network, where by design nothing
is supposed to run — and both were autoconfiguring from its router
advertisements: an fd00::/64 ULA each, plus an IPv6 default route each.

That left four IPv6 default routes per node at identical priority 1024 —
mgmt0, enp2s0f0np0, enp87s0 and lo — of which only mgmt0 has a globally
routable source address. With nothing to break the tie, outbound IPv6 mostly
left through an interface whose only usable source was a ULA.

This is the same argument the file already makes for ens19, where host
addresses on a link that exists only as a macvlan parent are called "never
needed and actively harmful". It was simply never extended to the physical
ports.

How it surfaced: NetbirdRelayMemberDown and NetbirdPublicEdgeMemberDown had
been firing for 30 hours, reading as though the proxies were down. They were
not — every failing target is an IPv6 literal and the blackbox exporter is a
pod, so the probes were failing on cluster egress. The proxies answered IPv6
in 13 ms from a workstation on another VLAN throughout.

Applied to k8s-1 first and verified before rolling to the other two, because
the existing precedent disables v6 on a VLAN *child* while this targets VLAN
*parents*, and mgmt0 carries the address Ceph and Cilium use:

  mgmt0 kept 2003:d6:9718:3b02:… and fd40::…    unchanged
  fd00:: addresses                              2 -> 0
  IPv6 default routes                           4 -> 2 (mgmt0, lo)
  failing blackbox probes                       5 -> 0
  Ceph                                          217 PGs active+clean, no OSD restart

All three nodes are already patched live; this puts it in git so the next
apply-config does not undo it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 07:51:51 +02:00
k.renner f7f04281c0 Merge pull request 'fix: [alert] AlertmanagerClusterFailedToSendAlerts (monitoring) (#649)' (#650) from alert-fix/issue-649 into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#650
2026-08-27 04:54:09 +00:00
k.renner c4a6d72cd6 Merge pull request 'fix: [alert] KubeStatefulSetReplicasMismatch (openbao) (#645)' (#648) from alert-fix/issue-645 into master
Reviewed-on: k.renner/homelab#648
2026-08-27 04:53:56 +00:00
k.renner 8c019e50d2 Merge pull request 'feat(monitoring): alert on OpenBao replicas being unavailable' (#647) from openbao-alerting into master
Reviewed-on: k.renner/homelab#647
2026-08-27 04:53:34 +00:00
security-bot c9e9830092 chore(image-automation): bump ghcr.io/music-assistant/server:2.10.0
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-08-27 01:17:36 +00:00
issue-fix-bot 7e825aff0f fix: [alert] AlertmanagerClusterFailedToSendAlerts (monitoring) (#649)
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-26 23:45:38 +00:00
issue-fix-bot 781b849adb fix: [alert] KubeStatefulSetReplicasMismatch (openbao) (#645)
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
2026-08-26 22:45:57 +00:00
claude-botandClaude Opus 5 af81045277 feat(monitoring): alert on OpenBao replicas being unavailable
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
There was no OpenBao alerting of any kind — no rule, no ServiceMonitor. That
is why today's seal went unnoticed for two hours: the k8s-1 Talos upgrade
sealed the single replica, the ClusterSecretStore went InvalidProviderConfig,
and nothing said so.

Three replicas make this more important rather than less. Before, a seal was a
visible outage that announced itself. Now the service survives one, so a
sealed replica is a quiet loss of redundancy that nothing surfaces until the
next drain takes quorum with it.

Watches pod readiness rather than a seal metric, because there is no seal
metric to watch. OpenBao is not scraped: no ServiceMonitor exists, and its
metrics endpoint needs a token or an explicit unauthenticated_metrics_access
telemetry setting, neither configured. Verified against the running
Prometheus — no bao_* or vault_* series at all. External Secrets exports
nothing usable either, so the ClusterSecretStore going NotReady is invisible.

Readiness carries the signal for a structural reason, not by luck: a sealed
server fails its readiness probe because it can neither serve requests nor act
as a raft standby. Sealed is therefore always not-Ready. The converse does not
hold — a crash or stuck scheduling fires the same alert — but those want a
human too.

Three rules:

  OpenBaoReplicaNotReady   < 3 ready for 10m, warning. The duration only
                           ignores ordinary rescheduling; a sealed replica
                           never recovers on its own.
  OpenBaoQuorumLost        < 2 ready for 5m, critical. Raft quorum of three
                           is two, so below that the store is down.
  OpenBaoMetricsMissing    absent() for 15m, warning. Both rules above are
                           threshold comparisons, so if the series disappears
                           they stop evaluating and the silence is
                           indistinguishable from health.

Verified against the running Prometheus rather than assumed: the selector
matches (the same query with `< 4` returns the series at 3), and the alerting
thresholds correctly return nothing at 3/3 ready. Testing only that it returns
nothing would not have caught a typo in the selector.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 23:00:10 +02:00
k.renner 71d6bd3724 Merge pull request 'feat(openbao): run three raft replicas so a node drain is not an outage' (#646) from openbao-ha-3-replicas into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#646
2026-08-26 20:51:03 +00:00
claude-botandClaude Opus 5 0809823511 feat(openbao): run three raft replicas so a node drain is not an outage
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
OpenBao ran a single replica. Every node drain took the whole secret store
down with it: today the k8s-1 Talos upgrade sealed it, the ClusterSecretStore
went InvalidProviderConfig, and it stayed that way for two hours because
nothing was watching.

Three, not two. Raft needs a quorum and the quorum of two is two, so a
two-replica cluster tolerates no failures — it would stop the moment one pod
is sealed or rescheduled, which is worse than the single replica it replaced.
Three tolerates one.

What this buys: a drain seals the replica on that node, and raft elects a new
leader from the remaining standbys within seconds. The service does not go
down.

What it does not buy, stated plainly because it is easy to assume otherwise: a
sealed server can neither serve requests nor act as a standby, so that replica
stays out of the cluster until someone unseals it. Scaling removes the outage,
not the manual unseal. For rolling node maintenance the consequence matters —
a Talos upgrade drains all three nodes in turn, so unseal each replica as it
comes back, or the third drain takes the last unsealed one and quorum is lost
anyway.

retry_join is what makes a scaled replica join the existing cluster rather
than come up isolated and uninitialized; without it each new pod would need a
manual `bao operator raft join`. All three peers are listed on every node —
the stanza is a list of possible leaders, a node ignores its own entry, and
listing all three keeps the config identical across replicas regardless of
which pod currently leads. Addressed through the headless service so each
entry resolves to one specific pod.

The snapshot job moves from the plain service to openbao-active. With one
replica the plain service could only ever hit the one pod; with three it
selects any of them, and a raft snapshot has to be taken against the leader.

No affinity was added: the chart already applies a required podAntiAffinity on
kubernetes.io/hostname, so the replicas spread across the three nodes on their
own — verified against the running StatefulSet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 22:49:41 +02:00
issue-fix-bot 5155581fba fix: [alert] TargetDown (default) (#635)
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-26 20:45:37 +00:00
k.renner 96acff3456 Merge pull request 'chore(talos): drop the node addresses on the Ceph SAN' (#643) from ceph-san-cleanup into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#643
2026-08-26 20:18:18 +00:00
claude-botandClaude Opus 5 b5a1818896 chore(talos): drop the node addresses on the Ceph SAN
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
The nodes held 10.99.0.1-.3/29 on enp2s0f1np1 only because reaching that link
required CephCluster.spec.network.provider: host, which is not per-daemon and
put every Ceph daemon on host networking. Rook now attaches the link to the
OSD pods through a Multus macvlan, and a macvlan parent only needs to be up —
it does not need an address of its own for its children to work.

By the rule this segment is built on — Ceph-internal traffic and nothing else,
no router, no other participants — the node has no business holding an address
there either. This is stricter than before, not looser.

The link keeps up: true and mtu: 9000. Both still matter: the parent must be
up for the macvlan children to exist, and the MTU is what the children
inherit.

Not in this commit, deliberately: widening the attachment's whereabouts pool
from .4-.6 to the full /29. That must not land before these addresses are
actually gone from the running nodes, or whereabouts could hand .1 to an OSD
while a node still holds it. Follow-up once these configs are applied and
verified.

Also records the one-time step that the manifests cannot express. Removing
addressRanges did not clear public_network and cluster_network from Ceph's
config database — the same trap the bluestore comment in the same file
describes. The first OSD of the phase A rollout came up on the pod network,
still had public_network 10.2.40.0/24 in the config database, found no
interface in it and crash-looped on "Failed to pick public address". Both keys
were removed by hand. A rebuild from scratch will not hit this because the
config database starts empty; another provider change on a live cluster will.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 22:17:15 +02:00
k.renner 1a94adb4c4 Merge pull request 'feat(rook-ceph): attach the cluster network via Multus (phase B of two)' (#642) from ceph-provider-phase-b into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#642
2026-08-26 20:08:31 +00:00
k.renner e9f3175b5b Merge pull request 'refactor(rook-ceph): drop the host network provider (phase A of two)' (#640) from ceph-provider-phase-a into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#640
2026-08-26 19:54:07 +00:00
claude-botandClaude Opus 5 a775b675d4 feat(rook-ceph): attach the cluster network via Multus (phase B of two)
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
Puts the OSDs back on the VLAN 49 link that phase A gave up, without putting
every Ceph daemon on host networking to get there.

The cluster network is an OSD concern. Per the Ceph documentation it carries
OSD heartbeat, replication and recovery, and "Ceph Monitors always operate on
the public network". Reaching it through addressRanges required
provider: host, which is not per-daemon — the mons went onto host networking
as a side effect, making a mon's address the node's DHCP lease.

That failure is documented rather than hypothetical. When a lease moved, the
mon could not bind ("Cannot assign requested address") and crash-looped. Rook
detected it and began a failover, but with three mons on three nodes each
holding the mon host ports the replacement could not be scheduled anywhere —
the canary sat Pending on "didn't have free ports". Two of three nodes hit it
during the Talos v1.12.11 upgrade; both needed hand intervention.

Selecting only `cluster` leaves `public` on the pod network, so mons, MDS,
MGR and the CSI pods keep Rook-managed identities and hold no host ports.
That removes the failure mode rather than working around it.

Isolation is not weakened. Rook's Multus prerequisites about hosts routing to
the Multus network apply to a Multus *public* network; selecting only
`cluster` means they do not apply here. VLAN 49 stays Ceph-internal traffic
and nothing else, with no router and no other participants.

MUST NOT be applied before phase A. Verified rather than assumed — kubectl
diff against the current cluster refuses it:

  spec.network.provider: Invalid value: "multus": network provider must be
  disabled (reverted to empty string) before a new provider is enabled

Second of the two rolling OSD restarts, bounded by the same two settings: one
OSD at a time, only once PGs are clean.

Follow-up, not in this commit: once this settles, the node addresses on VLAN
49 (10.99.0.1-.3) have no remaining purpose and should come out of the Talos
configs, after which the attachment's pool can take the whole /29 instead of
the three addresses it reserves today.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 21:52:38 +02:00
claude-botandClaude Opus 5 a6dd1f4be2 refactor(rook-ceph): drop the host network provider (phase A of two)
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
Intermediate state, not a destination. san-network-attachment.yaml is already
in the cluster and stays unused until phase B sets provider: multus.

The detour is forced by Rook's own CRD validation, not chosen:

  rule:    self == '' || oldSelf == '' || self == oldSelf
  message: network provider must be disabled (reverted to empty string)
           before a new provider is enabled

host -> multus is rejected outright. It has to pass through empty, and an
empty provider means the default CNI — so addressRanges, which only takes
effect under provider: host, goes with it.

What this costs while it is in place: there is no cluster network. OSD
heartbeat, replication and recovery move onto the pod network and share it
with everything else instead of the dedicated X710 SFP+ link. Slower, and
every OSD restart pays that price in backfill. Not a correctness problem —
the OSDs can still reach one another throughout, which is the property that
matters, and the one a separate subnet would have broken.

The mons do not move here. Rook leaves existing mons on the network they were
created with; they change only on failover, which is deliberate and separate.

Bounded by the two settings that landed ahead of this: one OSD restarts at a
time, and only once PGs are clean again. Expect three sequential OSD restarts
with full recovery between them, and the same again in phase B.

kubectl diff against the live cluster shows provider host -> "" and the
generation bump. The removal of addressRanges does not appear there, because
this resource carries no managedFields and client-side diff cannot see a
field deletion. It is inert either way — Rook does not read addressRanges
without provider: host — but worth confirming after the apply rather than
assuming.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 21:51:08 +02:00
k.renner 5d923c84e0 Merge pull request 'fix(rook-ceph): bound OSD restarts to one at a time, gated on healthy PGs' (#639) from ceph-osd-update-safety into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#639
2026-08-26 19:44:29 +00:00
claude-botandClaude Opus 5 c88ee0e9a0 fix(rook-ceph): bound OSD restarts to one at a time, gated on healthy PGs
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
Rook's defaults are wrong for a cluster this size, and nothing here had
overridden them:

  storage.osdMaxUpdatesInParallel   default 20
  upgradeOSDRequiresHealthyPGs      default false

Twenty parallel OSD restarts is a small fraction of a large cluster. Here it
is all three, at once, with no gate on PG recovery in between. Every PG goes
inactive and every pool — RBD volumes and both CephFS mounts — becomes
unreachable for the duration. Not a degraded cluster, a stopped one.

With three OSDs at size 3 there is no spare copy to absorb the second
restart, so the health gate between one OSD returning and the next going down
is the only thing separating a rolling change from an outage.

This binds any change that rewrites the OSD deployment spec, not only Ceph
version upgrades. The immediate reason is the pending network provider
switch, which is exactly such a change — but the exposure predates it and
would have applied to any of them.

Lands inert: `kubectl diff` against the live cluster shows the two added
fields and the generation bump, nothing else. No OSD deployment spec changes,
so nothing restarts.

Deliberately separate from the provider switch rather than bundled with it,
so the bound is in place and observed before the operation it exists to
bound.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 21:43:19 +02:00
k.renner b0a473d038 Merge pull request 'feat(rook-ceph): add the VLAN 49 attachment for Ceph's cluster network' (#638) from ceph-san-nad into master
Reviewed-on: k.renner/homelab#638
2026-08-26 19:41:39 +00:00
claude-botandClaude Opus 5 585ea36c9f fix(rook-ceph): keep the SAN pool on-link with the node addresses
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 pool was 10.99.1.0/24, a separate subnet on the same segment, chosen to
avoid carving up a /29 with only three addresses left. That choice would have
broken the migration it exists for.

OSDs move one at a time, so the cluster runs mixed: an OSD already on the
attachment has to reach an OSD still using its node's host address on
10.99.0.x. There is no router on this segment — by design, it carries
Ceph-internal traffic and nothing else — so a pool outside the /29 is
unreachable from the un-migrated side. The two OSDs would mark each other
down, during precisely the window that is supposed to be the safe one.

Moves the pool into 10.99.0.0/29 with range_start/range_end reserving .4-.6,
leaving .1-.3 to the nodes. That is one address per OSD with nothing spare,
which is sufficient rather than merely tolerable: osdMaxUpdatesInParallel
bounds the migration to one OSD at a time, so allocations grow 1, 2, 3 and
never overlap. Headroom returns after the switch is confirmed and the node
addresses come out of the Talos configs — then the pool takes the whole /29.

Verified: the embedded CNI JSON parses with the intended range and bounds;
the NAD passes a server-side dry run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 21:37:41 +02:00
claude-botandClaude Opus 5 296f79469e feat(rook-ceph): add the VLAN 49 attachment for Ceph's cluster network
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
Step 2 of moving Ceph's cluster network off host networking. Inert on its
own: a NetworkAttachmentDefinition does nothing until a pod references it,
and nothing will until cephcluster.yaml names it under
network.selectors.cluster.

Why this exists at all: `addressRanges` only takes effect under
`provider: host`, so carrying replication over the dedicated 10G link meant
setting a provider that is not per-daemon. It puts mons on host networking
too, which makes a mon's address the node's DHCP lease — and when that lease
moves the mon cannot bind. Three mons on three nodes then each hold the mon
host ports, so Rook cannot schedule the replacement it just decided to create
and the failover stalls. Both happened during the v1.12.11 upgrade, on two of
three nodes.

None of that serves the goal. Per the Ceph documentation the cluster network
carries OSD heartbeat, replication and recovery only — "Ceph Monitors always
operate on the public network". Attaching it here gives the OSDs the link and
leaves every other daemon on the pod network.

Its own subnet rather than the existing /29. The nodes hold 10.99.0.1-.3 for
as long as Ceph still uses host networking, which leaves exactly three
addresses for three OSDs — no headroom during the one phase where things
actually move, and a leaked allocation from a hard node loss would mean an
OSD without an address. OSDs never need to reach the node addresses, since
the cluster network is OSD-to-OSD, so the pool sits in its own range on the
same segment instead. A /24 for three addresses costs nothing: nothing else
can ever live on this segment, and it takes address count off the table when
OSDs are added.

After the provider switch is confirmed, the node addresses on VLAN 49 have no
remaining purpose and can come out of the Talos configs.

No gateway and no routes, because there is no router on this segment and
there must not be one. That also means no `sbr` chaining, unlike the v30iot
attachments — there is no DHCP-supplied default route to confine here.

whereabouts for IPAM: dhcp needs a server that must not exist on the SAN, and
host-local keeps state per node with no cluster-wide coordination, so two
nodes would hand out the same address on shared L2.

MTU 9000 matches the link. macvlan inherits it, but stating it means a change
to the parent cannot silently halve replication throughput with nothing in
the tree recording the expectation.

Verified: whereabouts from step 1 is running on all three nodes with its
binary installed next to macvlan; the NAD passes a server-side dry run; the
embedded CNI JSON parses and carries the intended master, mtu, ipam type and
range.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 21:33:02 +02:00
k.renner f1f2c6ac6b Merge pull request 'feat(multus): add whereabouts IPAM for segments without a DHCP server' (#637) from whereabouts-ipam into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
Reviewed-on: k.renner/homelab#637
2026-08-26 19:28:54 +00:00
55 changed files with 1672 additions and 717 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
+5 -1
View File
@@ -51,7 +51,11 @@ kubectl -n openbao exec -it openbao-0 -- bao operator unseal # paste key
```
**Manual unseal is required after every pod restart** (node drain, upgrade,
crash): `kubectl -n openbao exec -it openbao-0 -- bao operator unseal`.
crash), **per replica** — the server runs three raft peers (see
`infrastructure/openbao/helmrelease.yaml`), and a sealed one is neither a
voter nor a standby, so it stays out of the cluster until unsealed:
`kubectl -n openbao exec -it openbao-<N> -- bao operator unseal`, same key for
all three. Three peers mean one sealed replica is survivable; two are not.
Accepted for now; auto-unseal needs a second trust root (transit/KMS) and is
explicitly out of scope. Symptom of sealed: ESO stores go NotReady, synced
Secrets keep their last value (they are real k8s Secrets — pods keep running).
@@ -0,0 +1,194 @@
# Proposal — separate the four traffic classes onto their own VLANs
Status: draft. The question this started with is **answered** — from the Cilium
source and flag reference, not from a test — and the answer changes the shape
of the proposal rather than confirming it.
Supersedes the framing in issue #576, which described this as "give node
management its own NIC, and Ceph public its own VLAN". That was two of four
classes and one of them turned out already solved.
## The four classes
| class | what it is | where it goes today |
|---|---|---|
| **Ceph replication** | OSD-to-OSD heartbeat, replication, recovery | VLAN 49, its own X710 port — **separated** |
| **cluster-internal** | pod-to-pod east-west, including Ceph client I/O | pod network, VXLAN over `mgmt0` |
| **cluster-external** | ingress to LoadBalancer VIPs, egress to LAN and internet | `mgmt0` |
| **management** | Talos API, etcd, kube-apiserver, kubelet | `mgmt0` |
Three of four share one interface. That is not a design decision — until the
bare-metal conversion there was one NIC to work with.
Ceph client I/O living on the pod network is correct rather than a leftover:
that traffic *is* cluster-internal. Ceph is done.
## Principle
**Each class gets its own VLAN. Which physical interface a VLAN lands on is a
separate, changeable decision.**
Defining classes by VLAN means the separation survives recabling, port failures
and capacity changes, and it does not require one port per class. Defining them
by interface would freeze a capacity decision into the architecture.
## The finding that shapes everything else
The original open question was whether `nodePort.directRoutingDevice` moves the
next-hop of Cilium's direct routes, or only selects an interface. It is
answerable from the source, and the answer is: **only the interface.**
`installDirectRoute` takes the next-hop as a parameter:
```go
func installDirectRoute(log *slog.Logger, prefix netip.Prefix, nodeIP net.IP,
skipUnreachable bool) (routeSpec *netlink.Route, err error)
```
and `nodeUpdate` supplies it as `newNode.GetNodeIP(false)`. `GetNodeIP` returns,
in priority order, `NodeInternalIP` → `NodeExternalIP` → anything else. The
same `newIP4` is what tunnel mode uses as the VXLAN endpoint.
`NodeInternalIP` comes from the Kubernetes Node object. `--ipv4-node` looks like
an override but is not one: `initFromConfig()` calls `SetNodeInternalIP` and
`initFromK8s()` runs **after** it and overwrites the value. In a Kubernetes
cluster there is no path by which Cilium's node IP differs from the kubelet's
`InternalIP`.
`--direct-routing-device` is documented as "Device name used to connect nodes in
direct routing mode" — a device, not an address.
**So cluster-internal traffic is welded to the node IP, and the node IP is the
kubelet InternalIP.** Two consequences:
- The class cannot be moved off whatever VLAN the node IP lives on.
- **Routing mode is irrelevant to this problem.** Tunnel mode sources the VXLAN
from the node IP; native mode uses it as the direct-route next-hop. Native
routing buys nothing here, so the disruptive, non-rolling step this proposal
previously contained is simply gone.
## Consequence: draw the line the other way
If cluster-internal cannot leave the node IP, then separation has to come from
moving *management services* off it — and each one has to be checked
individually, because they do not share a mechanism.
| management service | can it leave the node IP? | lever |
|---|---|---|
| etcd | **yes** | `cluster.etcd.advertisedSubnets` (+ `listenSubnets`, which defaults to it) |
| kube-apiserver endpoint | **yes** | already a VIP — `Layer2VIPConfig` with a `link:` field |
| Talos API | **n/a** | `apid` answers on every node address (measured); a client and firewall choice, not a bind setting |
| kubelet | **no** | it *is* the node IP, by definition |
So four VLANs are reachable, but the boundary does not fall where the class
names suggest: **kubelet stays with cluster-internal.** That is the honest
result, and it is worth stating plainly rather than filing kubelet under
management and hoping.
The remaining classes keep the levers established earlier:
- **cluster-external ingress** — `CiliumBGPNodeConfigOverride.spec.bgpInstances[].peers[].localAddress`.
For eBGP the session source is the next-hop, so pinning the session pins
where return traffic arrives. The session to the UDM has been running for
months.
- **cluster-external egress** — eBPF masquerading follows the output device, so
this is a default-route decision plus `enable-masquerade-to-route-source`
(today `false`).
- **Ceph replication** — Multus macvlan on VLAN 49, done.
## The general escape hatch
Ceph is separated because its OSDs got a second interface and stopped using the
pod network for replication. That is the *only* mechanism that fully detaches a
workload from the node IP, and it generalises: anything that must not ride the
node IP gets its own `NetworkAttachmentDefinition`.
It is also expensive per workload — an attachment, an IPAM pool, a pod spec
change. Worth it for Ceph replication. Not worth it for east-west traffic in
general.
## Recommended mapping
Two ways to arrange this, and they differ only in which VLAN gets relabelled:
**A — move the node IP to a new cluster VLAN.** VLAN 40 keeps etcd, the API VIP
and Talos API. Cleanest match to the class names, but it changes every node's
identity, which is the one address the whole cluster is administered through.
**B (recommended) — leave the node IP on VLAN 40 and move management off it.**
VLAN 40 becomes the cluster VLAN; a new VLAN carries etcd and the API VIP.
Identical end state, and no node ever changes its InternalIP.
Under B:
| VLAN | carries |
|---|---|
| 49 | Ceph replication |
| 40 (relabelled *cluster*) | pod east-west, kubelet |
| new *management* | etcd, kube-apiserver VIP, Talos API by convention |
| new *external* | LoadBalancer ingress, egress default route |
The property the user asked for — management carries only management traffic —
holds under B. The label moves; the property is what matters.
## Migration shape
Every step is rolling and reversible. The routing-mode change is gone.
1. **New VLANs** on the switch, trunked to the X710. Nothing uses them.
2. **Addresses** on the new VLANs via Talos, one node at a time, `--mode=try`.
Still unused; this only proves the L2 works.
3. **etcd** to the management VLAN via `advertisedSubnets`. One node at a time,
waiting for a healthy quorum between each.
4. **API VIP** to the management VLAN via `Layer2VIPConfig.link`. DNS for
`k8s.renner.uno` follows.
5. **cluster-external**: BGP `localAddress`, default route, masquerade flag.
Reversible by reverting the override; failure is visible immediately in
`cilium-dbg shell -- bgp/peers`.
6. Verify VLAN 40 by what is *absent* from it, not by what still works.
Step 3 is the one that deserves care — etcd is the only step where a mistake
costs quorum rather than connectivity.
## What this does not buy
Management currently shares a 10 Gb link and is not contended. Nothing observed
today is slow because of this. The case for the split is that traffic classes
with different failure modes and security postures should not be
indistinguishable — not that something is broken.
## Sources and measurements
Source, Cilium v1.20.1 (the running version):
- `pkg/datapath/linux/node.go` — `installDirectRoute(…, nodeIP net.IP, …)`,
fed from `newIP4 = newNode.GetNodeIP(false)`; `createDirectRouteSpec` rejects
a next-hop that is not directly reachable.
- `pkg/node/types/node.go` — `GetNodeIP` prefers `NodeInternalIP`.
- `pkg/node/sync/local_node_sync.go` — `initFromConfig()` applies
`--ipv4-node`, `initFromK8s()` runs afterwards and overwrites it from the
Kubernetes Node.
- `cilium-agent` flag reference — `--direct-routing-device`: "Device name used
to connect nodes in direct routing mode".
Talos:
- Multihoming guide — `cluster.etcd.advertisedSubnets`; `listenSubnets`
defaults to it. kube-apiserver and Talos API listen addresses are not
covered there.
- `kubernetes/talos/patches/k8s-{1,2,3}.yaml` — `Layer2VIPConfig` for
`10.2.40.60` with `link: mgmt0`.
Measured on the cluster:
- `apid` answered on a node address that is not the InternalIP, so the Talos
API is not bound to one address.
- k8s-1 currently holds one routable v4 address plus the VIP on `mgmt0`; its
InternalIP had changed since the last session, which is why nothing here
names an address as fixed.
- Cilium runs `routing-mode: tunnel` with `enable-masquerade-to-route-source`
unset and no direct-routing device configured.
Not verified: whether the kube-apiserver's `--advertise-address` follows the
node IP independently of the VIP. It does not affect the recommendation, since
clients reach the API through the VIP either way.
+162 -38
View File
@@ -1,10 +1,25 @@
# Cold-rebuild runbook — homelab cluster from scratch
Rebuild the entire cluster from **this repo + backups only**, assuming nothing
in the cluster survives (all VMs gone, Ceph gone, in-cluster Gitea gone).
in the cluster survives (all three nodes wiped, Ceph gone, in-cluster Gitea
gone).
Order: **tofu → talos → flux → openbao → apps.**
All three nodes are bare-metal MS-01 machines. Nothing provisions them: there
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.
**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
In steady state Flux's `GitRepository flux-system` pulls manifests from the
@@ -61,12 +76,21 @@ These must exist outside the cluster or the rebuild cannot complete:
`openbao-daily` offsite copy. In a total-loss rebuild the offsite Velero
copy is the one that survives.
- [ ] **Tooling:** `talosctl`, `kubectl`, `flux`, `tofu`, `bao`, plus the
OpenTofu backend + Proxmox/UniFi credentials the `opentofu/` stack needs.
OpenTofu backend + UniFi credentials the `opentofu/` stack needs. No
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.
- [ ] **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 1 — tofu: provision the VMs + supporting infra
## Step 1a — tofu: DNS, object storage, UniFi
Recreate the Talos VMs and the platform resources OpenTofu owns (Proxmox VMs,
UniFi DNS records incl. `k8s.renner.uno` / node A records, etc.).
OpenTofu no longer provisions any node. The `proxmox_vm` module still exists in
the tree but nothing instantiates it, so `tofu apply` here creates only the
platform resources: UniFi DNS records including `k8s.renner.uno` and the node A
records, the Hetzner object storage, and the NAT hairpin rules.
```sh
cd opentofu
@@ -74,44 +98,119 @@ tofu init -upgrade -backend-config="access_key=..." -backend-config="secret_key=
tofu apply
```
In CI this is `.woodpecker/deploy-infrastructure.yml` (manual, `TOFU_APPLY=true`),
which also pre-seeds the Talos image onto every Proxmox node. Wait until the
node FQDNs (`k8s-{1,2,3}.srv.renner.uno`) resolve and the VMs are up before
Step 2 — `bootstrap.sh` polls DNS but will not create the records.
Run this first regardless, because Step 2 needs `k8s.renner.uno` and the node
FQDNs to resolve — the control-plane endpoint is baked into every generated
config, and the nodes are addressed by name.
**k8s-1 is not a VM and OpenTofu does not create it.** jupiter runs Talos on
bare metal, so this step only provisions k8s-2 (saturn) and k8s-3 (neptune).
Before Step 2, boot jupiter from the bare-metal image below so it comes up in
maintenance mode and answers on `k8s-1.srv.renner.uno` — `bootstrap.sh` treats
all three nodes identically from there, since `apply-config --insecure` does
not care whether the target is a VM or bare metal.
## Step 1b — boot all three machines from install media
```
https://factory.talos.dev/image/0f6fb8bd8bb53013e9743b7c7b099d96fb9c32fd2464ced32693c2dc0ea5feb9/v1.12.11/metal-amd64.iso
https://factory.talos.dev/image/0f6fb8bd8bb53013e9743b7c7b099d96fb9c32fd2464ced32693c2dc0ea5feb9/v1.13.9/metal-amd64.iso
```
That schematic is deliberately not the one `.woodpecker/deploy-infrastructure.yml`
seeds onto the Proxmox nodes. It keeps the same `net.ifnames=1` kernel argument
— without it the predictable interface names that `talos/patches/k8s-1.yaml`
matches its VLAN parents against do not exist — and differs in three ways:
Write it to a USB stick and boot each MS-01 from it. Each comes up in
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.
- drops `siderolabs/qemu-guest-agent`, which has nothing to talk to on bare metal
- adds `siderolabs/intel-ucode`, since no hypervisor is supplying microcode now
- adds `siderolabs/thunderbolt`, unused so far and inert until
`machine.kernel.modules` requests `thunderbolt`/`thunderbolt_net`. It is
present only so enabling the MS-01 Thunderbolt mesh later does not require a
new image and a reinstall. Note its udev rule auto-authorises any Thunderbolt
device attached to the machine once those modules are loaded.
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
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/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
- no `qemu-guest-agent`, which has nothing to talk to on bare metal
Verify the disks before going further. Talos enumerates NVMe in an order that
is not stable across boots, and the per-node configs select by serial for
exactly that reason — the failure mode is the installer taking the Ceph OSD
drive. `talosctl -n <maintenance-ip> --insecure get disks` should show three
NVMe devices whose serials match the `diskSelector` and `VolumeConfig` entries
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.
The procedure below is what actually works, and is the same one
`RUNBOOK-baremetal-conversion.md` uses.
### Generate the configs
This is a genuinely new cluster — nothing survives to copy secrets from, so
generate them fresh. (In a *partial* rebuild, where one node still runs, pull
secrets off it instead with `talosctl get mc v1alpha1` + `gen secrets
--from-controlplane-config`, or the new node forms its own cluster rather than
joining.)
```sh
kubernetes/bootstrap.sh talos
talosctl gen secrets -o secrets.yaml
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/k8s-$N.yaml -o install-k8s-$N.yaml
done
```
Generates + applies Talos configs to `k8s-{1,2,3}`, bootstraps etcd/Kubernetes,
writes `~/.talos/config` + `~/.kube/config`, and stages both into
`kubernetes/access-staging/` for later phases. Ends when all 3 nodes are `Ready`.
Two things the generator gets wrong for this repo, both of which will bite
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.
Validate each before applying:
```sh
talosctl validate --config install-k8s-$N.yaml --mode metal
```
### Apply in two stages
Do **not** apply the full per-node config in maintenance mode. A document that
references an interface which does not exist yet hangs the install: the node
sits at `STAGE: Installing` indefinitely with `KUBELET n/a` and `APISERVER n/a`,
and nothing times out. Concretely, the `ens19` VLAN document must be removed
from the first-stage config — its parent exists, but the install stalls anyway.
```sh
# Stage 1: minimal config, from the maintenance-mode address off the console
talosctl apply-config --insecure -n <maintenance-ip> --file install-k8s-$N-stage1.yaml
# Stage 2: once the node is up on management, apply the complete config
talosctl -n k8s-$N.srv.renner.uno apply-config --mode=try --timeout=5m --file install-k8s-$N.yaml
```
`--mode=try` rolls back on its own if the node becomes unreachable, which is the
only safety net there is for a network change that can strand a machine with no
IPMI. Use it for every network-affecting apply, then make it permanent once
proven.
### Bootstrap etcd, then collect access
Bootstrap **exactly one** node:
```sh
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.
## Step 3 — flux: bootstrap GitOps **from the GitHub mirror** (break-glass)
@@ -149,8 +248,8 @@ Secrets, and every secret-consuming app stays Pending. This step is **manual**
(the unseal key lives in `pass`, off-cluster by design).
```sh
# 1. Wait for the OpenBao pod to exist (sealed, 0/1 Ready is expected).
kubectl -n openbao get pod openbao-0
# 1. Wait for the OpenBao pods to exist (all three sealed, 0/1 Ready is expected).
kubectl -n openbao get pods
# 2. Restore the most recent raft snapshot. Source it from the snapshot PVC
# (infrastructure/openbao/snapshot-cronjob.yaml) or, in a total-loss rebuild,
@@ -158,8 +257,12 @@ kubectl -n openbao get pod openbao-0
kubectl -n openbao cp <snapshot>.snap openbao-0:/tmp/restore.snap
kubectl -n openbao exec -it openbao-0 -- bao operator raft snapshot restore /tmp/restore.snap
# 3. Unseal with the key from pass (threshold 1).
# 3. Unseal with the key from pass (threshold 1) — EVERY replica, same key.
# A sealed server is neither a raft voter nor a standby, so unsealing only
# openbao-0 leaves the three-peer cluster one node short of quorum.
kubectl -n openbao exec -it openbao-0 -- bao operator unseal # paste homelab/openbao/unseal-key
kubectl -n openbao exec -it openbao-1 -- bao operator unseal # same key
kubectl -n openbao exec -it openbao-2 -- bao operator unseal # same key
```
Verify the store recovers before moving on:
@@ -170,7 +273,9 @@ kubectl -n <ns> get externalsecret # want: SecretSynced
```
> **Manual unseal is required after every OpenBao pod restart**, not just
> during rebuilds. Details + rationale: `MIGRATION-sops-openbao.md`.
> during rebuilds, and for *each* replica that restarts — a rolling node
> drain seals all three in turn. Details + rationale:
> `MIGRATION-sops-openbao.md`.
## Step 5 — apps: let the rest reconcile
@@ -225,9 +330,28 @@ 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.
- `bootstrap.sh` has no `openbao` phase — Step 4 is manual because the unseal
key must not live in CI. See issue #187 for the open question of whether to
wire a semi-automated OpenBao restore.
- **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.
It has also never had an `openbao` phase — Step 4 is manual because the
unseal key must not live in CI; see issue #187 on whether to wire a
semi-automated OpenBao restore.
- **OpenBao seals on every restart and each replica must be unsealed
individually.** Three raft replicas mean the service survives losing one, but
a sealed server is neither a voter nor a standby — unsealing only one leaves
the cluster short of quorum. Step 4 covers this; it is repeated here because
it is the step most likely to be half-done under pressure.
- Ceph's cluster network is a Multus attachment on VLAN 49 with whereabouts
IPAM, not host networking. Nothing in the rebuild needs special handling for
it — the nodes hold no address on that segment — but a rebuilt cluster will
not have the whereabouts allocations from before, which is harmless since
they are assigned fresh.
- warn-and-continue waits in the `flux` phase can mask a genuinely broken
Ceph/infra bring-up; always read the phase output rather than trusting the
exit code.
@@ -1,18 +0,0 @@
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: barman-cloud-creds
namespace: authentik
annotations:
# Prune-disabled — a standing guard kept from the seed->authentik-KS handoff (barman creds).
kustomize.toolkit.fluxcd.io/prune: disabled
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: openbao
target:
name: barman-cloud-creds # keeps the old Secret's name — consumers unchanged
dataFrom:
- extract:
key: seed/authentik/barman-creds
@@ -15,4 +15,4 @@ spec:
name: hetzner-s3-creds # keeps the old Secret's name — consumers unchanged
dataFrom:
- extract:
key: seed/authentik/hetzner-creds
key: infrastructure/hetzner-s3
@@ -27,5 +27,4 @@ resources:
- cluster.yaml
- objectstore.yaml
- scheduled-backup.yaml
- barman-creds-externalsecret.yaml
- hetzner-creds-externalsecret.yaml
@@ -1,21 +1,8 @@
---
# Barman S3 credentials for ha-pg.
#
# FOLLOW-UP (#397): sourced from seed/paperless/hetzner-creds, which is not where
# they belong. woodpecker/hetzner-creds-externalsecret.yaml has the identical
# problem and the same note. The values are the same today, so this is correct
# but coupled: rotating paperless's Hetzner credentials would silently change
# the backup credentials for Home Assistant and Woodpecker too.
#
# The fix is one dedicated path per consumer (seed/home-assistant/hetzner-creds)
# and a one-line change to the `key:` below. Creating it needs an OpenBao write:
#
# bao kv put homelab/seed/home-assistant/hetzner-creds \
# @<(bao kv get -format=json homelab/seed/paperless/hetzner-creds | jq '.data.data')
#
# Note the mount is `homelab`, so the path is homelab/seed/... — #397 records a
# 403 on `bao kv list seed`, which was a wrong-path error rather than a
# permissions problem.
# One shared path for one shared credential — see the note in
# ../paperless/hetzner-creds-externalsecret.yaml.
#
# The namespace allowlist on the openbao ClusterSecretStore
# (infrastructure/post-crds/openbao-clustersecretstore.yaml) must include this
@@ -35,4 +22,4 @@ spec:
name: hetzner-s3-creds
dataFrom:
- extract:
key: seed/paperless/hetzner-creds
key: infrastructure/hetzner-s3
@@ -1,18 +0,0 @@
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: barman-cloud-creds
namespace: immich
annotations:
# Prune-disabled — a standing guard kept from the seed->immich-KS handoff (barman creds).
kustomize.toolkit.fluxcd.io/prune: disabled
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: openbao
target:
name: barman-cloud-creds # keeps the old Secret's name — consumers unchanged
dataFrom:
- extract:
key: seed/immich/barman-creds
@@ -15,4 +15,4 @@ spec:
name: hetzner-s3-creds # keeps the old Secret's name — consumers unchanged
dataFrom:
- extract:
key: seed/immich/hetzner-creds
key: infrastructure/hetzner-s3
@@ -13,6 +13,5 @@ resources:
- cluster.yaml
- objectstore.yaml
- scheduled-backup.yaml
- barman-creds-externalsecret.yaml
- hetzner-creds-externalsecret.yaml
- pvcs.yaml
@@ -6,12 +6,14 @@ kind: Kustomization
resources:
- prometheusrule-ceph.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
- prometheusrule-woodpecker-rotation.yaml
# CNPG backup health. Seven Postgres clusters ship to Hetzner and nothing
@@ -23,4 +25,5 @@ resources:
- home-assistant-servicemonitor.yaml
- immich-servicemonitor.yaml
- loki-servicemonitor.yaml
- smartctl-exporter-servicemonitor.yaml
- probes-blackbox.yaml
@@ -0,0 +1,92 @@
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: disk-wear
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: disk-wear.rules
rules:
# Wear percentages are self-reported: 100 means the rated endurance is
# spent, not that the drive stops. Planning thresholds, not predictions.
- alert: NvmeDriveWearHigh
# Long `for` because wear is monotonic and cannot flap.
expr: smartctl_device_percentage_used >= 80
for: 1h
labels:
severity: warning
annotations:
summary: "NVMe {{ $labels.device }} on {{ $labels.instance }} is at {{ $value }}% rated wear"
description: >-
{{ $value }}% of rated write endurance used. Nothing is failing
yet. Urgency depends on what the drive carries — the Disk Health
dashboard shows the role. Losing an OSD costs a rebalance;
EPHEMERAL is /var and rebuilds, apart from the node's etcd member
data, which has to be removed and re-added.
- alert: NvmeDriveWearCritical
expr: smartctl_device_percentage_used >= 95
for: 1h
labels:
severity: critical
annotations:
summary: "NVMe {{ $labels.device }} on {{ $labels.instance }} is at {{ $value }}% rated wear"
description: >-
The drive is at or past the end of its rated endurance. It will
likely keep working for a while, but it is now operating outside
what the manufacturer stands behind, and replacement should be
scheduled rather than considered.
# Wear is a budget; spare exhaustion is damage.
- alert: NvmeDriveSpareExhausted
expr: smartctl_device_available_spare <= smartctl_device_available_spare_threshold
for: 15m
labels:
severity: critical
annotations:
summary: "NVMe {{ $labels.device }} on {{ $labels.instance }} has exhausted its spare area"
description: >-
Available spare has fallen to the manufacturer's threshold. The
drive has run out of reserve blocks to remap failures into.
Replace it.
- alert: NvmeDriveCriticalWarning
expr: smartctl_device_critical_warning > 0
for: 5m
labels:
severity: critical
annotations:
summary: "NVMe {{ $labels.device }} on {{ $labels.instance }} reports critical warning {{ $value }}"
description: >-
The drive has raised its own critical warning flag. Read the
detail with `smartctl -a` on the node; the value is a bitfield,
not a count.
- alert: NvmeDriveMediaErrors
# On the increase, not the total: pre-existing errors would otherwise
# alert forever. Standing counts are visible on the dashboard only.
expr: increase(smartctl_device_media_errors[6h]) > 0
for: 5m
labels:
severity: warning
annotations:
summary: "NVMe {{ $labels.device }} on {{ $labels.instance }} recorded {{ $value }} new media errors"
description: >-
The drive reported unrecoverable data-integrity errors in the
last six hours. A healthy drive never increments this. Check
whether Ceph has logged scrub errors on the same node.
# The check that the checks are running: no series fires no alert.
# Counted against node-exporter's inventory, not a fixed fleet size.
- alert: SmartctlExporterIncomplete
expr: count(smartctl_device_percentage_used) < count(node_nvme_info) or absent(smartctl_device_percentage_used)
for: 30m
labels:
severity: warning
annotations:
summary: "Fewer NVMe drives report SMART than node-exporter can see"
description: >-
Some drives visible to node-exporter are not reporting SMART, or
none are. Either a DaemonSet pod is down or a drive stopped
answering — both mean the wear alerts above have stopped watching
part of the fleet, which looks identical to everything being
fine.
@@ -0,0 +1,91 @@
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: 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: openbao.rules
rules:
# WHY THIS WATCHES POD READINESS RATHER THAN A SEAL METRIC
#
# OpenBao is not scraped: there is no ServiceMonitor for it, and its
# metrics endpoint needs either a token or an explicit
# `unauthenticated_metrics_access` telemetry setting, neither of which
# is configured. Confirmed against Prometheus — no bao_* or vault_*
# series exist. External Secrets exports nothing usable either, so the
# ClusterSecretStore going NotReady is invisible as a metric.
#
# kube-state-metrics readiness carries the signal anyway, and does so
# for a structural reason rather than by luck: a sealed OpenBao fails
# its readiness probe, because a sealed server can neither serve
# requests nor act as a raft standby. Sealed is therefore always
# not-Ready. The converse does not hold — a crash or a stuck
# scheduling would also fire this — but both want a human too, so the
# imprecision costs nothing.
- alert: OpenBaoReplicaNotReady
# A replica that is sealed never recovers on its own, so the
# duration is only there to ignore ordinary rescheduling: a healthy
# pod moving between nodes is Ready again in well under a minute.
expr: kube_statefulset_status_replicas_ready{namespace="openbao", statefulset="openbao"} < 3
for: 10m
labels:
severity: warning
annotations:
summary: "OpenBao has {{ $value }} of 3 replicas ready"
description: >-
An OpenBao replica has been unavailable for 10 minutes. The usual
cause is a sealed replica after a node drain — every restart
seals it, and it stays out of the raft cluster until unsealed by
hand. The service is still up on the remaining replicas, so
nothing is broken yet, but redundancy is reduced and a second
drain would cost quorum. Check `kubectl -n openbao get pods` and
`bao status` in the not-ready pod; unseal with the key from
`pass` (homelab/openbao/unseal-key).
- alert: OpenBaoQuorumLost
# Raft quorum of a three-node cluster is two. At one ready replica
# the cluster cannot elect a leader and the secret store is down:
# the ClusterSecretStore goes InvalidProviderConfig and no
# ExternalSecret can refresh.
#
# Shorter window than the warning above because this is an outage,
# not a degradation — but not zero, so a rolling restart that
# briefly dips does not page.
expr: kube_statefulset_status_replicas_ready{namespace="openbao", statefulset="openbao"} < 2
for: 5m
labels:
severity: critical
annotations:
summary: "OpenBao below raft quorum — {{ $value }} of 3 replicas ready"
description: >-
Fewer than two OpenBao replicas are ready, so raft cannot hold
quorum and the secret store is unavailable. ExternalSecrets stop
refreshing; existing Secrets keep their last values, so failures
surface only when something rotates or a new secret is needed.
Unseal the sealed replicas (key in `pass`,
homelab/openbao/unseal-key) — each one individually, since a
sealed server cannot be a standby.
- alert: OpenBaoMetricsMissing
# Guards the two alerts above. Both are threshold comparisons, so if
# kube-state-metrics stops exporting this series — the StatefulSet
# deleted, the namespace gone, kube-state-metrics itself broken —
# they silently stop evaluating and the silence looks identical to
# health. This makes that state say something.
expr: absent(kube_statefulset_status_replicas_ready{namespace="openbao", statefulset="openbao"})
for: 15m
labels:
severity: warning
annotations:
summary: "No readiness metric for the OpenBao StatefulSet"
description: >-
kube-state-metrics has not reported
kube_statefulset_status_replicas_ready for the openbao
StatefulSet in 15 minutes. Either the StatefulSet is gone or
kube-state-metrics is not scraping — and while this is absent,
OpenBaoReplicaNotReady and OpenBaoQuorumLost cannot fire at all.
@@ -40,17 +40,31 @@ spec:
description: "A Velero backup run for {{ $labels.schedule }} completed with Failed or PartiallyFailed status in the last hour. Inspect with `kubectl -n velero get backup.velero.io -l velero.io/schedule-name={{ $labels.schedule }}` and the velero pod logs."
- alert: VeleroBackupStale
# Daily schedule with no successful backup in over 30h: covers
# the case where multiple consecutive runs failed (Garage flapping
# over the backup window) without the per-event alerts being
# acted on.
# Daily schedule with no successful backup in over 60h: covers the
# case where consecutive runs failed without the per-event alerts
# being acted on.
#
# 60h, not 30h. At 30h a single missed cycle pages, and a single
# missed cycle is the normal cost of touching the nodes: Velero moves
# data through pods, so draining a node kills whatever upload is in
# flight and the schedule is simply lost for that day — it is not
# retried. Rolling node work between 03:15 and 08:45 UTC therefore
# produced a guaranteed critical alert for every schedule it
# overlapped, which is noise about planned work rather than a signal
# about backups.
#
# 60h still catches what this is for. Two consecutive failures is no
# longer explicable by one maintenance window, and the per-event
# VeleroBackupFailed alert fires on the first failure regardless — so
# nothing is hidden, only the escalation to critical is deferred
# until a second cycle confirms it.
expr: |
time() - velero_backup_last_successful_timestamp{schedule!=""} > 30 * 3600
time() - velero_backup_last_successful_timestamp{schedule!=""} > 60 * 3600
and
velero_backup_last_successful_timestamp{schedule!=""} > 0
for: 10m
labels:
severity: critical
annotations:
summary: "Velero {{ $labels.schedule }} has no successful backup in 30h+"
description: "Last successful backup for {{ $labels.schedule }} was over 30 hours ago. With a daily schedule that means at least one full cycle missed. RPO is degrading."
summary: "Velero {{ $labels.schedule }} has no successful backup in 60h+"
description: "Last successful backup for {{ $labels.schedule }} was over 60 hours ago. With a daily schedule that is at least two consecutive cycles missed, which one maintenance window does not explain. RPO is degrading."
@@ -0,0 +1,25 @@
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: smartctl-exporter
namespace: monitoring
labels:
app.kubernetes.io/name: smartctl-exporter
# kube-prometheus-stack's Prometheus resource selects ServiceMonitors
# by this label; without it the resource exists but is never
# discovered.
release: kube-prometheus-stack
spec:
selector:
matchLabels:
app.kubernetes.io/name: smartctl-exporter
endpoints:
# smartctl polls the drives every 60s; scraping faster gains nothing.
- port: http-metrics
path: /metrics
interval: 60s
relabelings:
# Without this a series carries only the exporter pod's IP, which does
# not identify the machine the drive is in.
- sourceLabels: [__meta_kubernetes_pod_node_name]
targetLabel: node
@@ -0,0 +1,475 @@
{
"annotations": {"list": []},
"description": "NVMe health and performance per node. The top row answers 'is anything wrong'; the graphs below answer 'why'. Panels resolve each drive through node_nvme_info and node_uname_info rather than plotting the Linux device name, because NVMe enumeration order is not guaranteed to agree across machines — the same name can be a different drive on the next node. Which drive currently holds which role is in the inventory table, deliberately not in this text. Endurance comes from smartctl_exporter, whose series are labelled by node rather than by the exporter pod's IP — see the note at the bottom for what it still cannot tell you.",
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 1,
"id": null,
"links": [],
"panels": [
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"description": "Ceph's overall verdict. A HEALTH_WARN is frequently raised by something unrelated to the drives, so read the detail before treating it as a storage problem.",
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"mappings": [{"options": {"0": {"color": "green", "index": 0, "text": "HEALTH_OK"}, "1": {"color": "orange", "index": 1, "text": "HEALTH_WARN"}, "2": {"color": "red", "index": 2, "text": "HEALTH_ERR"}}, "type": "value"}],
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}, {"color": "orange", "value": 1}, {"color": "red", "value": 2}]},
"unit": "short"
},
"overrides": []
},
"gridPos": {"h": 4, "w": 5, "x": 0, "y": 0},
"id": 10,
"options": {"colorMode": "background", "graphMode": "none", "justifyMode": "auto", "orientation": "auto", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "textMode": "auto"},
"targets": [{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "ceph_health_status", "legendFormat": "", "refId": "A"}],
"title": "Ceph health",
"type": "stat"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"description": "OSDs the cluster knows about but which are not up. Should read zero. Counted as a difference against ceph_osd_metadata rather than compared to a fixed number, so adding or removing an OSD does not turn this panel into a false alarm. An OSD that is up but out shows as a Ceph health warning instead — it is a state, not a fault, and it does not belong in a red-or-green box.",
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"mappings": [],
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}, {"color": "red", "value": 1}]},
"unit": "short"
},
"overrides": []
},
"gridPos": {"h": 4, "w": 5, "x": 5, "y": 0},
"id": 11,
"options": {"colorMode": "background", "graphMode": "none", "justifyMode": "auto", "orientation": "auto", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "textMode": "auto"},
"targets": [
{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "sum(ceph_osd_metadata) - sum(ceph_osd_up)", "legendFormat": "", "refId": "A"}
],
"title": "OSDs down",
"type": "stat"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"description": "Raw Ceph capacity in use. The cluster average is the optimistic number: Ceph stops writing when a single OSD crosses the full ratio, so per-OSD headroom is what actually bites.",
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"mappings": [],
"max": 1,
"min": 0,
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}, {"color": "orange", "value": 0.75}, {"color": "red", "value": 0.85}]},
"unit": "percentunit"
},
"overrides": []
},
"gridPos": {"h": 4, "w": 5, "x": 10, "y": 0},
"id": 12,
"options": {"colorMode": "background", "graphMode": "area", "justifyMode": "auto", "orientation": "auto", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "textMode": "auto"},
"targets": [{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "ceph_cluster_total_used_bytes / ceph_cluster_total_bytes", "legendFormat": "", "refId": "A"}],
"title": "Ceph capacity used",
"type": "stat"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"description": "Fullest EPHEMERAL volume across the fleet — Talos's /var. Its contents are meant to be disposable: container images, kubelet state and pod scratch all rebuild. Filling it is still a problem, because a node with no room to write stops being useful long before anything is lost, and nothing rebalances /var the way Ceph rebalances a full OSD.",
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"mappings": [],
"max": 1,
"min": 0,
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}, {"color": "orange", "value": 0.7}, {"color": "red", "value": 0.85}]},
"unit": "percentunit"
},
"overrides": []
},
"gridPos": {"h": 4, "w": 5, "x": 15, "y": 0},
"id": 13,
"options": {"colorMode": "background", "graphMode": "area", "justifyMode": "auto", "orientation": "auto", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "textMode": "auto"},
"targets": [{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "max(1 - node_filesystem_avail_bytes{mountpoint=\"/var\"} / node_filesystem_size_bytes{mountpoint=\"/var\"})", "legendFormat": "", "refId": "A"}],
"title": "EPHEMERAL used (worst node)",
"type": "stat"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"description": "Which physical drive is which, resolved live rather than written down — this table is the answer to 'what is in the machines', so that no panel description has to be. The OSD column is joined from ceph_disk_occupation, so it follows Rook; a drive with no OSD is carrying either the system disk or EPHEMERAL.",
"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": 4},
"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": "(label_replace(node_nvme_info * on (instance) group_left(nodename) node_uname_info, \"nvmedev\", \"$1\", \"device\", \"(nvme[0-9]+)\") * on (nodename, nvmedev) group_left(ceph_daemon) label_replace(label_replace(ceph_disk_occupation, \"nodename\", \"$1\", \"instance\", \"(.*)\"), \"nvmedev\", \"$1\", \"devices\", \"(nvme[0-9]+)n[0-9]+\")) or (label_replace(node_nvme_info * on (instance) group_left(nodename) node_uname_info, \"nvmedev\", \"$1\", \"device\", \"(nvme[0-9]+)\") unless on (nodename, nvmedev) label_replace(label_replace(ceph_disk_occupation, \"nodename\", \"$1\", \"instance\", \"(.*)\"), \"nvmedev\", \"$1\", \"devices\", \"(nvme[0-9]+)n[0-9]+\"))",
"format": "table",
"instant": true,
"legendFormat": "",
"refId": "A"
}
],
"title": "Drive inventory (identity and role)",
"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, "nvmedev": true},
"indexByName": {},
"renameByName": {"nodename": "node", "device": "device", "model": "model", "serial": "serial", "firmware_revision": "firmware", "ceph_daemon": "ceph OSD"}
}
}
],
"type": "table"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"description": "Rated endurance consumed, worst drive in the fleet. This is the drive's own accounting against what the manufacturer warrants, not a failure prediction — a drive past 100 usually keeps working and starts eating its spare area. Read it for planning; read the SMART table for whether anything is failing now.",
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"mappings": [],
"max": 100,
"min": 0,
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}, {"color": "orange", "value": 80}, {"color": "red", "value": 95}]},
"unit": "percent"
},
"overrides": []
},
"gridPos": {"h": 4, "w": 4, "x": 20, "y": 0},
"id": 14,
"options": {"colorMode": "background", "graphMode": "area", "justifyMode": "auto", "orientation": "auto", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "textMode": "auto"},
"targets": [{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "max(smartctl_device_percentage_used)", "legendFormat": "", "refId": "A"}],
"title": "Worst NVMe wear",
"type": "stat"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"description": "Rated endurance consumed per drive. The line is nearly flat by nature — wear moves on a scale of weeks — so the useful reading is the spread between drives and the slope, not today's value. Joined to smartctl_device for the model name, because the device label alone is not comparable across nodes.",
"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": [],
"max": 100,
"min": 0,
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}, {"color": "orange", "value": 80}, {"color": "red", "value": 95}]},
"unit": "percent"
},
"overrides": []
},
"gridPos": {"h": 9, "w": 12, "x": 0, "y": 13},
"id": 15,
"options": {"legend": {"calcs": ["last"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
"targets": [
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"expr": "smartctl_device_percentage_used * on (node, device) group_left(model_name) smartctl_device",
"legendFormat": "{{node}} {{model_name}}",
"refId": "A"
}
],
"title": "NVMe wear",
"type": "timeseries"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"description": "The failure-now signals, as opposed to the endurance budget above. Spare below its threshold means the drive has run out of blocks to remap defects into; media errors are unrecoverable data-integrity failures and should never increment; the critical warning is a bitfield the drive raises itself. Written bytes is the counter behind the wear percentage, useful for projecting when a drive will reach it.",
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"custom": {"align": "auto", "cellOptions": {"type": "auto"}, "inspect": false},
"mappings": [],
"thresholds": {"mode": "absolute", "steps": [{"color": "text", "value": null}]}
},
"overrides": [
{"matcher": {"id": "byName", "options": "wear"}, "properties": [{"id": "unit", "value": "percent"}, {"id": "custom.cellOptions", "value": {"type": "color-text"}}, {"id": "thresholds", "value": {"mode": "absolute", "steps": [{"color": "green", "value": null}, {"color": "orange", "value": 80}, {"color": "red", "value": 95}]}}]},
{"matcher": {"id": "byName", "options": "spare"}, "properties": [{"id": "unit", "value": "percent"}]},
{"matcher": {"id": "byName", "options": "media errors"}, "properties": [{"id": "custom.cellOptions", "value": {"type": "color-text"}}, {"id": "thresholds", "value": {"mode": "absolute", "steps": [{"color": "green", "value": null}, {"color": "red", "value": 1}]}}]},
{"matcher": {"id": "byName", "options": "critical warning"}, "properties": [{"id": "custom.cellOptions", "value": {"type": "color-text"}}, {"id": "thresholds", "value": {"mode": "absolute", "steps": [{"color": "green", "value": null}, {"color": "red", "value": 1}]}}]},
{"matcher": {"id": "byName", "options": "powered on"}, "properties": [{"id": "unit", "value": "s"}]},
{"matcher": {"id": "byName", "options": "written"}, "properties": [{"id": "unit", "value": "bytes"}]}
]
},
"gridPos": {"h": 9, "w": 12, "x": 12, "y": 13},
"id": 16,
"options": {"cellHeight": "sm", "footer": {"countRows": false, "fields": "", "reducer": ["sum"], "show": false}, "showHeader": true, "sortBy": [{"desc": true, "displayName": "wear"}]},
"targets": [
{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "smartctl_device_percentage_used * on (node, device) group_left(model_name) smartctl_device", "format": "table", "instant": true, "refId": "A"},
{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "smartctl_device_available_spare * on (node, device) group_left(model_name) smartctl_device", "format": "table", "instant": true, "refId": "B"},
{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "smartctl_device_media_errors * on (node, device) group_left(model_name) smartctl_device", "format": "table", "instant": true, "refId": "C"},
{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "smartctl_device_critical_warning * on (node, device) group_left(model_name) smartctl_device", "format": "table", "instant": true, "refId": "D"},
{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "smartctl_device_power_on_seconds * on (node, device) group_left(model_name) smartctl_device", "format": "table", "instant": true, "refId": "E"},
{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "smartctl_device_bytes_written * on (node, device) group_left(model_name) smartctl_device", "format": "table", "instant": true, "refId": "F"}
],
"title": "SMART health",
"transformations": [
{"id": "merge", "options": {}},
{
"id": "organize",
"options": {
"excludeByName": {"Time": true, "__name__": true, "container": true, "endpoint": true, "instance": true, "job": true, "namespace": true, "pod": true, "service": true},
"indexByName": {},
"renameByName": {"node": "node", "device": "device", "model_name": "model", "Value #A": "wear", "Value #B": "spare", "Value #C": "media errors", "Value #D": "critical warning", "Value #E": "powered on", "Value #F": "written"}
}
}
],
"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": 22},
"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": 22},
"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": 31},
"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": 31},
"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. The thresholds are generic NVMe limits rather than any particular drive's rating — check the datasheet before treating a crossing as urgent.",
"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": 40},
"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": 40},
"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"},
"description": "EPHEMERAL fill level per node. Growth here is usually container images and pod scratch space rather than anything Ceph does.",
"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": [],
"max": 1,
"min": 0,
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}, {"color": "orange", "value": 0.7}, {"color": "red", "value": 0.85}]},
"unit": "percentunit"
},
"overrides": []
},
"gridPos": {"h": 8, "w": 24, "x": 0, "y": 49},
"id": 9,
"options": {"legend": {"calcs": ["last", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
"targets": [
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"expr": "(1 - node_filesystem_avail_bytes{mountpoint=\"/var\"} / node_filesystem_size_bytes{mountpoint=\"/var\"}) * on (instance) group_left(nodename) node_uname_info",
"legendFormat": "{{nodename}} /var",
"refId": "A"
}
],
"title": "EPHEMERAL usage",
"type": "timeseries"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"gridPos": {"h": 11, "w": 24, "x": 0, "y": 57},
"id": 8,
"options": {
"code": {"language": "plaintext", "showLineNumbers": false, "showMiniMap": false},
"content": "### What this dashboard cannot tell you\n\nThese notes are about mechanisms, not about the drives currently fitted. The inventory table above is the live answer to what is in the machines; nothing here restates it, because prose does not get re-read when hardware is swapped.\n\n**Wear is a budget, not a prediction.** The percentage is what the drive reports against its own rated endurance. Drives do not stop at 100 — they usually keep working and start consuming spare blocks — so read it for planning, and read available spare and media errors for whether something is failing now. Those are different questions and the alert rules treat them as such.\n\n**A green top row is not a healthy drive.** Endurance says nothing about the drive that dies of a controller fault tomorrow, and this dashboard cannot see one coming.\n\nCeph keeps its own wear figures for OSDs in the devicehealth module, which is a useful cross-check but covers OSDs only: `kubectl -n rook-ceph exec deploy/rook-ceph-tools -- ceph device ls`. A drive removed from Ceph is purged from that registry and its history goes with it.\n\n**Roles are assigned in configuration, not by device name.** Talos selects the system disk and the EPHEMERAL volume by serial, Rook selects the OSD by `/dev/disk/by-id` path. Two consequences outlive any particular set of drives: a broad selector such as `disk.transport == 'nvme'` matches every drive in the box, with the installer taking the OSD as the failure mode — and a drive retired from Ceph is not necessarily out of service, and putting a worn one on EPHEMERAL is a sensible destination rather than a hazard — that volume is built to be rebuilt. Check the inventory table for what a given drive holds before reading its wear as urgent.\n\n**A dying NVMe here hangs rather than errors.** Talos boots with `nvme_core.io_timeout=4294967295`, so NVMe commands never time out. Expect a stall, not an I/O error, and expect the latency panel to go flat rather than spike.\n\n**Kernel messages are not retained.** Talos kernel logs do not reach Loki — the only log jobs are Kubernetes pods and a systemd-journal from elsewhere. `talosctl -n <node> dmesg` is the only source, it is a ring buffer, and a reboot empties it. An NVMe error from before the last reboot left no trace anywhere.",
"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
@@ -275,8 +275,11 @@ spec:
- alertmanager-smtp
# SMTP via Proton submission (smtp.protonmail.ch:587 STARTTLS).
# The Proton SMTP token is a per-account credential generated in
# the Proton webapp (Settings -> IMAP/SMTP) and lives in the
# alertmanager-smtp SOPS-encrypted Secret. Outbound 587 from the
# the Proton webapp (Settings -> IMAP/SMTP). It is stored in
# OpenBao at apps/monitoring/alertmanager-smtp and projected into
# the alertmanager-smtp Secret by
# ./alertmanager-smtp-externalsecret.yaml — rotating the token
# means updating OpenBao, not this repo. Outbound 587 from the
# monitoring ns is not blocked by any CNP today.
config:
global:
@@ -15,6 +15,7 @@ resources:
- prometheus-gate-service.yaml
- prometheus-gate-ingress.yaml
- ci-diagnostics-rbac.yaml
- smartctl-exporter.yaml
- dashboards/
# prometheusrule-garage.yaml lives in ./monitoring-rules/ and is applied by a
# separate Flux Kustomization that dependsOn `monitoring` — otherwise the
@@ -0,0 +1,89 @@
# SMART/endurance metrics for every NVMe drive in the fleet (#549).
#
# Runs privileged: host device access is gated by the container device cgroup,
# and Kubernetes offers no per-device allowlist. Narrower settings do not work;
# the alternatives that were tried are in the commit history.
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: smartctl-exporter
namespace: monitoring
labels:
app.kubernetes.io/name: smartctl-exporter
spec:
selector:
matchLabels:
app.kubernetes.io/name: smartctl-exporter
template:
metadata:
labels:
app.kubernetes.io/name: smartctl-exporter
spec:
# Control planes are also the storage nodes here, so the exporter has to
# tolerate the control-plane taint or it would cover nothing at all.
tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
containers:
- name: smartctl-exporter
image: quay.io/prometheuscommunity/smartctl-exporter:v0.14.0
# No --smartctl.device: the exporter scans and rescans on its own.
# Device names are not stable across these machines.
args:
- --web.listen-address=:9633
ports:
- name: http-metrics
containerPort: 9633
protocol: TCP
securityContext:
runAsUser: 0
runAsNonRoot: false
privileged: true
# The API rejects privileged together with
# allowPrivilegeEscalation: false.
allowPrivilegeEscalation: true
readOnlyRootFilesystem: true
volumeMounts:
# Not read-only: smartmontools opens NVMe devices read-write.
- name: dev
mountPath: /dev
# Scratch space, because the root filesystem is read-only.
- name: tmp
mountPath: /tmp
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
memory: 128Mi
livenessProbe:
httpGet:
path: /metrics
port: http-metrics
initialDelaySeconds: 15
periodSeconds: 30
volumes:
- name: dev
hostPath:
path: /dev
type: Directory
- name: tmp
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: smartctl-exporter
namespace: monitoring
labels:
app.kubernetes.io/name: smartctl-exporter
spec:
clusterIP: None
selector:
app.kubernetes.io/name: smartctl-exporter
ports:
- name: http-metrics
port: 9633
targetPort: http-metrics
protocol: TCP
@@ -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.9.13 # {"$imagepolicy": "flux-system:music-assistant"}
image: ghcr.io/music-assistant/server:2.10.0 # {"$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.9.13 # {"$imagepolicy": "flux-system:music-assistant"}
image: ghcr.io/music-assistant/server:2.10.0 # {"$imagepolicy": "flux-system:music-assistant"}
env:
- name: LOG_LEVEL
value: info
@@ -1,18 +0,0 @@
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: barman-cloud-creds
namespace: paperless
annotations:
# Prune-disabled — a standing guard kept from the seed->paperless-KS handoff (barman creds).
kustomize.toolkit.fluxcd.io/prune: disabled
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: openbao
target:
name: barman-cloud-creds # keeps the old Secret's name — consumers unchanged
dataFrom:
- extract:
key: seed/paperless/barman-creds
@@ -1,3 +1,9 @@
# One Hetzner S3 key, one path, shared by every barman ObjectStore. Separate
# per-app paths would not make rotation independent — that needs one Hetzner
# key per consumer (#397).
#
# velero reads the same key from infrastructure/velero-hetzner, which holds it
# as a credentials file rather than these three fields.
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
@@ -15,4 +21,4 @@ spec:
name: hetzner-s3-creds # keeps the old Secret's name — consumers unchanged
dataFrom:
- extract:
key: seed/paperless/hetzner-creds
key: infrastructure/hetzner-s3
@@ -20,7 +20,6 @@ resources:
- cluster.yaml
- objectstore.yaml
- scheduled-backup.yaml
- barman-creds-externalsecret.yaml
- hetzner-creds-externalsecret.yaml
- pvcs.yaml
configMapGenerator:
@@ -12,4 +12,4 @@ spec:
name: hetzner-s3-creds
dataFrom:
- extract:
key: seed/trmnl/hetzner-creds
key: infrastructure/hetzner-s3
@@ -1,11 +1,8 @@
---
# Barman S3 credentials for wp-pg.
#
# Sourced from seed/paperless/hetzner-creds, which is not where they belong.
# Issue #397 wants woodpecker to own seed/woodpecker/hetzner-creds; creating
# that path needs OpenBao write access (`bao kv list seed` → 403 for the
# identities available here), so it stays open. The values are identical, so it
# is a one-line swap of the `key:` below once the path exists.
# One shared path for one shared credential — see the note in
# ../paperless/hetzner-creds-externalsecret.yaml.
#
# The namespace allowlist on the openbao ClusterSecretStore
# (infrastructure/post-crds/openbao-clustersecretstore.yaml) must include this
@@ -24,4 +21,4 @@ spec:
name: hetzner-s3-creds
dataFrom:
- extract:
key: seed/paperless/hetzner-creds
key: infrastructure/hetzner-s3
@@ -36,11 +36,9 @@ spec:
target:
name: hetzner-s3-creds
dataFrom:
# Reuses the paperless copy of the shared Hetzner S3 credentials (identical
# ACCESS_KEY_ID / ACCESS_SECRET_KEY / REGION) to avoid seeding a new OpenBao
# path for this migration — same approach woodpecker took. FOLLOW-UP: give
# wordpress its own seed/wordpress/hetzner-creds path once OpenBao can be
# written, and repoint this here (the value is the same, so it's a drop-in
# swap).
# One shared path for one shared credential. There is a single Hetzner S3
# key behind all of these — see the note in
# ../paperless/hetzner-creds-externalsecret.yaml for why per-app paths were
# the wrong shape and what would have to change for them to be right.
- extract:
key: seed/paperless/hetzner-creds
key: infrastructure/hetzner-s3
@@ -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.
@@ -33,22 +33,14 @@ spec:
remediation:
retries: 3
values:
# Roll 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 a changed ConfigMap leaves every agent
# running the previous datapath until something restarts it. Nothing did.
# 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
# every agent on the previous datapath indefinitely (#592).
#
# This is not hypothetical. vlan-bpf-bypass reached cilium-config and every
# v30iot macvlan workload stayed broken until the agents were deleted by
# hand, one node at a time (issue #592). policyMapMax has the same
# exposure, and its comment below says "takes effect on agent restart" —
# documentation standing in for automation.
#
# The trade: any cilium-config change now rolls the CNI cluster-wide,
# node by node. That is disruptive-ish, but it is what a datapath change
# already required — the difference is that it now happens on its own
# instead of being forgotten. Reloader is already deployed for exactly
# this pattern; named per-ConfigMap rather than blanket, as elsewhere.
# The trade is that any cilium-config change now rolls the CNI node by
# node — which is what a datapath change required anyway, only now it is
# not forgotten.
annotations:
configmap.reloader.stakater.com/reload: cilium-config
# Which pod labels feed security-identity computation. Patterns are
@@ -69,57 +61,42 @@ spec:
socketLB:
enabled: true
prometheus:
# The agent exported no metrics at all, so BPF map utilisation was
# invisible: when per-endpoint policy maps (cilium_policy_v3_*) run out
# of space the agent only says "no space left on device" in its log and
# pod sandbox creation fails (issue #452). cilium_bpf_map_pressure makes
# that observable — and alertable — before it stalls a rollout.
# Without agent metrics, BPF map utilisation is invisible: a full policy
# map only shows up as "no space left on device" in the log while pod
# sandbox creation fails (#452). cilium_bpf_map_pressure makes it
# alertable beforehand.
enabled: true
serviceMonitor:
enabled: true
bpf:
# Size of each endpoint's own cilium_policy_v3_<id> map, holding one
# entry per (identity, direction, protocol, port) the endpoint is
# allowed. When it fills, endpoint regeneration fails with "no space
# left on device", the CNI ADD call never returns, and the pod is stuck
# in sandbox creation — so this must stay comfortably above the busiest
# endpoint's entry count, tracked by cilium_bpf_map_pressure.
# Takes effect on agent restart; the map is sized at creation.
# Bootstrap installs Cilium from the inline manifest in
# kubernetes/talos/patches/cilium-cni.yaml, so bpf-policy-map-max there
# must move with this value or a cold rebuild starts with the old size.
# kubernetes/talos/cilium.yaml carries a third copy of the same key.
# Nothing enforces any of this; grep bpf-policy-map-max before changing it.
# Per-endpoint policy map size: one entry per (identity, direction,
# protocol, port) allowed. When it fills, endpoint regeneration fails
# with "no space left on device" and the pod hangs in sandbox creation.
# Keep it comfortably above the busiest endpoint, watched by
# 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
# filter" in cilium_drop_count_total.
#
# 30 is v30iot, the macvlan parent for the shelly-manager, matter-server,
# otbr and music-assistant attachments. On bare metal it is a VLAN
# sub-interface of enp2s0f0np0; inside the former Proxmox guests it was
# an untagged virtio NIC, which is why this was not needed before.
# Only VLAN 30 (v30iot) needs listing. Cilium attaches to devices that
# carry an address, so it sees mgmt0's frames already untagged by the
# 8021q layer — nothing for a VLAN filter to match. ens19 is deliberately
# address-less, so its frames only meet the filter on the shared parent,
# still tagged. Adding 40 would change nothing and would touch the
# datapath carrying etcd and the API.
#
# VLAN 40 (mgmt0) is deliberately NOT listed, and the asymmetry is the
# interesting part. Cilium attaches to devices that carry an address —
# `cilium-dbg status --verbose` lists enp87s0, enp2s0f0np0, enp2s0f1np1
# and mgmt0, but NOT ens19. mgmt0 holds the node IP, so Cilium owns it as
# a device and sees frames the 8021q layer has already untagged; nothing
# for a VLAN filter to match. ens19 is deliberately address-less (see
# talos/patches/k8s-1.yaml — host addresses on that L2 make node replies
# bypass the UDM), so Cilium never attaches to it and VLAN 30 frames only
# meet the filter on the shared parent, still tagged.
# The symptom when this is wrong is not a DHCP timeout: a tc-layer drop
# reaches an AF_PACKET sender as ENOBUFS, so the CNI dhcp plugin reports
# "no buffer space available" (#592).
#
# So the fix belongs on 30 alone. Listing 40 would change nothing, and
# would touch the datapath carrying etcd and the API for no reason.
#
# A tc-layer drop surfaces to an AF_PACKET sender as ENOBUFS, so the
# symptom is the CNI dhcp plugin reporting "no buffer space available"
# rather than a DHCP timeout. See issue #592.
#
# Same three-copy problem as policyMapMax above: vlan-bpf-bypass in
# kubernetes/talos/cilium.yaml and kubernetes/talos/patches/cilium-cni.yaml
# must move with this list. Takes effect on agent restart.
# Same three-copy problem as policyMapMax above. Takes an agent restart.
vlanBypass: [30]
# Let pods reach the cluster's own LoadBalancer external IPs; Cilium
# redirects the socket directly to the backend pod instead of sending
@@ -36,25 +36,16 @@ spec:
values:
container_runtime: containerd
# --- mTLS between agents and the LAPI: the durable fix for the recurring
# KubeDaemonSetRolloutStuck auth loop (#340) ---
# 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
# "machine not found" and crashloop until re-registered by hand — which
# wedges the whole `infrastructure` Kustomization behind a HelmRelease
# that never goes Ready (#340).
#
# The LAPI runs WITHOUT persistence (see persistentVolume note below), so
# its sqlite machine-registration DB is ephemeral and every LAPI pod restart
# wiped it. Under the old password flow the agents registered exactly ONCE
# (init container running `cscli lapi register --token`) and cached a
# password the wiped LAPI no longer recognised -> 401 "machine not found"
# -> CrashLoopBackOff on all three agents -> DaemonSet never Ready -> the
# crowdsec HelmRelease upgrade `--wait` times out forever -> the whole
# `infrastructure` Kustomization (and every app depending on it) stays
# unhealthy. That is the #340 outage.
#
# With client-cert auth the agent init container no longer registers at all
# (verified via `helm template`: the `cscli lapi register` step is gone).
# The agent presents a cert whose OU (agent-ou) the LAPI trusts, and a
# freshly restarted LAPI with an EMPTY DB auto-recreates each machine record
# from the presented cert on connect. So the system self-heals across LAPI
# restarts with no PVC, no Ceph, and no manual re-registration.
# With client certs the agent never registers: a restarted LAPI with an
# empty DB recreates each machine record from the presented cert, so this
# self-heals with no PVC and no intervention.
#
# cert-manager (already reconciled earlier in this same `infrastructure`
# stage) provisions a fully self-contained self-signed CA (selfSigned
@@ -87,13 +78,10 @@ spec:
lapi:
replicas: 1
# The chart defaults request 500m. Measured steady-state draw is 2m, and
# the request is what the scheduler reserves, so the default parks half a
# core of unusable capacity per LAPI. That is affordable on three nodes
# and is not on two: with one node out for maintenance the remaining pair
# sit at 100% of requested CPU while actually using ~22%, and nothing new
# can be placed. The limit stays at the chart default so a log flood can
# still burst; only the reservation shrinks.
# 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;
# the limit stays at the chart default so a log flood can still burst.
resources:
requests:
cpu: 50m
@@ -6,19 +6,12 @@
# fails the route over across all per-node peers (no single point of
# failure if a node/pod drops).
#
# Uses the ROOTLESS image (userspace wireguard-go). The netbird process
# terminates the WireGuard tunnel in userspace and reaches cluster
# services by opening its OWN sockets to them — NOT by L3-forwarding
# packets. That distinction is the whole point:
# - Cilium socket-LB DNATs the netbird process's own connections, so
# forwarding to ClusterIP / LB VIPs actually works. A kernel L3
# routing peer fails here: socket-LB never sees forwarded packets,
# so they hit the raw VIP and are blackholed (confirmed via conntrack).
# - No NET_ADMIN / no kernel WireGuard → runs fully unprivileged
# (drop ALL caps, runAsNonRoot), so it satisfies Pod Security too.
#
# Flow: mesh peer (100.96.x.x) --wg--> a per-node pod (userspace netbird)
# --own socket (socket-LB DNAT)--> service ClusterIP/LB VIP
# Must stay on the ROOTLESS image (userspace wireguard-go). The process
# terminates the tunnel in userspace and reaches services by opening its own
# sockets, which Cilium's socket-LB can DNAT. A kernel WireGuard peer
# L3-forwards instead, socket-LB never sees those packets, and they are
# blackholed at the raw VIP. Rootless also means no NET_ADMIN, so the pod
# runs fully unprivileged.
#
# Egress is locked down by the netbird-ingress-egress CiliumNetworkPolicy
# (network-policies/ingress-isolation.yaml).
@@ -41,18 +41,14 @@ spec:
- ports:
- port: "8080"
protocol: TCP
# shelly-manager (:8080) is likewise reached only through its own
# Authentik gate outpost. Note this rule and the vscode one are
# port-identical: because this CNP's endpointSelector is {}, a
# `toPorts: 8080` grant here applies to EVERY pod in the namespace that
# listens on 8080, not just the intended one — which is exactly how
# shelly-manager silently rode on the old traefik→:8080 allow written
# for code-server, and why removing that allow in 760e187 took
# shelly-manager's ingress down with it. The `fromEndpoints` selector is
# what keeps these two apart: each gate outpost can only reach its own
# target, enforced on the egress side by the matching CNPs in the
# authentik namespace (vscode-gate-egress.yaml,
# shelly-manager-gate-egress.yaml).
# shelly-manager, behind its own Authentik gate outpost.
#
# CAUTION for every :8080 rule here: this policy's endpointSelector is {},
# so a toPorts grant on 8080 applies to EVERY pod in the namespace
# listening on that port, not just the intended one. Three apps share
# :8080. What keeps them apart is the fromEndpoints selector plus the
# matching per-gate egress CNPs in the authentik namespace. Changing a
# :8080 rule here affects all of them.
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": authentik
@@ -61,11 +57,8 @@ spec:
- ports:
- port: "8080"
protocol: TCP
# esphome (ESPHome Device Builder, :6052) is likewise reached only through
# its own Authentik gate outpost. Unlike the :8080 vscode/shelly rules
# above, :6052 is unique to esphome, so this namespace-wide (endpointSelector
# {}) toPorts grant doesn't silently apply to any other app — the
# fromEndpoints selector still scopes it to the esphome-gate outpost.
# esphome (:6052), behind its own gate outpost. Port is unique to it, so
# the namespace-wide grant does not reach anything else.
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": authentik
@@ -74,13 +67,8 @@ spec:
- ports:
- port: "6052"
protocol: TCP
# zigbee2mqtt (Z2M frontend, :8080) is likewise reached only through its own
# Authentik gate outpost. Like the vscode/shelly-manager rules above this is
# the SHARED :8080 (zigbee2mqtt is the third app on that port), so this
# namespace-wide (endpointSelector {}) grant applies to every :8080 pod — the
# fromEndpoints selector scopes it to the zigbee2mqtt-gate outpost, and the
# matching authentik-side zigbee2mqtt-gate-egress.yaml scopes egress to the
# zigbee2mqtt pod specifically.
# zigbee2mqtt frontend, behind its own gate outpost. Shared :8080 — see the
# caution above.
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": authentik
@@ -89,11 +77,8 @@ spec:
- ports:
- port: "8080"
protocol: TCP
# zone-configurator (Everything Presence mmWave zone configurator, :42069)
# is likewise reached only through its own Authentik gate outpost. Like
# esphome's :6052, :42069 is unique to it, so this namespace-wide (endpoint
# Selector {}) toPorts grant doesn't silently apply to any other app; the
# fromEndpoints selector scopes it to the zone-configurator-gate outpost.
# zone-configurator (:42069), behind its own gate outpost. Port is unique
# to it.
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": authentik
@@ -131,25 +116,16 @@ spec:
- fromEntities:
- host
- remote-node
# World ingress (IoT-VLAN devices via the BGP LoadBalancers) is NOT
# granted namespace-wide here. A blanket `fromEntities: world` on this
# endpointSelector:{} policy exposed EVERY pod in the namespace to the
# untrusted IoT VLAN / any BGP-routed source — vscode (:8080),
# zone-configurator (:42069), otbr, whisper, etc. — not just the two
# LoadBalancers that need it. World is now scoped per-target in the
# dedicated policies at the bottom of this file: world -> mosquitto
# :1883/:8883 and world -> home-assistant :8123 only. (matter-server on
# 10.2.x.226 rides a multus net1 interface that bypasses Cilium policy
# entirely, so it is unaffected either way.)
# ICMP — all standard ICMPv4 and ICMPv6 types from any cluster pod.
# Per Cilium docs, "If any layer 4 policy is specified, then ICMP
# will be blocked unless it's related to a connection that is
# otherwise allowed by the policy." The CT-RELATED path is not
# passing these in practice (Hubble shows ICMPv6 DestinationUnreachable
# being POLICY_DENIED on the egress side of established DNS flows),
# so allow ICMP explicitly. Same shape as commit 3c517a0 — widened
# from the error class to the full standard type set. Cilium's
# ICMPField requires an explicit type per entry; no wildcard.
# Do NOT add a blanket `fromEntities: world` to this policy. With
# endpointSelector {} it exposes every pod in the namespace to the IoT
# VLAN and any BGP-routed source, not just the LoadBalancers that need it.
# World is scoped per target in the dedicated policies at the bottom of
# this file. (matter-server rides a multus interface that bypasses Cilium
# policy entirely and is unaffected either way.)
# ICMP must be allowed explicitly: once any L4 policy exists Cilium blocks
# it unless CT-RELATED passes it, which in practice it does not. Cilium's
# ICMPField takes one type per entry — there is no wildcard, so the list
# has to be spelled out.
- fromEndpoints:
- matchExpressions:
- key: io.kubernetes.pod.namespace
@@ -186,10 +162,8 @@ spec:
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": apps
# In-cluster InfluxDB: HA's HACS InfluxDB integration writes state
# changes here (replaces the legacy http://influxdb.srv.renner.uno
# target — see kubernetes/apps/influxdb/ha-token-job.yaml for the
# write-bucket token wiring).
# In-cluster InfluxDB: HA's HACS integration writes state changes here.
# Token wiring is in apps/influxdb/ha-token-job.yaml.
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": influxdb
@@ -213,9 +187,8 @@ spec:
- toEntities:
- host
- remote-node
# World: IoT-VLAN devices, ESPHome, HA Cloud, garage S3 (initial
# tarball seed), legacy InfluxDB on VM 108, third-party integration
# endpoints (Hue, Roomba, Philips, Brother IPP, …).
# World: IoT-VLAN devices, HA Cloud, object storage, and third-party
# integration endpoints on the LAN and internet.
- toEntities:
- world
# ICMP — egress mirror of the ingress ICMP allow above. Lets HA
@@ -1,12 +1,5 @@
---
# Baseline ingress policy for the `woodpecker` namespace (wp-pg now; server and
# agent arrive in stage 2).
#
# Unlike the `apps` baseline this replaces, every allow here lands on a
# namespace holding one service, so a namespace-scoped grant is not a blanket
# grant across unrelated workloads. That is the point of the move: in `apps`,
# letting the CNPG operator reach wp-pg would also have opened gitea, gatus,
# external-dns and ical-proxy to the whole database namespace (issue #433).
# Baseline ingress policy for the `woodpecker` namespace.
#
# CAUTION when adding rules here: this policy has `endpointSelector: {}`, so
# every rule below applies to EVERY pod in the namespace. A `toPorts` block
@@ -15,11 +8,7 @@
# belongs in its own policy with its own selector, as
# `wp-pg-allow-cnpg-operator` below does.
#
# No egress section, so egress is unrestricted. Deliberate for now and revisited
# in stage 2: wp-pg needs DNS plus `world` for WAL archiving to Hetzner S3, and
# the server/agent add gitea, the Kubernetes API and outbound plugin pulls.
# Writing that ruleset once, with the full workload set present, beats guessing
# it here and silently breaking the WAL archiving this stage exists to verify.
# There is no egress section, so egress is unrestricted. Still open (#433).
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
@@ -42,12 +42,28 @@ spec:
# the agent-injector mutating webhook is unused surface.
enabled: false
server:
# Single-node raft (not standalone/file) so we get `bao operator raft
# snapshot` as a consistent backup primitive. TLS terminates at the
# Traefik ingress; in-cluster traffic is plain HTTP.
# Raft (not standalone/file) for two reasons: `bao operator raft
# snapshot` as a consistent backup primitive, and real HA across three
# nodes. TLS terminates at the Traefik ingress; in-cluster traffic is
# plain HTTP.
ha:
enabled: true
replicas: 1
# Three, not two. Raft needs a quorum, and the quorum of two is two —
# a two-replica cluster tolerates no failures at all and would stop
# the moment one pod is sealed or rescheduled, which is worse than
# the single replica it replaced. Three tolerates one.
#
# What this buys, and what it does not: a node drain seals the replica
# on it, and raft elects a new leader from the remaining standbys
# within seconds, so the service does not go down. It does NOT remove
# the manual unseal — a sealed server can neither serve requests nor
# act as standby, so that replica stays out until someone unseals it.
#
# The consequence for rolling node maintenance is worth stating: a
# Talos upgrade drains all three nodes in turn, sealing each replica
# as it goes. Unseal each one as it comes back, or the third drain
# takes the last unsealed replica with it and quorum is lost anyway.
replicas: 3
raft:
enabled: true
setNodeId: true
@@ -65,8 +81,32 @@ spec:
cluster_address = "[::]:8201"
}
# retry_join is what makes a replica join the existing raft
# cluster instead of coming up as an isolated, uninitialized
# node. Without it, scaling the StatefulSet produces pods that
# never become peers and would each need a manual
# `bao operator raft join`.
#
# All three peers are listed on every node, including the node
# itself. That is deliberate: the stanza is a list of possible
# leaders, a node ignores its own entry, and listing all three
# means the set is identical on every replica and stays correct
# regardless of which pod currently leads.
#
# Addressed through the headless service, so each entry resolves
# to one specific pod rather than load-balancing across them.
storage "raft" {
path = "/openbao/data"
retry_join {
leader_api_addr = "http://openbao-0.openbao-internal:8200"
}
retry_join {
leader_api_addr = "http://openbao-1.openbao-internal:8200"
}
retry_join {
leader_api_addr = "http://openbao-2.openbao-internal:8200"
}
}
service_registration "kubernetes" {}
@@ -5,25 +5,15 @@ kind: Kustomization
# secret. The matching OpenBao policy/role for each lives in ../policies/ and is
# bootstrapped manually (not GitOps-managed).
#
# IMAGE PINNING: every rotator here uses a DATED TAG of secret-rotator
# (`:YYYY-MM-DD`), never a digest and never `:latest`. This is a registry-local
# image, so it does not need digest pinning, and a dated tag makes "what actually
# runs" a reviewable diff.
# IMAGE PINNING: dated tags (`:YYYY-MM-DD`), never a digest and never
# `:latest`. Do NOT switch to digests — untagged manifests in this registry are
# garbage-collected once the tags move past them, so a pinned digest silently
# stops resolving after a few unrelated rebuilds of build-ci-images.yml. These
# jobs are quarterly, so the resulting ImagePullBackOff looks like nothing
# happening rather than like a failure (#458).
#
# Digests were tried and actively broke: untagged manifests are garbage-collected
# once :latest and the dated tags move past them, so a digest pinned today stops
# resolving after a few rebuilds. That decay is driven by edits to
# .woodpecker/build-ci-images.yml — a shared file — so adding an UNRELATED image
# republishes secret-rotator and orphans the manifest every rotator here was
# pinned to. 11 of these were pinned to digests that no longer existed and would
# have ImagePullBackOff'd on their first scheduled run (issue #458); because they
# are quarterly, that would have looked like nothing happening rather than like a
# failure.
#
# Keep them all on one tag: it is a single binary containing every mode
# (random | ha-token | cloudflare-roll | netbird-key | proxmox-token |
# gitea-token | woodpecker-secret), so a split serves no purpose and just makes
# the next bump partial.
# Keep every rotator on the same tag: it is one binary containing all modes, so
# splitting only makes the next bump partial.
#
# ADDING A ROTATOR: copy an existing file, and check you did not inherit a
# digest pin from an older example — nothing enforces this automatically, so it
@@ -53,7 +53,13 @@ spec:
command: ["/bin/sh", "-ec"]
args:
- |
export BAO_ADDR=http://openbao.openbao.svc.cluster.local:8200
# openbao-active, not openbao: with more than one replica
# the plain service selects any pod, and a raft snapshot has
# to be taken against the leader. Standbys forward ordinary
# requests, but this is not one to leave to chance — the
# active service selects the leader by label, so the
# snapshot is always taken where it is consistent.
export BAO_ADDR=http://openbao-active.openbao.svc.cluster.local:8200
BAO_TOKEN=$(bao write -field=token auth/kubernetes/login \
role=openbao-snapshot \
jwt="$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)")
@@ -24,12 +24,10 @@ spec:
- start: "fd99::"
stop: "fd99::ff"
---
# Cilium BGPv2 control-plane config peering each k8s node with the UDM
# over its MGMT LAN interface — NOT over v30iot. Control-plane traffic
# belongs on the management network; the IoT VLAN is the noisy untrusted
# L2 segment we're explicitly trying to keep cluster infrastructure off
# of. Each node sources from its eth0 (10.2.40.x / fd40::<host>) via
# the kernel routing decision; no explicit localAddress override needed.
# Cilium BGPv2 control-plane config peering each node with the UDM over the
# management LAN — never over v30iot, which is the untrusted IoT segment.
# The source address comes from the kernel routing decision, so no explicit
# localAddress override is needed.
#
# Two parallel sessions, one per address family:
# - v4 session: peer 10.2.40.1, family IPv4 unicast only
@@ -1,27 +1,16 @@
---
# Velero Schedules for stateful app namespaces.
#
# Scope is restricted to PersistentVolumeClaims via includedResources.
# Flux owns every other K8s object (Deployment, Service, Ingress,
# ConfigMap, Secret, …) — backing those up shadows current git state
# on a rebuild: Velero restores stale manifests from yesterday's
# snapshot, then the app Kustomization clobbers them with whatever git
# says — except when the Kustomization is blocked on the restore chain,
# leaving the cluster temporarily serving the stale spec (e.g. the
# paperless Ingress missing prod hostnames after the 2026-04-22
# rebuild). PVC-only is the canonical "back up data, not state" scope
# (see ~/Notes/wiki/k8s-backup-scope.md and the project memory rule).
# Scope is PersistentVolumeClaims only. Flux owns every other object, and
# backing those up shadows git on a rebuild: Velero restores yesterday's
# manifests and the cluster serves them until the Kustomization catches up.
# Back up data, not state.
#
# snapshotMoveData=true keeps doing the heavy lifting via Kopia +
# node-agent: PVC contents are uploaded to Garage and restored
# snapshotMoveData uploads PVC contents through Kopia and the node-agent,
# independently of the K8s API surface.
#
# Naming convention: <app>-daily for the app's Schedule. Restore is a
# manual, operator-driven procedure — see kubernetes/RUNBOOK-cold-rebuild.md.
# (There is no automatic bootstrap-restore Job; it was removed as dead code,
# issue #200.)
#
# Retention: 30 days, matching the live CNPG Barman ObjectStore policy.
# Naming: <app>-daily. Restore is manual — see RUNBOOK-cold-rebuild.md.
# Retention matches the CNPG Barman policy.
apiVersion: velero.io/v1
kind: Schedule
@@ -203,22 +192,13 @@ 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
# 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
# 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
# PVC restore on top of that flow for the same PVC needs its own review.
# Restore of these schedules is manual — see
# kubernetes/RUNBOOK-cold-rebuild.md.
# Daily at 06:45 local. Sweeps every PVC in the home-assistant namespace —
# several apps under apps/ set `namespace: home-assistant` in their
# Kustomization, so their PVCs land here too.
#
# home-assistant-config has its own restore path: the HA pod's `seed`
# initContainer pulls the native HAOS backup. Layering a Velero PVC restore
# on top of that for the same PVC needs its own review.
schedule: "45 6 * * *"
useOwnerReferencesInBackup: false
template:
@@ -229,6 +209,13 @@ spec:
storageLocation: default
ttl: 720h0m0s
snapshotMoveData: true
# Excludes the CNPG cluster's PVC. A crash-consistent snapshot of a
# running Postgres data directory is not a restorable backup, and Barman
# covers that database anyway — see ha-pg-scheduled-backup.yaml.
labelSelector:
matchExpressions:
- key: cnpg.io/cluster
operator: DoesNotExist
---
apiVersion: velero.io/v1
kind: Schedule
@@ -8,6 +8,10 @@ spec:
image: quay.io/ceph/ceph:v19.2.6 # {"$imagepolicy": "flux-system:ceph"}
allowUnsupported: false
dataDirHostPath: /var/lib/rook
# Defaults to false. With three OSDs and size 3 there is no spare copy, so
# this gate is the only thing keeping a rolling OSD change from taking the
# next one down mid-recovery. Paired with storage.osdMaxUpdatesInParallel.
upgradeOSDRequiresHealthyPGs: true
mon:
count: 3
allowMultiplePerNode: false
@@ -28,112 +32,40 @@ spec:
daemon:
keyRotationPolicy: KeyGeneration
keyGeneration: 2
# Bump CephFS subvolume cloner pool from 4 → 16. Velero CSI Snapshot
# Data Movement provisions a backupPVC per source PVC by cloning the
# VolumeSnapshot, and a 4-thread default stalls migrate-immich's
# multi-PVC Backup with `rados: "all cloner threads are busy"`. 16
# gives headroom for parallel restores during cluster rebuild too
# (paperless + audiobookshelf + immich + ... all clone at once).
# CephFS cloner pool raised from the default 4. Velero clones a
# VolumeSnapshot per PVC, and 4 threads stall a multi-PVC backup with
# `rados: "all cloner threads are busy"`.
cephConfig:
mon:
# Ceph's 30% default assumes the mon has a filesystem to itself, where
# 30% free is a meaningful reserve. Here the mons live on
# dataDirHostPath /var/lib/rook — the node's /var, shared with ~29 GB
# of containerd image and snapshot storage — and the mon DB itself is
# ~106 MB. So the default measures container churn, not Ceph: k8s-2
# trips MON_DISK_LOW whenever the image cache fills, and image GC does
# not clear it (most of /var/lib/containerd is overlayfs snapshots held
# by running containers, which GC cannot reclaim — see #434).
# 20% of a 60 GB /var still leaves ~12 GB against a 106 MB database.
# mon_data_avail_crit stays at the 5% default, and CephMonDiskLow in
# monitoring-rules watches /var directly for the real risk, which is the
# node filling rather than the mon growing.
# The Ceph default, stated rather than omitted. Deleting a line here does
# NOT restore the default: `ceph config` keeps whatever was last applied,
# so the old value would sit in the mon config database indefinitely.
# Change values in place; to actually drop one, `ceph config rm` it.
mon_data_avail_warn: "30"
# Off, against the upstream default. The forced compact_range on every
# paxos trim rewrote the whole store hundreds of times a day and was the
# single largest source of disk writes on these nodes.
#
# BARE-METAL MIGRATION: revisit this. Give the mons their own volume and
# the default 30% becomes meaningful again, at which point this override
# should be dropped rather than carried over. It cannot be done on the
# current cluster — every StorageClass is Ceph-backed, so a mon PVC
# would be circular, and Rook only applies mon.volumeClaimTemplate to
# newly created mons, never migrating existing ones.
mon_data_avail_warn: "20"
# Stop compacting the whole RocksDB prefix on every paxos trim.
#
# Upstream default is true — "Compact a certain prefix (including paxos)
# when we trim its old states" — which is fine at low paxos rates and
# pathological here. Measured 2026-08-19:
#
# paxos transactions ~99,000/day (1.15/s, sampled over 60 s)
# paxos logical data 811 MB/day (21.9 GB over 27 d uptime)
# ACTUAL mon disk writes 80 GB/day per mon, 240 GB/day for three
# (cgroup io.stat: 2.15 TB/27 d)
# => ~98x write amplification, for a ~106 MB database
#
# That was ~62% of all OS-disk writes on these nodes and roughly a
# quarter of everything the cluster writes — more than every client
# workload combined. With paxos_trim_min at 250, ~99k transactions/day
# means hundreds of forced range compactions per day, each rewriting the
# store.
#
# The churn itself is 99% cluster log and mgr stats, not real cluster
# activity: logm holds 6.7M paxos versions and mgrstat 4.4M, against
# osdmap's 9.6k. Every Rook-operator poll (mgr stat / versions / status /
# report / osd dump — 23 audit entries per minute per mon) becomes a
# cluster-log entry, hence a paxos transaction, replicated to all three
# mons. Reducing that polling is the complementary fix and is tracked
# separately; this setting removes the amplification regardless of rate.
#
# This does NOT disable compaction. RocksDB's own levelled compaction
# still runs and still reclaims trimmed keys; what stops is the extra
# forced compact_range on every trim. Store growth is therefore bounded
# by normal compaction, and there are two backstops if that assumption is
# wrong: mon_data_size_warn (15 GB default) raises HEALTH_WARN, which
# CephHealthWarn in monitoring-rules already alerts on, and
# mon_compact_on_start below compacts fully on every mon restart.
#
# Watch after applying: `du -sh /var/lib/rook/mon-*/store.db` on a node,
# or the mon store size in `ceph status`. If it grows past ~1 GB and does
# not come back down, revisit rather than leaving it.
# This does NOT disable compaction — RocksDB's levelled compaction still
# reclaims trimmed keys. Backstops if that proves wrong:
# mon_data_size_warn raises HEALTH_WARN, and mon_compact_on_start below
# compacts fully on every mon restart.
mon_compact_on_trim: "false"
# Compensating control for the above: a full compaction on mon start.
# Upstream default is false. Mons restart on upgrades, node reboots and
# reschedules, so this gives a periodic full reclaim without paying for
# one on every trim. Cheap here — the store is ~106 MB.
# Compensating control, against the upstream default: mons restart often
# enough for this to give periodic full reclaim without paying for one
# on every trim.
mon_compact_on_start: "true"
mgr:
mgr/volumes/max_concurrent_clones: "16"
# Discard/TRIM for the QLC NVMe per host (CT1000P3PSSD8, Crucial P3 Plus —
# earlier comments here said "P2", which is a different drive). These need
# an OSD restart to take effect (init-time). Baseline 2026-05-05:
# 71.6 MB/s avg / 0.89 s avg latency on a 5-min replicated 4 MiB write
# bench (post-SLC-cache). See ceph-speed-baseline-2026-05-05 in the wiki.
# Discard/TRIM. Init-time settings — an OSD restart is needed to apply.
#
# bluestore_prefer_deferred_size_ssd / bluestore_deferred_batch_ops_ssd are
# DELIBERATELY LEFT AT THE CEPH DEFAULTS (0 and 16) and must not be raised
# again without re-reading this.
#
# They were previously set to the HDD-style 65536 / 64 as a throughput tune.
# prefer_deferred_size defers every write SMALLER than the threshold: the
# write goes first into RocksDB's WAL and is applied to the object store
# afterwards. That is a double write, and at 65536 it caught essentially
# every write this cluster makes — SQLite pages are 4 KiB, Postgres pages
# 8 KiB. Ceph disables deferral on SSD (default 0) precisely because solid
# state does not need the random-write batching that helps spinning rust.
#
# Two reasons it was the wrong trade here:
# - The benchmark that justified it cannot have exercised it. It used
# 4 MiB writes, 64x ABOVE the 64 KiB cutoff, so not one of those writes
# was ever deferred. The setting was validated by a workload it does
# not affect.
# - It doubles writes onto QLC, which has the lowest endurance of any
# NAND type. Measured 2026-08-19: all three OSD drives at 80/82/87%
# endurance used, advancing ~0.13 points/day, i.e. rated life exhausted
# between Nov 2026 and Jan 2027 — and simultaneously, since size=3 on
# 3 OSDs means every drive absorbs 100% of cluster writes (see #434).
#
# This cluster is write-light (~117 GB/day at the RBD client layer), so
# throughput was never the binding constraint. Endurance is. If small-write
# latency ever becomes the real problem, fix it with more OSDs rather than
# by paying a 2x write tax on every 4 KiB page.
# bluestore_prefer_deferred_size_ssd and bluestore_deferred_batch_ops_ssd
# are DELIBERATELY LEFT AT THE CEPH DEFAULTS and must not be raised again.
# Deferral double-writes everything below the threshold, and an HDD-style
# value catches every write this cluster makes (4 KiB SQLite, 8 KiB
# Postgres pages). Ceph disables it on SSD for that reason. Throughput has
# never been the binding constraint here; endurance is. If small-write
# latency becomes a real problem, add OSDs instead.
osd:
bdev_enable_discard: "true"
bdev_async_discard: "true"
@@ -147,52 +79,45 @@ spec:
ssl: false
monitoring:
enabled: true
# Dedicated Ceph cluster network on VLAN 49 / 10.99.0.0/29, carried by an
# X710 SFP+ port PCI-passed-through to each Talos K8s VM (see
# talos/patches/k8s-{1,2,3}.yaml and opentofu/vm_15{0,1,2}_k8s_*.tf).
# `addressRanges` requires `provider: host` — Rook v1.18 explicitly notes
# the default (CNI) provider ignores addressRanges. Public range matches
# the existing v40srv subnet that the controlplane VMs already live on.
# Dedicated Ceph cluster network on VLAN 49, attached to the OSDs through
# Multus. The attachment is san-network-attachment.yaml.
#
# Only `cluster` is selected. Do not add `public`, and do not reach for
# `addressRanges` — that is honoured only under provider: host, which puts
# every daemon including the mons on host networking. A mon's address then
# becomes the node's DHCP lease, and when a lease moves the mon crash-loops
# on "Cannot assign requested address" with no node free to fail over to,
# because all three already hold the mon host ports.
#
# With `public` unset, client traffic stays on the pod network and VLAN 49
# carries Ceph-internal traffic only — which is also why Rook's Multus
# prerequisites about host routing do not apply here.
#
# Changing the provider on a LIVE cluster needs a step this file cannot
# express: `ceph config` retains public_network and cluster_network after
# they disappear from here, and an OSD that comes up on the pod network with
# a stale public_network crash-loops on "Failed to pick public address".
# Clear them with `ceph config rm global public_network` (and cluster_network)
# before the rollout. A cluster built from scratch starts with an empty
# config database and never sees this.
network:
provider: host
addressRanges:
public:
- "10.2.40.0/24"
cluster:
- "10.99.0.0/29"
# Nodes are listed explicitly rather than useAllNodes, so that a node can be
# taken out of the storage fleet by removing it here. Under useAllNodes the
# operator re-provisions an OSD on any node whose device still carries a
# BlueStore signature, which makes purging an OSD ahead of pulling its drive
# impossible: the OSD is recreated within a reconcile and starts backfilling
# onto the drive being replaced.
provider: multus
selectors:
cluster: rook-ceph/ceph-san
# Nodes listed explicitly, not useAllNodes: under useAllNodes the operator
# re-provisions an OSD on any node whose device still carries a BlueStore
# signature, so purging an OSD before pulling its drive is impossible — it
# comes back within a reconcile and backfills onto the drive being replaced.
#
# All three nodes are bare metal and see three NVMe drives each, so they name
# their OSDs by /dev/disk/by-id path. A bare name would be actively wrong:
# Talos enumerates these machines' drives in a different order than Proxmox
# did, and the kernel name lands on the Kingston SYSTEM disk or on EPHEMERAL
# depending on the boot. Rook would probably decline the system disk for
# carrying partitions, but that is luck rather than intent. The by-id path is
# derived from model and serial and does not depend on enumeration at all.
#
# k8s-3 demonstrated why: it enumerated the Kingston as nvme0n1 on one boot
# and the Crucial as nvme0n1 on the very next one.
#
# k8s-1 (jupiter)
# 50026B7382B48B86 Kingston — Talos system disk
# 2332E86B749A Crucial — Talos EPHEMERAL (/var)
# S437NX0M400426 PM983 — this OSD
#
# k8s-2 (saturn)
# 50026B7382DA5F6E Kingston — Talos system disk
# 2332E86B74BC Crucial — Talos EPHEMERAL (/var)
# S437NX0M403161 PM983 — this OSD
#
# k8s-3 (neptune)
# 50026B7382B48B90 Kingston — Talos system disk
# 2326E6E8E67D Crucial — Talos EPHEMERAL (/var)
# S437NF0M501176 PM983 — this OSD
# Devices are named by /dev/disk/by-id path. Kernel names are not stable
# across boots on these machines — k8s-3 enumerated a different drive as
# nvme0n1 on two consecutive boots — and a bare name can land on the system
# disk or on EPHEMERAL.
storage:
# One at a time. The default of 20 would restart every OSD in this cluster
# simultaneously — every PG inactive until they return. Binds any change
# that rewrites the OSD deployment spec, not just version upgrades.
osdMaxUpdatesInParallel: 1
useAllNodes: false
useAllDevices: false
nodes:
@@ -207,18 +132,12 @@ spec:
- fullpath: /dev/disk/by-id/nvme-SAMSUNG_MZQLB960HAJR-00007_S437NF0M501176
config:
osdsPerDevice: "1"
# Storage layer must never be evicted by kubelet under node pressure
# and must never be picked first by the Linux OOM killer. system-node-
# critical (PriorityValue 2000001000) is the highest built-in class:
# kubelet preserves these pods last, and kernel sets oom_score_adj to
# -997 so anything else dies before a Ceph daemon does.
# Highest built-in class: kubelet evicts these last and the kernel gives
# them the lowest oom_score_adj.
priorityClassNames:
all: system-node-critical
# memory requests == limits => Guaranteed QoS. Combined with the
# priority class above, the pod's oom_score_adj is the lowest possible.
# CPU limits are deliberately NOT set — Ceph daemons benefit from
# bursting under recovery/backfill, and a CPU limit would throttle
# them; CPU pressure on this cluster is not a real concern (~15%).
# requests == limits gives Guaranteed QoS. CPU limits are deliberately NOT
# set — Ceph daemons burst during recovery and a limit would throttle them.
resources:
mon:
requests:
@@ -1,6 +1,10 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
# Attachment for the VLAN 49 cluster network. Inert until cephcluster.yaml
# names it under network.selectors.cluster — creating it changes nothing on
# its own.
- san-network-attachment.yaml
- cephcluster.yaml
- toolbox.yaml
- storageclass.yaml
@@ -0,0 +1,69 @@
---
# The Ceph cluster network as a Multus attachment, so the OSDs reach VLAN 49
# without every Ceph daemon landing on host networking to get there. The
# alternative and why it was rejected is in cephcluster.yaml.
#
# ISOLATION
#
# VLAN 49 carries Ceph-internal traffic and nothing else. Two consequences
# are deliberate:
#
# No gateway and no routes. There is no router on this segment and there
# must not be one. whereabouts adds neither unless asked, so nothing here
# can leak onto or off the SAN. This also means no `sbr` chaining is
# needed — unlike the v30iot attachments, there is no DHCP-supplied default
# route to confine, because there is no DHCP and no default route.
#
# The pool is the whole usable /29, .1 through .6. It was .4-.6 while the
# migration ran, because the nodes still held .1-.3 for as long as Ceph
# needed host networking to reach this link — three addresses for three
# OSDs, no spare. The nodes have since given those up (the link is only a
# macvlan parent now and needs no address of its own), so the reservation
# has nothing left to avoid.
#
# Keeping the pool inside the /29 rather than moving it elsewhere on the
# segment is not cosmetic. There is no router here, so an address outside
# the /29 would be unreachable from anything still using one inside it —
# which is exactly what would break a future rolling change, where migrated
# and un-migrated OSDs have to keep talking to each other.
#
# Six addresses for three OSDs is the headroom the migration could not
# afford: room for a leaked allocation to sit until the reconciler collects
# it, and room to add a second OSD per node without touching this file.
#
# IPAM
#
# whereabouts, because neither alternative works on this segment: dhcp needs
# a server that must not exist here, and host-local keeps its state per node
# with no cluster-wide coordination, so two nodes would hand out the same
# address on shared L2. See infrastructure/multus/whereabouts-daemonset.yaml.
#
# MTU 9000 matches the link. macvlan children inherit the master's MTU, but
# it is stated so that a change to the parent does not silently halve
# replication throughput without anything in the tree recording the
# expectation.
apiVersion: k8s.cni.cncf.io/v1
kind: NetworkAttachmentDefinition
metadata:
name: ceph-san
namespace: rook-ceph
spec:
# `master` is an interface name. enp2s0f1np1 is the second X710 SFP+ port,
# a busPath-derived predictable name (net.ifnames=1 via the Image Factory
# schematic) that is identical on k8s-1/2/3 and cannot shuffle across
# reboots.
config: |
{
"cniVersion": "0.3.1",
"name": "ceph-san",
"type": "macvlan",
"master": "enp2s0f1np1",
"mode": "bridge",
"mtu": 9000,
"ipam": {
"type": "whereabouts",
"range": "10.99.0.0/29",
"range_start": "10.99.0.1",
"range_end": "10.99.0.6"
}
}
@@ -7,11 +7,8 @@ spec:
interval: 24h
url: https://traefik.github.io/charts
---
# Traefik runs alongside ingress-nginx during the migration off the
# archived kubernetes/ingress-nginx project. Shares the `ingress` namespace
# so the post-migration topology is just "ingress namespace, one controller".
# Pilots: shelly-manager.home.renner.uno. ingress-nginx remains the default
# IngressClass; this controller is opt-in via `ingressClassName: traefik`.
# The cluster's only ingress controller, and the only IngressClass. It
# replaced ingress-nginx, which was retired after that project was archived.
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
@@ -44,27 +41,18 @@ spec:
values:
deployment:
replicas: 2
# Entrypoint-default Middlewares for websecure: baseline security
# response headers (HSTS/CSP/X-Frame-Options/etc.) and a generous
# abuse/brute-force rate limit, both applied to every router on this
# entrypoint without per-Ingress annotations. See
# security-headers-middleware.yaml / rate-limit-middleware.yaml for the
# actual settings and rationale. This is the same entrypoint-default
# wiring mechanism the (currently detached) crowdsec-bouncer Middleware
# documents re-enabling via — independent of that CrowdSec enforcement
# decision, which stays off pending the client-IP-visibility fix
# referenced below.
# Entrypoint-default Middlewares for websecure: security response headers
# and a rate limit, applied to every router on this entrypoint without
# per-Ingress annotations. Settings live in
# security-headers-middleware.yaml and rate-limit-middleware.yaml.
additionalArguments:
- --entryPoints.websecure.http.middlewares=ingress-security-headers@kubernetescrd,ingress-rate-limit@kubernetescrd
# CrowdSec bouncer plugin (Yaegi), loaded but NOT globally attached.
# Enforcement was disabled after it caused an IPv6 ingress outage on
# first deploy: Traefik sees a single shared/SNAT'd source IP for v6
# ingress (no real per-client IP), so one http-admin-interface-probing
# hit (the e2e scan) banned that shared IP and 403'd all v6 clients.
# Re-attach the bouncer (additionalArguments entrypoint middleware ->
# ingress-crowdsec-bouncer@kubernetescrd) only after fixing client-IP
# visibility (forwardedHeaders/trustedIPs or the v6 masquerade) and
# adding an internal/LAN whitelist. The plugin + crowdsec-middleware.yaml
# CrowdSec bouncer plugin (Yaegi), loaded but deliberately NOT attached.
# Traefik sees one shared SNAT'd source for IPv6 ingress, so a single
# probe banned that address and 403'd every v6 client. Do not re-attach
# it until client-IP visibility is fixed (forwardedHeaders/trustedIPs or
# the v6 masquerade) and a LAN whitelist exists.
# The plugin + crowdsec-middleware.yaml
# stay in place so re-enabling is a one-line change. Detection (the
# crowdsec agents parsing Traefik logs) remains on and is unaffected.
experimental:
+22 -48
View File
@@ -5,45 +5,26 @@ machine:
- k8s-3.srv.renner.uno
- k8s.renner.uno
- 10.2.40.60
# Install from the Image Factory metal installer, not the stock
# ghcr.io/siderolabs/installer that `talosctl gen config` defaults to.
# Extensions live in the installer image, so with the stock one the schematic
# is only ever a property of the boot media and nothing lands on disk —
# `talosctl get extensions` returns empty on a node installed that way.
# Image Factory metal installer, NOT the stock ghcr.io/siderolabs/installer
# that `talosctl gen config` defaults to. Extensions live in the installer
# image; install with the stock one and nothing lands on disk —
# `talosctl get extensions` comes back empty.
#
# What the schematic carries, and why each matters here:
# The schematic carries intel-ucode, thunderbolt and i915, and sets
# net.ifnames=1, which is what the per-node VLAN parents match against.
# Changing which extensions are present means a new schematic hash and a
# reinstall or upgrade, not an edit here.
#
# siderolabs/intel-ucode CPU microcode. On a VM the hypervisor supplied
# it; on bare metal nothing does except the BIOS.
# siderolabs/thunderbolt Inert until machine.kernel.modules asks for
# thunderbolt/thunderbolt_net. Present so enabling
# the MS-01 mesh later needs neither a new image
# nor a reinstall — which is exactly the property
# lost by installing without it.
# siderolabs/i915 Intel GPU microcode and kernel modules for the
# Alder Lake-P Iris Xe in the MS-01. No workload
# requests /dev/dri today; it is here for the same
# reason as thunderbolt, so that wanting hardware
# transcoding later is a config change rather than
# another rolling upgrade of every node.
# Read only at install and upgrade time — editing this does nothing to a
# running node. The tag must match the version being upgraded to, or the
# upgrade is a downgrade. Talos only tests migration between adjacent minor
# releases: take the latest patch of each intermediate minor rather than
# skipping one.
#
# Not included, and why, so the absences are not mistaken for oversights:
# the X710 (i40e) and I226 (igc) NICs need no firmware extension —
# intel-ice-firmware covers E810/ice only. mei is a prerequisite for Intel
# Arc discrete GPUs, of which there are none. iscsi-tools, nvme-cli and
# util-linux-tools have no consumer: storage is Ceph RBD, and device health
# is reported by Ceph itself rather than a node-level exporter.
#
# 0f6fb8bd… is the bare-metal schematic: same net.ifnames=1 as the Proxmox
# one (the predictable interface names the per-node VLAN parents match
# against), minus qemu-guest-agent. It supersedes a21cec8f…, which was
# identical but without i915 — see RUNBOOK-cold-rebuild.md.
#
# Only read at install and upgrade time. Changing it here does nothing to a
# running node until that node is upgraded. Bump the tag with the Talos
# version — it must match the running version, or the upgrade is a downgrade.
# The Kubernetes version is a separate decision, moved by
# `talosctl upgrade-k8s`.
install:
image: factory.talos.dev/metal-installer/0f6fb8bd8bb53013e9743b7c7b099d96fb9c32fd2464ced32693c2dc0ea5feb9:v1.12.11
image: factory.talos.dev/metal-installer/0f6fb8bd8bb53013e9743b7c7b099d96fb9c32fd2464ced32693c2dc0ea5feb9:v1.13.9
features:
rbac: true
stableHostname: true
@@ -65,20 +46,13 @@ machine:
nodeIP:
validSubnets:
- 10.2.40.0/24
# Lower the image GC thresholds from the kubelet defaults (85/80) to
# bound containerd's image cache, which is the largest single consumer
# of /var. Spegel P2P pull-through softens the re-pull cost when GC'd
# images are needed back.
# Lower than the kubelet defaults (85/80) to bound containerd's image
# cache, the largest single consumer of /var.
#
# These thresholds do NOT control whether Ceph's mon reports
# MON_DISK_LOW, and must not be tuned for that. Measured on k8s-2:
# image GC frees only the content store (~8.6 GB), while the bulk of
# /var/lib/containerd is ~20 GB of overlayfs snapshots — unpacked
# layers shared with running containers, which GC cannot reclaim.
# Dropping high to 60 deleted 28 images and moved free space by 0.02
# percentage points. The mon's own data is ~106 MB; its 30%-free
# warning is measuring a filesystem it merely shares, so the lever for
# it is elsewhere (mon volume, /var size, or mon_data_avail_warn).
# Do NOT tune these to clear Ceph's MON_DISK_LOW. Image GC reclaims only
# the content store; the bulk of /var/lib/containerd is overlayfs
# snapshots shared with running containers, which GC cannot touch. The
# lever for the mon warning is elsewhere.
extraConfig:
imageGCHighThresholdPercent: 70
imageGCLowThresholdPercent: 60
+51 -60
View File
@@ -1,32 +1,25 @@
# k8s-1 runs Talos directly on jupiter's MS-01 hardware; k8s-2 and k8s-3 are
# still Proxmox VMs. Every machine-specific selector below differs from theirs
# for that reason: there are no virtio NICs, no PCI-passthrough layer, and all
# four physical NICs and all three NVMe drives are visible to Talos at once.
#
# Networking uses the v1.12 multi-document config rather than the deprecated
# machine.network.interfaces. That is deliberate: the docs do not define what
# happens when the same link is configured by both schemas, so this file keeps
# every link in one schema instead of straddling them.
# Networking uses the multi-document config, not the deprecated
# machine.network.interfaces. Keep every link in one schema: the docs do not
# define what happens when both configure the same link.
machine:
network:
hostname: k8s-1
sysctls:
# ens19 (VLAN 30, v30iot) is LINK ONLY — no host addressing. It exists as
# the macvlan parent for the v30iot pod attachments
# (matter/otbr/shelly-manager/music-assistant), whose children do their own
# DHCP on that L2. Host addresses were never needed and are actively
# harmful: a connected 10.2.30.0/24 route makes node replies to IoT-subnet
# clients bypass the UDM, which breaks every UDM-DNATed flow (hairpin NAT:
# un-translated SYN-ACK -> client kernel RST, verified live 2026-07-23).
# This disables v6 entirely on that link, including link-local.
# ens19 (VLAN 30, v30iot) is LINK ONLY — it is the macvlan parent for the
# v30iot pod attachments and must never get host addressing. A connected
# 10.2.30.0/24 route makes node replies bypass the UDM and breaks every
# DNATed flow.
net.ipv6.conf.ens19.disable_ipv6: "1"
# These two sit on the untagged fallback network, where nothing may run.
# Left enabled they autoconfigure a ULA and an IPv6 default route from its
# RAs, which ties with mgmt0's and costs the cluster IPv6 egress.
# Disabling v6 on a VLAN parent does not affect the VLAN child.
net.ipv6.conf.enp2s0f0np0.disable_ipv6: "1"
net.ipv6.conf.enp87s0.disable_ipv6: "1"
install:
# Three NVMe drives are ordinary block devices to Talos here: the Kingston
# OM8PGP41024Q-A0 system disk, the Samsung PM983 that Rook claims as the
# Ceph OSD, and the retired Crucial P3 that carries EPHEMERAL (see the
# VolumeConfig below). Every one of them is selected by serial. Nothing in
# this file may use a broad selector: `disk.transport == 'nvme'` matches all
# three, and the failure mode is Talos taking the OSD drive.
# Select disks by serial only. A broad selector such as
# `disk.transport == 'nvme'` matches every drive in the machine, and the
# failure mode is the installer taking the Ceph OSD drive.
diskSelector:
serial: 50026B7382B48B86
certSANs:
@@ -36,15 +29,10 @@ machine:
cluster:
allowSchedulingOnControlPlanes: true
---
# EPHEMERAL (/var) goes on the retired Crucial P3, not the system disk.
#
# That drive is at 87% of its rated endurance and was this node's Ceph OSD until
# the PM983 replaced it. /var is exactly the right role for a disk in that
# state: containerd's image and snapshot store, pod logs and the Ceph mon store
# are all reconstructible, so the drive is consumable here in a way it is not as
# an OSD. It also gives /var ~931 GB instead of the 64 GB the VM-era zvol had,
# which is the constraint that makes k8s-2 and k8s-3 trip DiskPressure whenever
# they absorb another node's pods.
# EPHEMERAL (/var) goes on the retired Ceph OSD drive, deliberately: its
# contents are reconstructible, so a worn disk is consumable here in a way it
# is not as an OSD. Note /var/lib/etcd lives here too, which is the one thing
# on the volume that is not free to lose.
#
# THIS ONLY TAKES EFFECT ON FIRST PROVISION. Talos applies volume configuration
# solely when the volume does not yet exist; a config applied after EPHEMERAL
@@ -52,8 +40,8 @@ cluster:
# requires wiping the disk and booting install media again.
#
# Matched by serial deliberately. `disk.transport == 'nvme' && !system_disk`
# would match the PM983 as well, and losing that coin flip means Talos formats
# the OSD drive.
# still matches the OSD drive, and losing that coin flip means Talos formats
# it.
apiVersion: v1alpha1
kind: VolumeConfig
name: EPHEMERAL
@@ -63,30 +51,25 @@ provisioning:
minSize: 50GiB
grow: true
---
# X710 port 0 carries the switch trunk. It takes no addressing itself — the
# port's untagged VLAN is the 192.168.8.0/24 network, which this node has no
# business being on — so management and v30iot both ride it as tagged VLANs.
# X710 port 0 carries the switch trunk and takes no addressing itself — its
# untagged VLAN is a network this node has no business being on. Management
# and v30iot ride it as tagged VLANs.
#
# Links are addressed by predictable kernel name. Those derive from PCI
# topology, not probe order — though not immune: adding the third NVMe
# renumbered the buses and moved the unused second I226 from enp89s0 to
# enp90s0. Neither name used here was affected.
# Links are named by predictable kernel name, which derives from PCI topology
# and can still shift when hardware is added.
#
# LinkAliasConfig would be the better way to pin these, and is worth
# reintroducing — but only once it is proven. Aliasing both X710 ports by
# permanent MAC and using the aliases as VLAN parents produced a node with no
# address on any interface: the aliases were never created, so every document
# referencing them did nothing, including the static address on port 1. Verify
# any alias-based revision with `talosctl apply-config --mode=try`, which rolls
# back on its own, before making it permanent.
# LinkAliasConfig would pin these better, but aliasing the X710 ports by MAC
# and using the aliases as VLAN parents left the node with no address on any
# interface — the aliases were never created and every document referencing
# them silently did nothing. Prove any alias-based revision with
# `talosctl apply-config --mode=try` first; it rolls back on its own.
apiVersion: v1alpha1
kind: LinkConfig
name: enp2s0f0np0
up: true
---
# Management, VLAN 40. Carries the node IP, etcd, the API and Ceph's public
# network. The k8s-1 VM reached 10.2.40.174 over this same physical port via
# Proxmox's v40srv VNet (vmbr0.40), so the switch trunks VLAN 40 here.
# network.
apiVersion: v1alpha1
kind: VLANConfig
name: mgmt0
@@ -94,6 +77,15 @@ vlanID: 40
parent: enp2s0f0np0
up: true
---
# DHCP, and the lease is not reserved in this repo — reservations live on the
# UDM out of band. Addresses do move under a running node.
#
# kube-apiserver does not follow a re-IP: it advertises $(POD_IP) fixed at pod
# start and the lease reconciler keeps renewing the masterlease under the old
# address, so `default/kubernetes` hands out a dead endpoint until the static
# pod restarts (#635). After any node re-IPs, check that
# `kubectl get endpoints kubernetes -n default` agrees with
# `kubectl get nodes -o wide` and reboot the node if it does not.
apiVersion: v1alpha1
kind: DHCPv4Config
name: mgmt0
@@ -105,10 +97,8 @@ kind: Layer2VIPConfig
name: 10.2.40.60
link: mgmt0
---
# Named ens19 on purpose. The four v30iot macvlan NADs hard-code
# `master: ens19`, and k8s-2/k8s-3 still present the VLAN as a virtio NIC of
# that name. VLANConfig.name is free-form, so keeping it lets one set of NADs
# serve both VM and bare-metal nodes for as long as the cluster is mixed.
# Named ens19 on purpose: the v30iot macvlan NADs hard-code `master: ens19`.
# VLANConfig.name is free-form, so renaming this link means editing them too.
apiVersion: v1alpha1
kind: VLANConfig
name: ens19
@@ -116,14 +106,15 @@ vlanID: 30
parent: enp2s0f0np0
up: true
---
# Dedicated Ceph cluster network on X710 port 1, VLAN 49 / 10.99.0.0/29,
# untagged on the wire. Used by Rook for OSD<->OSD replication via
# CephCluster.spec.network.addressRanges.cluster. Not routed off its own
# segment, and deliberately has no default route.
# Dedicated Ceph cluster network on X710 port 1, VLAN 49, untagged on the
# wire. OSD-to-OSD replication and nothing else.
#
# The node holds NO address here on purpose. Rook attaches the link to the OSD
# pods through a Multus macvlan, and a macvlan parent only needs to be up.
# Giving the node an address would put it on a segment reserved for
# Ceph-internal traffic.
apiVersion: v1alpha1
kind: LinkConfig
name: enp2s0f1np1
up: true
mtu: 9000
addresses:
- address: 10.99.0.1/29
+17 -17
View File
@@ -9,11 +9,14 @@ machine:
# macvlan parent for the v30iot pod attachments; host addresses on that L2
# make node replies bypass the UDM and break every UDM-DNATed flow.
net.ipv6.conf.ens19.disable_ipv6: "1"
# Same for the physical ports on the untagged fallback network: left
# enabled they take an IPv6 default route from its RAs and cost the
# cluster IPv6 egress.
net.ipv6.conf.enp2s0f0np0.disable_ipv6: "1"
net.ipv6.conf.enp87s0.disable_ipv6: "1"
install:
# Three NVMe drives are visible on bare metal. Select the installer target
# by serial so it cannot land on the OSD drive or on EPHEMERAL, whatever
# order the kernel enumerates them in — Talos and Proxmox do not agree on
# that order.
# Select the installer target by serial so it cannot land on the OSD drive
# or on EPHEMERAL, whatever order the kernel enumerates them in.
diskSelector:
serial: 50026B7382DA5F6E
certSANs:
@@ -23,17 +26,15 @@ machine:
cluster:
allowSchedulingOnControlPlanes: true
---
# EPHEMERAL (/var) goes on the retired Crucial P3, not the system disk. That
# drive is at 81% of its rated endurance and is finished as an OSD, but
# everything EPHEMERAL holds is reconstructible, so it is consumable in this
# role. It also gives /var ~931 GB instead of the 64 GB the VM-era zvol had.
# EPHEMERAL (/var) goes on the retired Ceph OSD drive, deliberately: its
# contents are reconstructible, so a worn disk is consumable in this role.
#
# ONLY TAKES EFFECT ON FIRST PROVISION. Talos applies volume configuration
# solely when the volume does not yet exist; applied later it is silently
# ignored and relocating needs a disk wipe and a boot from install media.
#
# Matched by serial deliberately: a broad selector such as
# `disk.transport == 'nvme' && !system_disk` would match the PM983 as well.
# `disk.transport == 'nvme' && !system_disk` still matches the OSD drive.
apiVersion: v1alpha1
kind: VolumeConfig
name: EPHEMERAL
@@ -76,9 +77,8 @@ kind: Layer2VIPConfig
name: 10.2.40.60
link: mgmt0
---
# Named ens19 on purpose: the four v30iot macvlan NADs hard-code
# `master: ens19`, and k8s-3 still presents the VLAN as a virtio NIC of that
# name. VLANConfig.name is free-form, so one set of NADs serves both.
# Named ens19 on purpose: the v30iot macvlan NADs hard-code `master: ens19`.
# VLANConfig.name is free-form, so renaming this link means editing them too.
apiVersion: v1alpha1
kind: VLANConfig
name: ens19
@@ -86,13 +86,13 @@ vlanID: 30
parent: enp2s0f0np0
up: true
---
# Dedicated Ceph cluster network on X710 port 1, VLAN 49 / 10.99.0.0/29,
# untagged on the wire. OSD<->OSD replication only. Not routed off its own
# segment and deliberately has no default route.
# Dedicated Ceph cluster network on X710 port 1, VLAN 49, untagged on the
# wire. OSD-to-OSD replication only, not routed off its own segment.
#
# The node holds NO address here on purpose. Rook attaches the link to the OSD
# pods through a Multus macvlan, and a macvlan parent only needs to be up.
apiVersion: v1alpha1
kind: LinkConfig
name: enp2s0f1np1
up: true
mtu: 9000
addresses:
- address: 10.99.0.2/29
+12 -15
View File
@@ -1,18 +1,18 @@
# k8s-3 runs Talos directly on neptune's MS-01 hardware. Structurally identical
# to k8s-1.yaml — see that file for the reasoning behind each choice; only the
# serials and addresses differ. neptune was the last node converted, so all
# three control-plane nodes now share this layout and no Proxmox remains.
# Structurally identical to k8s-1.yaml — see that file for the reasoning
# behind each choice; only the serials and addresses differ.
machine:
network:
hostname: k8s-3
sysctls:
# v30iot NIC carries no host addressing — see k8s-1.yaml.
net.ipv6.conf.ens19.disable_ipv6: "1"
# Same for the physical ports on the untagged fallback network — see
# k8s-1.yaml.
net.ipv6.conf.enp2s0f0np0.disable_ipv6: "1"
net.ipv6.conf.enp87s0.disable_ipv6: "1"
install:
# Three NVMe drives are visible on bare metal. Select the installer target
# by serial so it cannot land on the OSD drive or on EPHEMERAL, whatever
# order the kernel enumerates them in — Talos and Proxmox do not agree on
# that order.
# Select the installer target by serial so it cannot land on the OSD drive
# or on EPHEMERAL, whatever order the kernel enumerates them in.
diskSelector:
serial: 50026B7382B48B90
certSANs:
@@ -22,10 +22,8 @@ machine:
cluster:
allowSchedulingOnControlPlanes: true
---
# EPHEMERAL (/var) goes on the retired Crucial P3, not the system disk. That
# drive is at 83% of its rated endurance and is finished as an OSD, but
# everything EPHEMERAL holds is reconstructible, so it is consumable in this
# role. Available spare is still 100% and it reports no media errors.
# EPHEMERAL (/var) goes on the retired Ceph OSD drive, deliberately: its
# contents are reconstructible, so a worn disk is consumable in this role.
#
# ONLY TAKES EFFECT ON FIRST PROVISION. Talos applies volume configuration
# solely when the volume does not yet exist; applied later it is silently
@@ -72,11 +70,10 @@ vlanID: 30
parent: enp2s0f0np0
up: true
---
# Dedicated Ceph cluster network — see k8s-1.yaml.
# Dedicated Ceph cluster network — see k8s-1.yaml. The node holds NO address
# here on purpose; the link is only a macvlan parent for the OSD pods.
apiVersion: v1alpha1
kind: LinkConfig
name: enp2s0f1np1
up: true
mtu: 9000
addresses:
- address: 10.99.0.3/29