Compare commits

..
Author SHA1 Message Date
issue-fix-bot b1901047a0 fix: [alert] CephHealthWarn (rook-ceph) (#562)
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-24 12:47:12 +00:00
19 changed files with 154 additions and 457 deletions
+2 -9
View File
@@ -129,20 +129,13 @@ steps:
- echo "$VAULT_PASS" > /tmp/.vault_pass && chmod 600 /tmp/.vault_pass
- export ANSIBLE_VAULT_PASSWORD_FILE=/tmp/.vault_pass
- |
# Ensure Talos image exists on all Proxmox nodes via API.
#
# jupiter is NOT in this list: it runs Talos on bare metal and is no
# longer a Proxmox node, so the storage API returns nothing for it and
# the download-wait loop below would burn its full 60x10s before
# giving up. saturn and neptune still host k8s-2/k8s-3 as VMs and
# still need the nocloud image; bare-metal nodes boot the metal-amd64
# image from their own installed disk instead.
# Ensure Talos image exists on all Proxmox nodes via API
TALOS_VERSION="v1.12.6"
TALOS_SCHEMATIC="88e3752db032a08082972bd0adf16a80849e3870c3f95cf5b669936c5699e5ad"
TALOS_URL="https://factory.talos.dev/image/$TALOS_SCHEMATIC/$TALOS_VERSION/nocloud-amd64.raw"
TALOS_FILENAME="talos-$TALOS_VERSION-$(echo "$TALOS_SCHEMATIC" | cut -c1-12)-nocloud-amd64.raw"
API_TOKEN="$TF_VAR_proxmox_user!$TF_VAR_proxmox_token_id=$TF_VAR_proxmox_token_secret"
for node in saturn neptune; do
for node in jupiter saturn neptune; do
EXISTS=$(curl -sk "$TF_VAR_proxmox_api_url/api2/json/nodes/$node/storage/local/content" \
-H "Authorization: PVEAPIToken=$API_TOKEN" 2>/dev/null | grep -c "$TALOS_FILENAME" || true)
if [ "$EXISTS" -gt 0 ]; then
+1 -1
View File
@@ -167,7 +167,7 @@ permanent recovery.
## CNPG restore-test (verify backups are restorable — non-destructive)
The monthly Velero restore-test (`kubernetes/infrastructure/velero/`) exercises
The weekly Velero restore-test (`kubernetes/infrastructure/velero/`) exercises
PVC restores but **explicitly does not touch CNPG** (barman-cloud is a separate
code path). Run this drill periodically to prove the CNPG backups are actually
restorable, **without touching the live clusters**. It restores into a throwaway
-24
View File
@@ -79,30 +79,6 @@ 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.
**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.
```
https://factory.talos.dev/image/a21cec8f35ae32ab5e9a452e7b13500ca8776053930b8e182b9cb23eed186b64/v1.12.6/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:
- 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.
## Step 2 — talos: form the cluster + get kubeconfig
```sh
@@ -179,32 +179,16 @@ data:
# Ecowitt WS90 weather station. Joined after the ZHA-mirrored block above
# was written, hence listed apart from it.
#
# This device also carries a non-default ZCL reporting configuration
# (per-attribute min interval and reportable-change deadbands). That is
# device-side state, not expressible here — Z2M has no settings field for
# it — so it is documented, with restore commands, in
# zigbee2mqtt-ws90-reporting.md. It is lost whenever Z2M re-runs
# `configure` on the device, and a fresh PVC will not have it.
#
# The station transmits every 8.8s, as a burst of seven per-cluster ZCL
# frames; Z2M publishes its full cumulative state once per frame. debounce
# merges a burst into one publish carrying the last value per attribute,
# and must stay below the transmit interval — a window that never sees
# silence never flushes.
#
# Every few days the firmware zeroes its readings for ~8s, then skips a
# transmit cycle and recovers on the next. The zeros propagate attribute
# by attribute through a burst rather than arriving together, and they
# start inside an otherwise normal cycle — the last valid reading and the
# first zero are ~20ms apart.
#
# Merging cuts an episode from six published bad frames to one. It does
# NOT make that frame recognisable: it carries whatever subset had zeroed
# when the burst ended, which may be every field or only temperature, and
# a frame reading {"temperature":0,"humidity":92,"pressure":96.9} is not
# separable from a valid winter reading. Since the merge is last-wins and
# the zeros arrive at the tail, it also discards the valid reading that
# led the burst. This bounds the corruption; it does not filter it.
# The firmware drops all readings to zero for a few seconds every few
# days. Merged, that surfaces as a single all-zero frame; unmerged, the
# zeros spread across the seven frames and none of them is individually
# distinguishable from a valid reading.
#
# Z2M reads this only at startup (requiresRestart), and this file is
# SEED-ONLY per the header — the running instance takes it from its PVC.
@@ -1,149 +0,0 @@
# WS90 Zigbee reporting configuration
Records the ZCL attribute-reporting configuration applied to the Wetterstation
(Ecowitt WS90 powered by Shelly, IEEE `0xc02cedfffe958f40`) and how to restore
it, because **this configuration lives on the device and in Z2M's
`database.db`, not in git**. Zigbee2MQTT has no settings-schema field for
reporting configuration, so there is nothing declarative to reconcile — Flux
cannot restore it and a fresh PVC will not have it.
## Applied configuration
Endpoint 1, all intervals in seconds. `change` is in the attribute's raw ZCL
units; the physical deadband is `change ÷ scale`.
| attribute | cluster | min | max | change | deadband | converter default |
| --- | --- | --- | --- | --- | --- | --- |
| temperature | `msTemperatureMeasurement` (1026) | 5 | 3600 | 10 | 0.1 °C | 10 / 3600 / 100 |
| humidity | `msRelativeHumidity` (1029) | 5 | 3600 | 50 | 0.5 % | 10 / 3600 / 100 |
| pressure | `msPressureMeasurement` (1027) | 5 | 3600 | 1 | 1 hPa | 10 / 3600 / 50 |
| illuminance | `msIlluminanceMeasurement` (1024) | 5 | 3600 | 5 | 5 lx | 10 / 3600 / 5 |
| windSpeed | `shellyWS90Wind` (0xfc01/0) | 5 | 3600 | 1 | — | 10 / 3600 / 1 |
| windDirection | `shellyWS90Wind` (0xfc01/4) | 5 | 3600 | 1 | — | 10 / 3600 / 1 |
| gustSpeed | `shellyWS90Wind` (0xfc01/7) | 5 | 3600 | 1 | — | 10 / 3600 / 1 |
| uvIndex | `shellyWS90UV` (0xfc02/0) | 5 | 3600 | 1 | — | 10 / 3600 / 1 |
| rainStatus | `shellyWS90Rain` (0xfc03/0) | 5 | 3600 | 1 | — | 10 / 3600 / 1 |
| precipitation | `shellyWS90Rain` (0xfc03/1) | 5 | 3600 | 1 | — | 10 / 3600 / 1 |
`genPowerCfg.batteryVoltage` (cluster 1, attribute 33) is deliberately left at
its hourly default.
The three `shellyWS90*` clusters are manufacturer-specific and require
`"options": {"manufacturerCode": 5264}` on every reconfiguration request.
## Why min is 5 and not 10
The station transmits every 8.8 s. `min` is a hold-off: a change-triggered
report is held until at least `min` seconds after the previous one. With
`min: 10` every report was therefore held past a transmit cycle and landed on
the next, so attributes reported at best every 17.6 s and drifted out of phase
with each other — the published stream showed no 8.8 s periodicity at all,
just a smear across 2–8 s.
At `min: 5` each attribute reports on its own transmit. The MQTT stream then
carries one publish per transmit cycle, measured at a mean gap of 8.69 s.
Total publish volume *fell* (~631/h to ~445/h) because each publish now
carries a whole cycle instead of a fragment of one.
Any value below 8.8 s achieves this. 5 leaves margin without approaching the
cycle time.
## Why the deadbands differ from the converter defaults
The defaults come from the shared `modernExtend` helpers in
zigbee-herdsman-converters (`m.temperature()`, `m.pressure()`, `m.humidity()`,
`m.illuminance()`), which the WS90 definition in `shelly.ts` composes without
overrides. They are anti-chatter values sized for battery-powered indoor
sensors, where report volume costs coin-cell life and mesh airtime. This
station is mains-and-solar and exists to resolve small changes, so the
trade-off runs the other way.
Each value above is matched to the resolution the device actually reports:
temperature and pressure arrive in 0.1-unit steps, humidity in whole percent.
Pressure is worth calling out. The converter default of `change: 50` with
`scale: 10` is a 5 kPa (50 hPa) deadband, which real barometric pressure never
traverses — so pressure only ever updated on the hourly `max` heartbeat, and
`pressure_trend` and `weather_condition` were derived from that. The same
library's older `reporting.ts` helper configures the identical ZCL attribute
with `change: 5`, a 5 hPa deadband, so the two code paths disagree by 10× and
the modernExtend value is the outlier.
## When this is lost
Anything that makes Z2M re-run `configure` on the device overwrites all of it
with the converter defaults:
- re-pairing or re-joining the device
- an explicit `zigbee2mqtt/bridge/request/device/configure`
- a converter update that changes the definition and triggers reconfiguration
It also will not exist on a fresh PVC. Re-apply with the commands below.
## Re-applying
Published from the `discovery-guard` sidecar, which already holds the
`zigbee2mqtt` broker credential (ACL `readwrite zigbee2mqtt/#`, which covers
`bridge/request/#`) so no credential has to leave the cluster:
Ten separate requests, one per attribute — written out literally rather than
generated by a shell helper, because the nested quoting through `kubectl exec
-- sh -c` is where this goes wrong. Each line below is in the exact form that
was executed and verified.
```sh
POD=$(kubectl get pod -n home-assistant -l app.kubernetes.io/name=zigbee2mqtt -o jsonpath='{.items[0].metadata.name}')
```
```sh
kubectl exec -n home-assistant "$POD" -c discovery-guard -- sh -c 'mosquitto_pub -h "$MQTT_HOST" -p 1883 -u "$MQTT_USER" -P "$MQTT_PASSWORD" -t "$BASE_TOPIC/bridge/request/device/configure_reporting" -m "{\"id\":\"0xc02cedfffe958f40\",\"endpoint\":1,\"cluster\":\"msTemperatureMeasurement\",\"attribute\":\"measuredValue\",\"minimum_report_interval\":5,\"maximum_report_interval\":3600,\"reportable_change\":10}"'
kubectl exec -n home-assistant "$POD" -c discovery-guard -- sh -c 'mosquitto_pub -h "$MQTT_HOST" -p 1883 -u "$MQTT_USER" -P "$MQTT_PASSWORD" -t "$BASE_TOPIC/bridge/request/device/configure_reporting" -m "{\"id\":\"0xc02cedfffe958f40\",\"endpoint\":1,\"cluster\":\"msRelativeHumidity\",\"attribute\":\"measuredValue\",\"minimum_report_interval\":5,\"maximum_report_interval\":3600,\"reportable_change\":50}"'
kubectl exec -n home-assistant "$POD" -c discovery-guard -- sh -c 'mosquitto_pub -h "$MQTT_HOST" -p 1883 -u "$MQTT_USER" -P "$MQTT_PASSWORD" -t "$BASE_TOPIC/bridge/request/device/configure_reporting" -m "{\"id\":\"0xc02cedfffe958f40\",\"endpoint\":1,\"cluster\":\"msPressureMeasurement\",\"attribute\":\"measuredValue\",\"minimum_report_interval\":5,\"maximum_report_interval\":3600,\"reportable_change\":1}"'
kubectl exec -n home-assistant "$POD" -c discovery-guard -- sh -c 'mosquitto_pub -h "$MQTT_HOST" -p 1883 -u "$MQTT_USER" -P "$MQTT_PASSWORD" -t "$BASE_TOPIC/bridge/request/device/configure_reporting" -m "{\"id\":\"0xc02cedfffe958f40\",\"endpoint\":1,\"cluster\":\"msIlluminanceMeasurement\",\"attribute\":\"measuredValue\",\"minimum_report_interval\":5,\"maximum_report_interval\":3600,\"reportable_change\":5}"'
kubectl exec -n home-assistant "$POD" -c discovery-guard -- sh -c 'mosquitto_pub -h "$MQTT_HOST" -p 1883 -u "$MQTT_USER" -P "$MQTT_PASSWORD" -t "$BASE_TOPIC/bridge/request/device/configure_reporting" -m "{\"id\":\"0xc02cedfffe958f40\",\"endpoint\":1,\"cluster\":\"shellyWS90Wind\",\"attribute\":\"windSpeed\",\"minimum_report_interval\":5,\"maximum_report_interval\":3600,\"reportable_change\":1,\"options\":{\"manufacturerCode\":5264}}"'
kubectl exec -n home-assistant "$POD" -c discovery-guard -- sh -c 'mosquitto_pub -h "$MQTT_HOST" -p 1883 -u "$MQTT_USER" -P "$MQTT_PASSWORD" -t "$BASE_TOPIC/bridge/request/device/configure_reporting" -m "{\"id\":\"0xc02cedfffe958f40\",\"endpoint\":1,\"cluster\":\"shellyWS90Wind\",\"attribute\":\"windDirection\",\"minimum_report_interval\":5,\"maximum_report_interval\":3600,\"reportable_change\":1,\"options\":{\"manufacturerCode\":5264}}"'
kubectl exec -n home-assistant "$POD" -c discovery-guard -- sh -c 'mosquitto_pub -h "$MQTT_HOST" -p 1883 -u "$MQTT_USER" -P "$MQTT_PASSWORD" -t "$BASE_TOPIC/bridge/request/device/configure_reporting" -m "{\"id\":\"0xc02cedfffe958f40\",\"endpoint\":1,\"cluster\":\"shellyWS90Wind\",\"attribute\":\"gustSpeed\",\"minimum_report_interval\":5,\"maximum_report_interval\":3600,\"reportable_change\":1,\"options\":{\"manufacturerCode\":5264}}"'
kubectl exec -n home-assistant "$POD" -c discovery-guard -- sh -c 'mosquitto_pub -h "$MQTT_HOST" -p 1883 -u "$MQTT_USER" -P "$MQTT_PASSWORD" -t "$BASE_TOPIC/bridge/request/device/configure_reporting" -m "{\"id\":\"0xc02cedfffe958f40\",\"endpoint\":1,\"cluster\":\"shellyWS90UV\",\"attribute\":\"uvIndex\",\"minimum_report_interval\":5,\"maximum_report_interval\":3600,\"reportable_change\":1,\"options\":{\"manufacturerCode\":5264}}"'
kubectl exec -n home-assistant "$POD" -c discovery-guard -- sh -c 'mosquitto_pub -h "$MQTT_HOST" -p 1883 -u "$MQTT_USER" -P "$MQTT_PASSWORD" -t "$BASE_TOPIC/bridge/request/device/configure_reporting" -m "{\"id\":\"0xc02cedfffe958f40\",\"endpoint\":1,\"cluster\":\"shellyWS90Rain\",\"attribute\":\"rainStatus\",\"minimum_report_interval\":5,\"maximum_report_interval\":3600,\"reportable_change\":1,\"options\":{\"manufacturerCode\":5264}}"'
kubectl exec -n home-assistant "$POD" -c discovery-guard -- sh -c 'mosquitto_pub -h "$MQTT_HOST" -p 1883 -u "$MQTT_USER" -P "$MQTT_PASSWORD" -t "$BASE_TOPIC/bridge/request/device/configure_reporting" -m "{\"id\":\"0xc02cedfffe958f40\",\"endpoint\":1,\"cluster\":\"shellyWS90Rain\",\"attribute\":\"precipitation\",\"minimum_report_interval\":5,\"maximum_report_interval\":3600,\"reportable_change\":1,\"options\":{\"manufacturerCode\":5264}}"'
```
These are idempotent — re-sending a request that matches the current
configuration is a no-op on the device.
The device must be awake and reachable; each request performs a real
`ConfigureReporting` exchange and fails if it is not.
## Verifying
Read back what the device actually accepted — Z2M records the negotiated
result, so this reflects the device, not the request:
```sh
kubectl exec -n home-assistant "$POD" -c zigbee2mqtt -- \
grep 0xc02cedfffe958f40 /app/data/database.db |
jq -r '.endpoints | to_entries[] | .value.configuredReportings[]?
| "cluster=\(.cluster) attr=\(.attrId) min=\(.minRepIntval) max=\(.maxRepIntval) change=\(.repChange)"'
```
All ten rows above should read `min=5 max=3600`. On the wire, the publish
cadence on `zigbee2mqtt/Wetterstation` should sit at ~8.7 s and temperature
should move in 0.1 °C steps; 1 °C steps mean the defaults are back.
## Making it durable upstream
The lasting fix is for the WS90 definition to pass explicit `reporting`
overrides rather than inherit indoor-sensor defaults, which would fix every
WS90 rather than this one. `m.temperature()` and the other helpers already
accept a `reporting` argument. The pressure divergence between
`modernExtend.ts` and `reporting.ts` is a separate upstream bug worth
reporting on its own.
+1 -11
View File
@@ -6,17 +6,7 @@ metadata:
labels:
app.kubernetes.io/name: immich-machine-learning
spec:
# TEMPORARY — RESTORE TO 1 ONCE k8s-1 HAS REJOINED.
#
# Down for the jupiter bare-metal conversion. With one of three nodes out,
# the remaining pair have no room: cluster CPU requests exceed two nodes'
# capacity, and k8s-3 runs ~5.8 GiB above the kubelet's eviction threshold,
# so every pod that does not need to be running is one less thing competing
# for both. This one is the best candidate: 500m reserved against a measured
# 2m idle draw, and inference bursts hard enough that lowering the request
# instead would be the wrong trade. Photo processing pauses; nothing else
# depends on it.
replicas: 0
replicas: 1
strategy:
type: RollingUpdate
selector:
+1 -5
View File
@@ -36,11 +36,7 @@ metadata:
labels:
app.kubernetes.io/name: immich-server
spec:
# TEMPORARY — RESTORE TO 1 ONCE k8s-1 HAS REJOINED. See the note on
# immich-machine-learning; immich is offline for the jupiter conversion.
# Its Postgres cluster is deliberately left running, so nothing needs
# restoring beyond these two replica counts.
replicas: 0
replicas: 1
strategy:
type: Recreate
selector:
@@ -31,23 +31,30 @@ spec:
# bluestore_slow_ops_warn_lifetime (24h) after the LAST slow op, so
# one multi-second stall outlives the 6h `for` and always pages,
# with latency back to normal long before anyone looks. Measured:
# both episodes in the 30d to 2026-08-23 ran ~26h and cleared
# untouched, the first ~24h after the last stall.
# three episodes in the 30d to 2026-08-24 (Sun Jul 26, Aug 2 and
# Aug 23), each ran ~24-26h and cleared untouched.
#
# Both began inside the Velero restore-test sweep — a ~5h sustained
# restore that outruns the SLC cache on these QLC NVMe. The sweep
# runs monthly, 08:00-14:00Z on the 1st.
# All began inside the weekly Velero restore-test window (Sun 08:00Z
# plus up to 6h, see velero/restore-test-cronjob.yaml) — a sustained
# restore that outruns the SLC cache on these QLC NVMe. Expected for
# the hardware, but not excludable: this is also the earliest signal
# of a genuinely failing drive, and all three are at 80-87%
# endurance used (see cephcluster.yaml).
#
# Expected for the hardware, but not excludable:
# this is also the earliest signal of a genuinely failing drive, and
# all three are at 80-87% endurance used (see cephcluster.yaml).
# No metric names the stalling OSD: ceph_health_detail carries no osd
# label, and ceph_daemon_health_metrics{type="SLOW_OPS"} reads 0 at
# every scrape (a stall is shorter than the scrape interval). Only
# the OSD's own log has it, hence the Loki query in the description.
# Jul 26 and Aug 2 hit osd.0+1+2 together, which is load-driven;
# Aug 23 hit osd.1 alone in one burst. If osd.1 alone repeats,
# suspect that drive rather than the restore load.
expr: ceph_health_detail{name!~"AUTH_INSECURE_.+"} == 1
for: 6h
labels:
severity: warning
annotations:
summary: "Ceph health check {{ $labels.name }} active for 6h"
description: "Ceph health check {{ $labels.name }} has been active for six hours. Only AUTH_INSECURE_* is excluded (kernel-blocked, see #527). BLUESTORE_SLOW_OP_ALERT is sticky for 24h after the last stall and clears itself: check whether the stall fell inside the monthly Velero restore-test window (08:00-14:00Z on the 1st), which is expected on these QLC drives — one outside that window, or a repeat on the same OSD, means check that drive (#557). Detail: `kubectl -n rook-ceph exec deploy/rook-ceph-tools -- ceph health detail`."
description: "Ceph health check {{ $labels.name }} has been active for six hours. Only AUTH_INSECURE_* is excluded (kernel-blocked, see #527). BLUESTORE_SLOW_OP_ALERT is sticky for 24h after the last stall and clears itself: check whether the stall fell inside the Sunday 08:00-14:00Z Velero restore-test window (08:00 start, 6h activeDeadlineSeconds cap), which is expected on these QLC drives — one outside that window, or a repeat on the same OSD, means check that drive (#557). No metric names the OSD — get it from Loki, which also works after the check clears: `{namespace=\"rook-ceph\",pod=~\"rook-ceph-osd-.*\"} |= \"slow operation observed\"`. While still active, `kubectl -n rook-ceph exec deploy/rook-ceph-tools -- ceph health detail` adds the op detail."
- alert: CephPoolLostRedundancy
# Names the pool, which POOL_NO_REDUNDANCY does not.
+1 -19
View File
@@ -16,29 +16,11 @@ ports:
port: 8006
protocol: TCP
endpoints:
# jupiter is OUT OF SERVICE as a PVE node: its MS-01 hardware is being
# converted to run Talos directly as k8s-1 (see
# kubernetes/talos/patches/k8s-1.yaml), and nothing answers the PVE API on
# .201 while that is in progress — verified 2026-08-25 from pve-exporter,
# which fails with "[Errno 113] Host is unreachable" for .201.
#
# This slice is manually managed, and Kubernetes health-checks nothing it did
# not create — a hardcoded `ready: true` keeps kube-proxy and Traefik load
# balancing onto a dead address forever. That silently defeated the whole
# point of pointing the pve scrape job at this Service rather than at a node
# IP (see the job_name: pve comment in
# kubernetes/apps/monitoring/helmrelease-prometheus.yaml): cluster metrics
# were meant to survive any one node being down, but instead
# up{job="pve"} flapped 0/1 as connections landed on .201, and pve.renner.uno
# served an error for the same fraction of requests.
#
# Marking it not-ready is the supported way to drain a manual endpoint. If
# jupiter is ever rebuilt as a PVE member, flip this back to true.
- addresses:
- 10.2.40.201
hostname: jupiter
conditions:
ready: false
ready: true
- addresses:
- 10.2.40.202
hostname: neptune
+1 -1
View File
@@ -79,7 +79,7 @@ spec:
# authoritative copy is the backup on Hetzner, and a lost probe just
# means re-running the test -- so they are equally undeserving of
# replication. Landing them unreplicated would mean a second size-1
# data pool inside `cephfs`, and restore-test runs MONTHLY against
# data pool inside `cephfs`, and restore-test runs WEEKLY against
# CI's ~96 pipelines/day, so it is not worth the complexity.
- name: WOODPECKER_BACKEND_K8S_STORAGE_CLASS
value: ceph-filesystem-ci
+1 -1
View File
@@ -268,7 +268,7 @@ EOF
# 120x15s = 30 min, not the old 60x5s = 5 min. node-agent has no
# loadConcurrency override, so Velero's default of one data path per node
# applies: while the restore-test sweep holds the slot, even this 1 GiB
# applies: while the Sunday restore-test sweep holds the slot, even this 1 GiB
# DataUpload sits in phase Prepared for well over 5 minutes. The old 300 s wait
# then let the teardown below delete the source namespace and the Backup
# mid-upload ("Cancel du under phase Prepared because it is being deleted"),
@@ -87,16 +87,6 @@ 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.
resources:
requests:
cpu: 50m
podAnnotations:
# Reloader restarts the LAPI when crowdsec-bouncer-key changes (it's an
# env-var secret, so a restart is required to pick up a rotated key). See
@@ -132,12 +122,6 @@ spec:
key: key
agent:
# Same reasoning as lapi.resources above, multiplied by three: this is a
# DaemonSet, so the chart's 500m default reserves half a core on every
# node to run a process that measures 1-2m.
resources:
requests:
cpu: 50m
# The agent parses the ingress controller's access logs (read from
# the pod's stdout) to detect attacks. Repointed from the retired
# ingress-nginx (decommissioned 2026-05-17) to Traefik. The
@@ -160,34 +160,11 @@ spec:
- "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.
#
# k8s-1 is absent while jupiter's OSD drive is swapped and the node is
# converted to bare metal. RE-ADD IT once the PM983 is in and the node has
# rejoined; the cluster runs on two OSDs until then.
#
# nvme0n1 on k8s-2/k8s-3 is the NVMe drive PCI-passed-through from saturn and
# neptune, and is the only block device those guests can see. Bare-metal k8s-1
# will see two — the OSD drive in MS-01 slot 1 (PCI 01:00.0) and the Talos
# system disk in slot 2 (58:00.0) — so when it is re-added, name its device
# deliberately rather than assuming slot 1 keeps enumerating first. The system
# disk is separately protected: talos/patches/k8s-1.yaml pins the installer to
# it by serial, and Rook skips a disk carrying partitions.
storage:
useAllNodes: false
useAllNodes: true
useAllDevices: false
nodes:
- name: k8s-2
devices:
- name: nvme0n1
- name: k8s-3
devices:
- name: nvme0n1
devices:
- name: nvme0n1 # NVMe drive PCI-passed-through from each PVE node
config:
osdsPerDevice: "1"
# Storage layer must never be evicted by kubelet under node pressure
@@ -87,27 +87,15 @@ metadata:
name: cephfs
namespace: rook-ceph
spec:
# TEMPORARY — REVERT TO size 3 / no min_size OVERRIDE ONCE k8s-1 IS BACK.
#
# The standing trade for this pool is size 3: it holds ~253 MiB against the
# data pool's 280 GiB, so it contributes nothing measurable to drive wear —
# the entire reason data0 dropped to 2 — while losing it would destroy the
# whole filesystem, not just the objects on the lost OSD. Paying 3x on a
# quarter of a gigabyte to keep that intact is the right trade in steady
# state.
#
# It does not hold while one of three hosts is out for the jupiter bare-metal
# conversion. size 3 on two remaining hosts leaves every PG undersized at two
# copies with min_size 2, so a single further OSD loss drops the pool below
# min_size and blocks IO on every CephFS mount cluster-wide. At 2/1 the same
# two copies are active+clean instead, and a further loss degrades rather
# than blocks — availability bought at the cost of accepting writes onto a
# single copy while degraded.
# metadataPool STAYS at size 3 while the data pool drops to 2. It holds
# 467 MiB against the data pool's 275 GiB, so it contributes nothing
# measurable to drive wear — the entire reason for the size reduction — while
# losing it would destroy the whole filesystem, not just the objects on the
# lost OSD. Paying 3x on half a gigabyte to keep that intact is the right
# trade; the wear saving comes from data0.
metadataPool:
replicated:
size: 2
parameters:
min_size: "1"
size: 3
dataPools:
- name: data0
# size 2 / min_size 1 for the same reasons as replicapool above — see
@@ -1,30 +1,19 @@
---
# Monthly Velero restore-test CronJob — 08:00 UTC on the 1st.
# Weekly Velero restore-test CronJob.
#
# The sweep restores every discovered schedule, immich's ~180 GiB library
# included. At `size: 2` that is ~360 GiB raw, ~120 GiB per OSD, against a
# ~65 GB/day weekday baseline on three QLC NVMe at 81-87% endurance used —
# ~47% of each drive's weekly write budget. Monthly keeps every restore path
# exercised and cuts that share ~4x.
#
# 08:00 — *after* that day's own daily backups have finished (paperless
# 03:15 through apps 07:15, each normally ~1 min). The previous 02:00 slot
# was chosen as "well before the next day's backups", which missed that the
# daily schedules fire on the same day: a sweep starting at 02:00 and
# running ~5 h sat on top of the entire 03:15-07:15 backup window and
# Sunday 08:00 UTC — *after* Sunday's own daily backups have finished
# (paperless 03:15 through apps 07:15, each normally ~1 min). The previous
# 02:00 slot was chosen as "well before Monday's backups", which missed
# that the daily schedules also fire on Sunday: a sweep starting at 02:00
# and running ~5 h sat on top of the entire 03:15-07:15 backup window and
# starved it of node-agent DataUpload concurrency. Observed effect on
# 2026-07-19 and 2026-07-26 — audiobookshelf-daily took 2 h 18 m and
# 2 h 33 m respectively against a ~1 min baseline, with the books
# DataUpload not starting until 06:18. Both are Sundays, the only day the
# sweep ran then; no other day shows it.
# DataUpload not starting until 06:18. Both are Sundays; no other day
# shows it.
#
# 08:00 + the 6 h activeDeadlineSeconds cap still lands well clear of the
# next day's 03:15.
#
# Day-of-month rather than a fixed weekday: Kubernetes parses cron with
# robfig/cron's standard parser, which has no `#` nth-weekday operator, and
# restricting both day fields (`0 8 1-7 * 0`) ORs them — every Sunday plus
# the 1st through 7th.
# 08:00 + the 6 h activeDeadlineSeconds cap still lands well clear of
# Monday's 03:15.
#
# Concurrency policy Forbid: the immich restore (~180 GiB, millions of
# small files) alone can run multiple hours, so the full sweep is sized
@@ -40,7 +29,7 @@ metadata:
name: restore-test
namespace: velero
spec:
schedule: "0 8 1 * *"
schedule: "0 8 * * 0"
timeZone: Etc/UTC
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 2
@@ -50,7 +39,7 @@ spec:
spec:
backoffLimit: 0
activeDeadlineSeconds: 21600 # 6 h hard cap (immich alone can take ~3 h)
ttlSecondsAfterFinished: 3456000 # 40 d — enough to inspect after the next monthly run
ttlSecondsAfterFinished: 1209600 # 14 d — enough to inspect after the next weekly run
template:
spec:
serviceAccountName: restore-test
@@ -1,5 +1,5 @@
---
# RBAC for the monthly restore-test CronJob.
# RBAC for the weekly restore-test CronJob.
#
# Permissions required by restore-test.sh:
# - velero.io: read schedules/backups/BSL, create+delete restores
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# Monthly verification that Velero PVC backups are actually restorable.
# Weekly verification that Velero PVC backups are actually restorable.
#
# What this exercises (per service):
# 1. Velero Restore reaches phase=Completed against yesterday's
@@ -53,7 +53,7 @@ check() {
# and ceph-filesystem StorageClasses ship with reclaimPolicy: Retain so that
# production PVC deletions can be undone manually. The restore-test has the
# opposite need: every PVC it creates is throwaway and MUST not survive the
# iteration. Without this, each run leaks O(100 GiB) of CephFS
# iteration. Without this, each weekly run leaks O(100 GiB) of CephFS
# subvolumes and the cluster eventually goes OSD_FULL (incident 2026-05-17).
#
# Why this still exists after ceph-*-ephemeral landed. Three categories of
+50 -121
View File
@@ -1,133 +1,62 @@
# 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.
machine:
network:
# Shared control-plane VIP 10.2.40.60 is configured as a Layer2VIPConfig
# below rather than machine.network.interfaces[].vip. Exposed via UniFi DNS
# as k8s.renner.uno.
hostname: k8s-1
# Shared control-plane VIP — etcd-elected leader hosts 10.2.40.60 as a
# secondary IP. Exposed via UniFi DNS as k8s.renner.uno.
#
# Two virtio NICs (mgmt + v30iot). `driver: virtio_net` would match
# both, so each is pinned by its PCI bus path:
# - 0000:06:12.0 — original mgmt NIC (the only virtio before)
# - 0000:06:13.0 — extra_network_bridges = ["v30iot"] in tofu
# Bus paths are consistent across k8s-{1,2,3}; verified live 2026-05-13.
# The X710 selector stays driver-based (i40e is unambiguous) and
# tolerates kernel renaming.
interfaces:
- deviceSelector:
busPath: "0000:06:12.0"
dhcp: true
vip:
ip: 10.2.40.60
# v30iot: LINK ONLY — no host addressing. The NIC is the macvlan
# parent for the v30iot pod attachments (matter/otbr/shelly-manager),
# whose children do their own DHCP on the L2; host addresses were
# never needed for that. Host DHCP (formerly dhcp + routeMetric 4096)
# installed a connected 10.2.30.0/24 route, so node replies to
# IoT-subnet clients bypassed the UDM and broke every UDM-DNATed
# flow (hairpin NAT: un-translated SYN-ACK -> client kernel RST,
# verified live 2026-07-23). The purpose the old comment claimed —
# Cilium L2 announce for an IoT LB pool — does not exist (no
# L2AnnouncementPolicy, no such pool). v6 RA addressing on this NIC
# is disabled via machine.sysctls for the same reason.
- deviceSelector:
busPath: "0000:06:13.0"
dhcp: false
# Dedicated Ceph cluster network on the X710 SFP+ port (PCI passthrough,
# mapping x710-port1 in opentofu/vm_150_k8s_1.tf). Separate /29 on VLAN 49,
# MTU 9000, no default route — used by Rook for OSD↔OSD replication via
# CephCluster.spec.network.addressRanges.cluster.
- deviceSelector:
driver: i40e
addresses:
- 10.99.0.1/29
mtu: 9000
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.
# v30iot NIC carries no host addressing (see interfaces above); this
# disables v6 entirely (incl. link-local) so the host is unreachable on that L2.
#
# Keyed on the STABLE predictable name `ens19` (busPath 0000:06:13.0 =
# v30iot). Predictable naming is re-enabled via the Image Factory
# schematic (net.ifnames=1; see .woodpecker/deploy-infrastructure.yml),
# so `ens19` is topology-derived and does NOT shuffle across reboots —
# unlike the former `eth0/1/2` (driver-probe order) which renamed on a
# k8s-3 reboot and silently mis-targeted this sysctl onto mgmt. The four
# v30iot macvlan NADs key on the same `ens19`.
net.ipv6.conf.ens19.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 now 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.
diskSelector:
serial: 50026B7382B48B86
disk: /dev/vda
certSANs:
- k8s-1.srv.renner.uno
nodeLabels:
topology.kubernetes.io/zone: jupiter
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.
#
# THIS ONLY TAKES EFFECT ON FIRST PROVISION. Talos applies volume configuration
# solely when the volume does not yet exist; a config applied after EPHEMERAL
# has been created on the system disk is silently ignored, and relocating it
# then requires wiping the disk and booting install media again. So this
# document has to be present in the very first `apply-config --insecure`.
#
# 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.
apiVersion: v1alpha1
kind: VolumeConfig
name: EPHEMERAL
provisioning:
diskSelector:
match: disk.serial == '2332E86B749A'
minSize: 50GiB
grow: true
---
# Physical NICs are aliased by permanent MAC. Both X710 ports bind i40e, so the
# driver selector k8s-2/k8s-3 use for their single passed-through port would
# match two links here. MAC matching is unambiguous and survives udev renaming —
# which is not hypothetical: adding the third NVMe renumbered the PCI buses and
# moved the second I226 from enp89s0 to enp90s0.
apiVersion: v1alpha1
kind: LinkAliasConfig
name: lan0
selector:
match: glob("58:47:ca:74:70:27", mac(link.permanent_addr))
---
apiVersion: v1alpha1
kind: LinkAliasConfig
name: ceph0
selector:
match: glob("58:47:ca:74:70:28", mac(link.permanent_addr))
---
# lan0 is the switch trunk (X710 port 0). It carries no addressing of its own;
# VLAN 40 and VLAN 30 ride on top. Proxmox's vlan-aware vmbr0 did this tagging
# before, which is why the VM saw two already-untagged virtio NICs instead.
apiVersion: v1alpha1
kind: LinkConfig
name: lan0
up: true
---
apiVersion: v1alpha1
kind: VLANConfig
name: mgmt0
vlanID: 40
parent: lan0
up: true
---
apiVersion: v1alpha1
kind: DHCPv4Config
name: mgmt0
---
apiVersion: v1alpha1
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.
# Nothing else on this machine is called ens19: the physical NICs are
# enp2s0f0np0, enp2s0f1np1, enp87s0 and enp90s0.
apiVersion: v1alpha1
kind: VLANConfig
name: ens19
vlanID: 30
parent: lan0
up: true
---
# Dedicated Ceph cluster network, VLAN 49 / 10.99.0.0/29, untagged on the wire
# on X710 port 1. Used by Rook for OSD<->OSD replication via
# CephCluster.spec.network.addressRanges.cluster. No default route.
apiVersion: v1alpha1
kind: LinkConfig
name: ceph0
up: true
mtu: 9000
addresses:
- address: 10.99.0.1/29
+51
View File
@@ -0,0 +1,51 @@
module "k8s_1" {
source = "./modules/proxmox_vm"
name = "k8s-1.srv.renner.uno"
target_node = "jupiter"
vmid = 150
memory = 49152
cores = 8
template_name = null
cloud_init = false
full_clone = false
bios = "ovmf"
machine = "q35"
boot = "order=virtio0"
agent_enabled = true
# Second NIC on v30iot so Cilium L2 announce can win ARP on the IoT
# VLAN from this node (previously only k8s-edge had v30iot, so any
# IoT-pool LB lease landing here was unreachable from devices).
extra_network_bridges = ["v30iot"]
disks = [
{
storage = "local-zfs"
size = "64G"
format = "raw"
slot = "virtio0"
file_id = local.talos_image
}
]
hostpci = [
{
mapping = "osd-ssd"
},
{
mapping = "x710-port1"
}
]
tags = "kubernetes;talos;controlplane"
ssh_public_keys = local.common_ssh_keys
vm_state = "running"
# Rebuild pipeline issues `-replace`; skip graceful shutdown so a
# non-responsive Talos guest can't hang the destroy for 30 minutes.
stop_on_destroy = true
}