Compare commits

...
Author SHA1 Message Date
claude-botandClaude Opus 5 315c9aeb46 fix(crowdsec): request 50m CPU instead of the chart's 500m
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
Draining k8s-1 for the jupiter conversion left three pods unschedulable:
k8s-2 and k8s-3 both sat at 100% of requested CPU while actually using 21-23%.
Cluster-wide the gap is 19,680m requested against 7,957m used.

crowdsec is the clearest single contributor. The chart defaults reserve 500m
for each agent and for the LAPI; measured draw is 1-2m. As a DaemonSet the
agent alone parks half a core on every node. Nothing here had ever set
resources, so this was an inherited default rather than a decision.

Only the request changes. Limits stay at the chart default, so a log flood can
still burst to 500m; what shrinks is the reservation the scheduler subtracts
from every node whether or not the process uses it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 17:05:05 +02:00
k.renner 3e57ed5c2d Merge pull request 'feat(k8s-1): run Talos on bare-metal jupiter and swap the OSD to U.2' (#566) from jupiter-baremetal-u2 into master
ci/woodpecker/push/deploy-infrastructure Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#566
2026-08-25 14:51:53 +00:00
claude-botandClaude Opus 5 6380f02ca4 feat(k8s-1): run Talos on bare-metal jupiter and swap the OSD to U.2
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
jupiter's Crucial P3 Plus OSD is at 87% endurance used, and reaching the
MS-01's only U.2-capable slot means moving the boot drive anyway. Doing the
bare-metal conversion in the same window avoids opening the machine twice and
throws away no work: the osd-ssd PCI mapping that a Proxmox-side swap would
need to be re-pointed at ceases to exist.

Networking uses the v1.12 multi-document config rather than the deprecated
machine.network.interfaces. The docs do not define what happens when one link
is configured by both schemas, so k8s-1 keeps every link in one of them. This
also buys VLANConfig.name, which is free-form: naming the VLAN 30 link ens19
keeps the four v30iot macvlan NADs working unchanged across VM and bare-metal
nodes, so the cluster can stay mixed without pinning those workloads.

Physical NICs are matched by permanent MAC, not driver — both X710 ports bind
i40e here, unlike the single passed-through port k8s-2/k8s-3 see.

cephfs-metadata drops to size 2 / min_size 1 for the duration. At size 3 on
two remaining hosts every PG sits undersized with min_size 2, so one further
OSD loss would block IO on every CephFS mount cluster-wide. This must go back
to size 3 once k8s-1 rejoins.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 16:44:21 +02:00
k.renner 47c1170d51 Merge pull request 'perf(velero): run the restore-test monthly instead of weekly' (#565) from velero-restore-test-monthly into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#565
2026-08-25 13:42:55 +00:00
claude-botandClaude Opus 5 44aa0d6919 perf(velero): run the restore-test monthly instead of weekly
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 sweep restores every discovered Velero schedule, immich's ~180 GiB
library included. At size 2 that is ~360 GiB raw, ~120 GiB per OSD, and
measured on 2026-08-25 it was ~350 of the ~740 GB each drive wrote per
week — ~47% of the endurance budget, against a ~65 GB/day weekday
baseline.

That baseline is itself the result of the 2026-08-18..21 write-reduction
work (#518, #519, #520, CNPG checkpoint tuning, RAM-backed emptyDirs,
paperless data to RBD, CI workspaces to cephfs-ci), which cut per-drive
writes ~70% from ~195 GB/day. With the application tier down, the restore
test is now the single largest consumer of remaining QLC endurance; the
three OSD drives are at 81/82/87% used.

Monthly still exercises every restore path and cuts the sweep's share of
drive wear ~4x, moving per-drive weekly writes from ~740 GB to ~530 GB.

Cadence-only change:
- 0 8 * * 0 -> 0 8 1 * *, keeping the 08:00 UTC slot that clears the
  03:15-07:15 daily backup window (see the comment in the manifest).
- ttlSecondsAfterFinished 14 d -> 40 d, so the previous run is still
  inspectable when the next one fires.
- Comments and docs that state the cadence updated to match, including
  the BLUESTORE_SLOW_OP_ALERT runbook text, which tells the reader to
  check whether a stall fell inside the restore-test window.

No day-of-week form is available: Kubernetes parses cron with
robfig/cron's standard parser, whose day-of-week field allows only
`* / , - ?` (no Quartz `#` nth-weekday), and restricting both day fields
ORs them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 07:44:21 +02:00
k.renner a8a7aa9765 Merge pull request 'docs(zigbee2mqtt): record the WS90 ZCL reporting configuration' (#564) from ws90-reporting-config into master
ci/woodpecker/cron/e2e Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
Reviewed-on: k.renner/homelab#564
2026-08-24 17:58:48 +00:00
claude-botandClaude Opus 5 c0791e2747 docs(zigbee2mqtt): record the WS90 ZCL reporting configuration
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 Wetterstation now carries a non-default reporting configuration: min
interval 5s on all ten reportable attributes, and tighter reportable-change
deadbands on temperature, humidity and pressure. None of it is expressible
in configuration.yaml - Zigbee2MQTT has no settings-schema field for
reporting configuration - so it lives only on the device and in Z2M's
database.db. This documents it and how to restore it.

min was 10s against an 8.8s transmit interval. Since min is a hold-off, every
change-triggered report was held past a transmit cycle and landed on the
next, so attributes reported at best every 17.6s and drifted out of phase.
The published stream showed no 8.8s periodicity at all. At min 5 each
attribute reports on its own transmit and the stream carries one publish per
cycle, measured at a mean gap of 8.69s. Publish volume fell from ~631/h to
~445/h because each publish is now a whole cycle rather than a fragment.

The deadbands come from the shared modernExtend helpers, which the WS90
definition composes without overrides. They are anti-chatter values sized for
battery-powered indoor sensors. Temperature was 1.00 C, giving a staircase
that moved about twice an hour; it is now 0.1 C, matching the resolution the
device actually reports. Pressure was 5 kPa - 50 hPa, which real barometric
pressure never traverses, so it only ever updated on the hourly heartbeat
that pressure_trend and weather_condition derive from. Note the same library
configures the identical ZCL attribute with a 5 hPa deadband in its older
reporting.ts helper, so the two code paths disagree by 10x.

Anything that makes Z2M re-run configure on the device reverts all of it, and
a fresh PVC will not have it. The restore commands are 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 was executed and
verified as written, including the read-back, and re-applying is a no-op.

No behaviour change from this commit itself - the device was reconfigured at
runtime, this only records it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 19:15:22 +02:00
k.renner 2e00109f66 Merge pull request 'docs(zigbee2mqtt): the WS90 merge does not make the glitch detectable' (#561) from fix-ws90-merge-overclaim into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#561
2026-08-24 17:07:34 +00:00
security-bot 394d8995a6 chore(image-automation): bump woodpeckerci/woodpecker-server:v3.18.0
ci/woodpecker/cron/e2e Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-08-24 10:50:03 +00:00
security-bot 0685044ce6 chore(image-automation): bump woodpeckerci/woodpecker-agent:v3.18.0
Updates managed by image-automation-controller.
2026-08-24 10:50:01 +00:00
claude-botandClaude Opus 5 06ffc3c910 docs(zigbee2mqtt): the WS90 merge does not make the glitch detectable
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
#560 claimed a merged episode "surfaces as a single all-zero frame". It
does not, and the difference decides whether a downstream filter is even
possible.

The zeros propagate attribute by attribute through the burst, so the
merged frame carries whatever subset had zeroed when that burst ended.
Across three episodes captured at nanosecond resolution the propagation
happened to complete, which is where the wrong generalisation came from.
Nothing guarantees it. A burst ending mid-propagation publishes a frame
with temperature at zero while humidity and pressure still read normally,
which is a physically consistent measurement - Z2M recomputes dew_point
from the false temperature - and is not separable from a genuine winter
reading.

The zeros also begin inside an otherwise normal transmit cycle, roughly
20ms after a valid reading in the same burst (measured: 27.7ms, 19.8ms,
22.0ms). They occupy a regular reporting slot, so no timing property
distinguishes them either.

Since the merge is last-wins and the zeros arrive at the tail of the
burst, it resolves that burst to the zeros and discards the valid reading
that led it. So debounce trades six bad samples per episode for one, at
the cost of one good sample. That is a bound on the corruption, not a
filter, and the comment now says so.

No behaviour change; the debounce value and device stanza are untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 06:41:14 +02:00
k.renner 89582c9c9f Merge pull request 'chore(ci): bump alpine to 3.24.1 in deploy-infrastructure' (#551) from bump-stale-ci-images into master
ci/woodpecker/push/deploy-infrastructure Pipeline was successful
ci/woodpecker/cron/e2e Pipeline was successful
ci/woodpecker/cron/update-audit Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#551
2026-08-23 20:59:54 +00:00
k.renner 3f8e9fe333 Merge pull request 'fix: [alert] CephHealthWarn (rook-ceph) (#557)' (#558) from alert-fix/issue-557 into master
Reviewed-on: k.renner/homelab#558
2026-08-23 20:59:29 +00:00
k.renner fd8412b9d7 Merge pull request 'fix: [alert] KubeJobFailed (openbao) (#554)' (#555) from alert-fix/issue-554 into master
Reviewed-on: k.renner/homelab#555
2026-08-23 20:58:45 +00:00
k.renner 1c51b78fda Merge pull request 'fix: [alert] KubeJobFailed (wordpress) (#550)' (#553) from alert-fix/issue-550 into master
Reviewed-on: k.renner/homelab#553
2026-08-23 20:58:28 +00:00
k.renner 96a51f1337 Merge pull request 'docs(zigbee2mqtt): correct the WS90 debounce rationale' (#560) from fix-ws90-debounce-rationale into master
Reviewed-on: k.renner/homelab#560
2026-08-23 20:57:51 +00:00
claude-botandClaude Opus 5 87c6f1235c docs(zigbee2mqtt): correct and trim the WS90 debounce comment
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 value is unchanged and correct; the comment committed alongside it in
#559 was neither accurate nor the right place for most of what it said.

Accuracy. It cited the device's ZCL reporting configuration as the bound,
reading minRepIntval 10 as "the station updates every 10s". minRepIntval
is not a cadence: with maxRepIntval 3600 and a reportable change set, the
ZCL reporting model gives periodic reports at the MAXIMUM interval — once
an hour — plus change-driven reports each held back so it lands no sooner
than 10s after the previous. It is a hold-off on change-triggered reports
and says nothing about the gap between reports of different attributes,
which is what a debounce window sees.

The bound that applies is the documented transmit interval — readings
every 2s, transmitted every 8.8s — against Z2M's documented rule not to
set debounce above the sensor's update interval. Shelly's knowledge base
rounds the same figure to 9s.

It also claimed the window had to exceed 1s because "the recovery burst
straddles a second boundary", inferred from log lines stamped 11:42:11
and 11:42:12. Those are second-resolution timestamps and cannot
distinguish 10ms from 900ms. No lower bound was ever established.

Length. The rest was commit-message material sitting in a config file:
justification for the chosen value, gap-distribution and run-length
measurements, and notes reconciling my own earlier misreadings. Comments
state facts about the system, not the history of arriving at them. What
remains is what the device does, what debounce does to it, and the
constraint on the value — 41 lines down to 13.

Verified before writing: post-debounce frames arrive complete (all 22
fields, no partials) at a settled ~10s cadence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 22:39:20 +02:00
k.renner 1ce3f449d4 Merge pull request 'feat(zigbee2mqtt): 2.13.0 and debounce the WS90 report burst' (#559) from worktree-z2m-2.13.0-ws90-debounce into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#559
2026-08-23 20:06:50 +00:00
claude-botandClaude Opus 5 91e55d3b56 feat(zigbee2mqtt): 2.13.0 and debounce the WS90 report burst
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
Two changes to the Wetterstation (Ecowitt WS90, 0xc02cedfffe958f40), both
aimed at the station's recurring zero-drop glitch.

2.13.0 carries zigbee-herdsman-converters#12748, which discards the ZCL
non-value markers (0xffff / 0xff / 0xffffff) the WS90 emits when a reading
is unavailable. Without it those markers are scaled and published as
measurements — the 6553 m/s gust readings in Koenkk/zigbee2mqtt#31048. The
release also recovers the rain rate after a counter reset, which is the
same lifetime-counter dip that packages/wetterstation.yaml compensates for
on the Home Assistant side. Its only breaking change is dropping Node.js 20
for bare-metal installs; the container ships its own runtime. The amd64
manifest was verified pullable before this commit.

debounce: 2 addresses the part 2.13.0 does not fix. Koenkk/zigbee2mqtt#31048
is still open, and the author of #12748 states explicitly that it does not
cover the zero drops: "a zero can be a real reading for these attributes,
only the all-bits-set markers are provably invalid."

The station reports as a burst of seven per-cluster ZCL frames and Z2M
publishes its full cumulative state once per frame, so one device report
becomes seven MQTT messages. During a glitch the zeros appear progressively
across those seven, which is what makes the corruption unrecognisable
downstream: an intermediate frame carrying {"temperature":0,"humidity":92,
"pressure":96.9} is internally consistent (Z2M recomputes dew_point from the
false temperature) and cannot be told apart from a legitimate winter
reading. Merging the burst yields a single frame with the all-zero
signature, which a consumer can reject.

The window is bounded on both sides. Above: the device's own reporting
configuration sets minRepIntval 10 / maxRepIntval 3600 on all seven WS90
clusters (1024/1026/1027/1029 and 0xfc01/0xfc02/0xfc03), so no attribute
repeats inside 10s. Below: the recovery burst straddles a second boundary,
so 1s splits it and emits a partial frame.

Staggering was checked rather than assumed — the 10s floor is per attribute,
and nine attributes report across those clusters, so a trailing-edge timer
could in principle keep resetting and never flush. Over 6h the longest
unbroken chain of sub-2s gaps was 5, bounding worst-case deferral under
~10s.

The device is also absent from the seeded device list, which predates it
joining, so this adds the friendly_name too.

NOTE: this file is seed-only. A running Z2M owns its configuration.yaml on
the PVC, so this does not change the live instance — it only makes a fresh
PVC come up correctly. debounce is requiresRestart, so applying it live
needs the option set on the running instance and a restart; the image bump
supplies the restart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 22:03:17 +02:00
issue-fix-bot 8386f755d1 fix: [alert] CephHealthWarn (rook-ceph) (#557)
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-23 18:46:50 +00:00
issue-fix-bot cf63aa61be fix: [alert] KubeJobFailed (openbao) (#554)
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-22 02:45:05 +00:00
issue-fix-bot 08289947a1 fix: [alert] KubeJobFailed (wordpress) (#550)
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-21 21:47:22 +00:00
security-bot 287cce7d1a chore(image-automation): bump ghcr.io/home-assistant/home-assistant:2026.8.3
ci/woodpecker/cron/update-all Pipeline failed
ci/woodpecker/cron/e2e Pipeline was successful
ci/woodpecker/cron/security-scan Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-08-21 21:42:10 +00:00
claude-bot 5b6a55c8ba chore(ci): bump alpine to 3.24.1 in deploy-infrastructure
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
From the #426 audit. Tag verified present before bumping.

The kaniko bump that was originally in this branch is dropped. It would
have pinned a new `-debug` image, and debug variants are not to run in
the production cluster. kaniko is archived upstream, so the fix is
migration rather than a version bump; tracked in #552 along with the
`-debug` requirement.
2026-08-21 22:55:02 +02:00
k.renner adefaab7e8 Merge pull request 'docs(rook-ceph): cut the narrative out of the ceph manifest comments' (#548) from document-pool-no-redundancy-meaning into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
Reviewed-on: k.renner/homelab#548
2026-08-21 20:28:21 +00:00
claude-bot 9a43330701 docs(rook-ceph): cut the narrative out of the ceph manifest 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
Comments should say what the setting does and what constrains it. The
blocks added in #542, #544, #545 and #547 had grown into incident
write-ups: measurement runs, prior values, how a problem was found,
what a check used to be. That belongs in commit messages and the wiki,
both of which already carry it.

Trimmed, no behaviour change:

  cephcluster.yaml       22 lines -> 6   (cephx rotation)
  cephfs-ci.yaml         26 lines -> 7   (why size 2)
  prometheusrule-ceph.yaml                (CephHealthWarn, 43 -> 13;
                                           CephPoolLostRedundancy, 15 -> 7)

The operative facts are kept: which checks are excluded and why they
cannot clear, that POOL_NO_REDUNDANCY reports blocked maintenance and
not only data risk, and that size 1 needs dedicated OSDs.
2026-08-21 22:27:10 +02:00
k.renner 9c796b924b Merge pull request 'fix(rook-ceph): raise cephfs-ci to size 2 — size 1 blocks all OSD maintenance' (#547) from fix-ci-pool-blocks-osd-maintenance into master
Reviewed-on: k.renner/homelab#547
2026-08-21 20:22:07 +00:00
claude-bot 85996991b6 fix(rook-ceph): raise cephfs-ci to size 2 — size 1 blocks all OSD maintenance
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 unreplicated CI pools introduced in #542 make every OSD on the
cluster permanently un-restartable.

`ceph osd ok-to-stop` refuses while stopping an OSD would take PGs
offline. A size-1 pool puts every PG on exactly one OSD, so the check
fails for all three:

  Error EBUSY: unsafe to stop osd(s) at this time
               (20 PGs are or would become offline)
  bad_become_inactive: 13.6 13.9 ... 14.1c 14.1d

Pools 13 and 14 are cephfs-ci-metadata and cephfs-ci-data0. The size-2
pools are not implicated — they run min_size 1 and appear under
ok_become_degraded, which is allowed.

Rook gates OSD restarts on that check, so this blocks CephX key
rotation, Ceph upgrades, node drains and reboots, and drive
replacement. It surfaced when #545 deadlocked: Rook retried
ok-to-stop against all three OSDs several times a second and could
never succeed, while `ceph -s` reported 217 pgs active+clean
throughout. Nothing is unhealthy; the restarts are simply refused.

The reasoning in #542 was that unreplicated CI data only risks
in-flight pipelines, since every byte is a fresh git clone. That part
holds. What it missed is that the cost is not scoped to the pool: one
unreplicated pool freezes maintenance for the entire cluster.

Most of #542's saving came from keeping CI churn off `cephfs`, whose
metadataPool is size 3 — that survives this change. Only the second-
order gain from dropping the replica is given up, and cephfs-ci-data0
stores 0 B with cephfs-ci-metadata at 182 MiB, so the backfill is
trivial.

size 1 becomes viable again once these pools sit on OSDs of their own,
where the permanently un-stoppable OSD holds nothing but throwaway
data. That is planned with the retired QLC drives.

Alerting follows the same change:

- POOL_NO_REDUNDANCY is no longer excluded from CephHealthWarn. It was
  excluded because cephfs-ci was expected to keep it permanently
  active; no pool is unreplicated now, so the exclusion would only
  hide a real one losing its replicas.
- CephPoolLostRedundancy drops its cephfs-ci exemption for the same
  reason, and its description now states the maintenance consequence,
  not just the data-loss one.
- The AUTH_INSECURE_* exclusion is reworded. Calling it
  "known-accepted" reads as a decision that the risk is fine; it is a
  kernel-blocked remediation (aes256k over the CSI mount path needs
  kernel 7.0+, nodes run 6.18).

Refs #542, #545, #527
2026-08-21 22:21:11 +02:00
k.renner 04b8c93113 Merge pull request 'fix(rook-ceph): rotate CephX daemon keys to close CVE-2025-30156' (#545) from rotate-cephx-daemon-keys into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#545
2026-08-21 20:07:25 +00:00
claude-botandClaude Opus 5 2abd946c05 fix(rook-ceph): rotate CephX daemon keys to close CVE-2025-30156
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
Ceph v19.2.6 ships the fix for CVE-2025-30156, a CephX authentication
bypass: AES-128-CBC service tickets carry no HMAC and use a hard-coded
IV, so a holder of any low-privilege key can bit-flip a ticket into
cluster-admin in linear time. Upgrading the binaries does not resolve
it — the keys themselves must be rotated to the aes256k cipher.

The cluster is mid-procedure. auth_allowed_ciphers (aes, aes256k),
auth_preferred_cipher (aes256k) and auth_service_cipher (aes256k) are
already set, and the rotating service keys were wiped, but no actual
key rotation has happened: every entity in status.cephx is still
keyGeneration 1 with keyType aes, created under 19.2.3.

Rook automates daemon-key rotation but it is opt-in and defaults to
Disabled, so spec.security.cephx was left empty and nothing rotated.
Rook v1.20.6 and Ceph v19.2.6 are both past the version floor the
feature requires.

Rotating daemon keys clears AUTH_INSECURE_SERVICE_KEY_TYPE, the only
one of the six new checks at ERR severity. That is the operationally
urgent part: Rook skips CephFS and RGW reconciliation entirely while
health is HEALTH_ERR — which is what deadlocked the mds/rgw rollout in
issue #527 — and the check is presently held down only by a mon-side
mute with a finite TTL. When that mute lapses the cluster returns to
HEALTH_ERR and the reconcile freeze comes back.

CSI keys are deliberately left on aes. aes256k over the CSI mount path
requires a host kernel of 7.0 or newer and these nodes run 6.18, so
`aes` must stay in auth_allowed_ciphers for CSI to authenticate at all.
AUTH_INSECURE_CLIENT_KEY_TYPE, AUTH_INSECURE_KEYS_ALLOWED and
AUTH_INSECURE_KEYS_CREATABLE therefore stay raised at WARN until the
nodes run a kernel that supports it.

Rotation restarts most Ceph daemons. With size 2 pools across 3 OSDs
this should be applied deliberately rather than alongside other churn,
and Ceph should reach HEALTH_OK before anything else touches the
cluster.

Refs #527, #528

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 22:05:10 +02:00
k.renner 8c0a6b2b1e Merge pull request 'fix(monitoring): CephHealthWarn has been firing continuously and hiding everything' (#544) from fix-ceph-health-alert into master
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#544
2026-08-21 08:02:00 +00:00
claude-bot cf6ff839be fix(monitoring): CephHealthWarn has been firing continuously and hiding everything
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
CephHealthWarn alerted on `ceph_health_status == 1`. It has been FIRING since
at least 2026-08-21T00:26Z and cannot clear: four AUTH_INSECURE_* checks are
permanently active from CVE-2025-30156 (#527) and stay that way until Rook
>=1.20.5 lands. A permanently-firing alert reports nothing -- every Ceph
warning since then has been invisible behind it.

This is a pre-existing gap, not a consequence of the cephfs-ci work, though
that work would have added a second permanent cause (POOL_NO_REDUNDANCY, since
the CI pools are size 1 on purpose).

ceph_health_detail exposes each check as its own series, so the alert now fires
on any check that is NOT known-accepted, and names it. Verified against live
data: 5 checks active, 0 unexpected, so it is quiet now and fires the moment
something genuinely new appears.

Excluding POOL_NO_REDUNDANCY wholesale would give something up: that check is
cluster-wide and does not say WHICH pool, so accepting it for cephfs-ci would
equally hide a production pool dropping to one replica. CephPoolLostRedundancy
restores that. There is no pool-size metric, but bytes_used/stored IS the
replication factor -- verified live as replicapool 2, cephfs-data0 2,
cephfs-metadata 3, cephfs-ci-metadata 1.

Both alerts were tested against live data before committing, in BOTH
directions. The positive control matters as much as the negative one: an early
draft used a >1e9 floor which silently excluded the CI pools entirely, so the
expression could not have matched anything. It looked correct precisely because
it returned no results. The control caught it; the floor is now 1e6.

The floor is not what suppresses the near-empty rgw pools. Their ratios are
9-36 because per-object overhead dominates, so they never match `< 1.5`.

6h on CephHealthWarn is unchanged -- Ceph warns for self-clearing conditions
and paging on those trains people to ignore the alert.
2026-08-21 09:53:37 +02:00
k.renner a11493ee22 Merge pull request 'perf(woodpecker): put CI workspaces on the unreplicated CephFS' (#543) from repoint-ci-workspaces into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#543
2026-08-21 07:32:11 +00:00
claude-bot 7666d220c5 perf(woodpecker): put CI workspaces on the unreplicated CephFS
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
Repoints WOODPECKER_BACKEND_K8S_STORAGE_CLASS at ceph-filesystem-ci, the
dedicated filesystem added in #542 whose pools are both size 1.

Measured 2026-08-21, polling both CephFS pools every 15s:

  CI active   1.5 min (6 intervals):  data0 143.2 MB   metadata 17.2 MB
  CI idle    16.0 min (63 intervals): data0   0.0 MB   metadata 11.0 MB
  CI share of writes: data0 100%, metadata 61%

data0 takes zero bytes across 16 minutes without a pipeline, so its entire
write volume is CI. The 15s cadence is what made this visible: a pipeline
creates, uses and deletes its PVC inside ~2 minutes, so a 5-minute sample
reported no CI pods during bursts CI was causing.

Per pipeline this moves the drive cost from 71.6x2 + 8.2x3 = ~168 MB to
~80 MB, since neither pool is replicated on the new filesystem.

RWX is unchanged and still required: pipeline steps share the workspace PVC
and the k8s backend can schedule them on different nodes, so an RWO volume
fails to mount on the second node. Only the filesystem underneath changes.

Also corrects the justification written into #542 for leaving
ceph-filesystem-ephemeral alone. That commit claimed restore-test.sh handles
"REAL data which must not land on unreplicated pools". That is wrong: its
probe PVCs are as throwaway as a CI workspace -- the authoritative copy is the
backup on Hetzner, and losing a probe just means re-running the test.

The actual reason it cannot move is technical: those probes are snapshot
CLONES, and `ceph fs subvolume snapshot clone` operates within a single
filesystem, so a snapshot taken in `cephfs` cannot be cloned into `cephfs-ci`.
Landing them unreplicated would require a second size-1 data pool inside
`cephfs`; restore-test runs weekly against CI's ~96 pipelines/day, so that is
not worth the complexity.

cephfs-ci verified healthy before this change: PHASE Ready, MDS Running, both
pools reporting size 1.
2026-08-21 09:31:01 +02:00
k.renner baa5e62bbe Merge pull request 'feat(rook-ceph): add an unreplicated CephFS for CI workspaces' (#542) from ci-throwaway-cephfs into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#542
2026-08-21 07:27:47 +00:00
claude-bot 503d2a4876 feat(rook-ceph): add an unreplicated CephFS for CI workspaces
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
Woodpecker's Kubernetes backend creates a PVC per pipeline, fills it with a
checkout plus build output, and deletes it when the pipeline ends. Those
workspaces currently live on the shared `cephfs`, so throwaway data is
replicated as if it mattered: 2x in cephfs-data0 and 3x in cephfs-metadata.

Measured 2026-08-21 by polling both pools every 15s. The interval matters --
at 5 minutes a whole pipeline is invisible, because its PVC is created, used
and deleted inside ~2 minutes. Sampling that slowly reported zero CI pods
during bursts CI was causing, which sent this investigation down three wrong
paths before the cadence was fixed.

  one pipeline, a single 15s interval:  data0 +71.6 MB   metadata +8.2 MB
  every interval with no CI pod:        data0   ~0        metadata   ~0

So CI accounts for essentially all CephFS write volume; between pipelines the
metadata pool is quiet at ~4 MB/day. On the shared filesystem one pipeline
costs 71.6x2 + 8.2x3 = ~168 MB of drive writes. On this one it costs ~80 MB.

size 1 is correct here even though it was rejected for `cephfs`. There,
metadataPool stays at 3 because losing it destroys the entire filesystem
rather than the objects on one OSD, and 550 GiB of real data depends on it.
Here every byte is a fresh git clone: total loss means in-flight pipelines
fail and get re-run. Replicating throwaway data was the cost, so it is what
gets removed.

`ceph-filesystem-ephemeral` is deliberately NOT repointed. It has a second
consumer -- velero/restore-test.sh restores REAL data through it for
verification, which must not land on unreplicated pools. A new class also
sidesteps StorageClass.parameters being immutable.

Nothing uses ceph-filesystem-ci yet, so this commit is inert: it provisions
the filesystem and its MDS and changes no existing workload. Repointing the
Woodpecker agent is a separate change, once this is confirmed healthy.

Known consequence: size 1 raises POOL_NO_REDUNDANCY, so cluster health will
warn until that is addressed. Silencing it via mon_warn_on_pool_no_redundancy
is cluster-wide and would also hide a production pool dropping to 1, so it
wants a targeted alert instead -- deliberately left out of this commit rather
than switching the warning off blind.

The MDS is 512Mi/1Gi with no standby, not the 4Gi active+standby that `cephfs`
runs. That figure is driven by immich's cached inode count; a CI workspace is
a few thousand files that vanish with the pipeline, and a second daemon to
protect throwaway work is not worth its memory.
2026-08-21 08:55:33 +02:00
security-bot 48407aa202 chore(image-automation): bump redis:8.10.1-alpine
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-08-20 17:37:50 +00:00
security-bot ce6e8ba2cb chore(image-automation): bump redis:7.4.11-alpine
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-08-20 17:19:40 +00:00
k.renner 96387108ff Merge pull request 'perf(cnpg): roll archive_timeout out to the remaining six clusters' (#541) from rollout-archive-timeout into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#541
2026-08-20 13:39:00 +00:00
claude-bot 119b2c77ef perf(cnpg): roll archive_timeout out to the remaining six clusters
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
A WAL segment is a fixed 16 MiB file. archive_timeout forces a switch on a
timer, and the switched-out segment is written and archived at its full size
no matter how little real WAL it holds. CNPG defaults to 5min, permitting
288 segments/day per cluster of largely padding.

gf-pg went first as a staged rollout and is now verified: 211 -> 41 MB/hour,
an 81% cut, with archived segments falling from ~12/hour to ~1.5/hour in
step. Two independent signals moving together is what confirms the volume
was padding rather than data, and the measured result matched the ~35
MB/hour predicted beforehand.

Fleet-wide this is worth roughly 18 GB/day of logical writes, which at
size=2 with ~1.27x BlueStore overhead is ~45 GB/day at the drives -- about
14% of the 328 GB/day the three OSD NVMes currently take.

ak-pg gets 15min rather than 30min. It holds credentials, sessions and
enrolment state, and the extra 15min would save only a further 0.3 GB/day,
which does not justify doubling the worst-case loss window on that data.

ha-pg saves least in relative terms: its segments are already 63% full of
real WAL (4.9 GB/day), so the timer is not what drives most of its switches.

Also corrects two comments in ha-pg-cluster.yaml that this work falsified.

wal_compression: the claim that these clusters "were not getting compression"
was wrong -- "on" is pglz, which was already compressing. Switching to zstd
moved the WAL rate not at all (4911/4976 -> 5002 MB/day, by LSN advance over
20min windows). Full-page images genuinely are ~62% of WAL here (61.9%
measured as a steady-state delta, against 62.4% lifetime -- so the migration
did not skew it, which was my first guess and it was wrong). There was simply
no headroom between pglz and zstd on this data. zstd is kept because it is
not worse and costs nothing, but it is not a lever.

checkpoint_timeout: 15min -> 30min likewise did not move WAL. Probable reason
is that a recorder is append-mostly -- a page is dirtied while it fills and
then not touched again, so it pays its full-page image once whatever the
checkpoint interval. Kept, but flagged as unjustified pending a data-file
measurement: this volume takes ~11 GB/day against 4.9 GB/day of WAL, so most
of its traffic is outside what a WAL measurement can see.

Context: drive-endurance work on three QLC NVMe OSDs at 81-87% wear.
2026-08-20 15:36:26 +02:00
k.renner 1d2248139d Merge pull request 'perf(grafana-db): raise archive_timeout to 30min on gf-pg (re-target of #539)' (#540) from archive-timeout-gf-pg into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#540
2026-08-20 12:49:41 +00:00
claude-bot 0a9d19a7c6 perf(grafana-db): raise archive_timeout to 30min on gf-pg
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
A WAL segment is a fixed 16 MiB file. archive_timeout forces a segment
switch on a timer so the archive never trails reality by more than that
interval, and the switched-out segment is written and archived at its full
16 MiB no matter how few bytes of real WAL it holds. CNPG defaults this to
5min, which permits up to 288 segments/day = 4.6 GB/day per cluster.

Measured on gf-pg: the database is 21 MB and generates 4.5 MB/day of actual
WAL, while its RBD volume takes 211 MB/hour -- against 192 MB/hour predicted
from 12 switches of 16 MiB. Essentially every byte written to this volume is
padding, roughly 1100x amplification.

This resolves an anomaly that has been open since the start of the endurance
work: gf-pg appeared to write gigabytes per day for a 21 MB database, and
neither WAL volume nor data changes could account for it. Segment padding
does, exactly.

Applied to gf-pg alone as a staged rollout. It is the lowest-risk cluster
and gives the cleanest signal -- its real WAL is small enough that any change
in volume is unambiguously attributable to this parameter. The same default
affects all seven clusters and is worth an estimated ~24 GB/day of logical
writes fleet-wide, but that only goes ahead once this one is verified.

The cost is RPO. On total loss of the cluster, up to 30min of Grafana state
becomes unrecoverable instead of 5min. That state is dashboards, annotations
and preferences, changing at ~4.5 MB/day, so the window is nearly always
empty. Clusters holding credentials or job state get evaluated separately
rather than inheriting this value by default.

archive_timeout is in CNPG's GlobalDefaultSettings, not its
FixedConfigurationParameters, so it is user-overridable -- verified against
pkg/postgres/configuration.go rather than assumed.

Context: drive-endurance work on three QLC NVMe OSDs at 81-87% wear.
2026-08-20 14:37:46 +02:00
k.renner 082092c4ba Merge pull request 'perf(cnpg): compress WAL with zstd instead of pglz' (#538) from reduce-pg-wal-fpi into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#538
2026-08-20 12:30:18 +00:00
claude-bot 591c76fb86 perf(cnpg): compress WAL with zstd instead of pglz
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
`wal_compression: "on"` resolves to pglz, PostgreSQL's original 2016-era
algorithm. Every CNPG cluster here was set that way, so none of them were
getting the compression actually available to them: all seven builds accept
lz4 and zstd (verified via pg_settings.enumvals on each, including immich's
custom pgvector image and trmnl's PostgreSQL 18).

This matters because of what dominates recorder-style write volume. A page
is written to WAL in full -- all 8 KiB -- on its first modification after a
checkpoint, for torn-page protection. Measured on ha-pg, those full-page
images are 62.4% of WAL bytes. Compressing them is therefore the single
largest lever available on WAL volume, and pglz was the weakest option for
the job.

ha-pg additionally moves checkpoint_timeout 15min -> 30min. Full-page images
are emitted once per page per checkpoint interval, so doubling the interval
roughly halves how often each hot page pays the 8 KiB toll. max_wal_size
stays at 2GB deliberately: it must remain well above what one interval
generates, or checkpoints would trigger on size and the interval change
would do nothing. Recovery replays at most one interval of WAL -- under
200MB at the measured rate, i.e. seconds.

The other six clusters keep checkpoint_timeout at 15min. Their measured
write rates do not justify trading recovery time: gf-pg generates 4.5 MB/day
of WAL and wp-pg 62 MB/day, against ak-pg's 604 MB/day.

full_page_writes stays on everywhere. Disabling it would remove the
overhead entirely but RBD can tear a page across an object boundary, and
the trade is write volume against an unrecoverable database.

Both parameters are sighup-reloadable, so this does not restart any cluster.

Context: drive-endurance work on three QLC NVMe OSDs at 81-87% wear.
2026-08-20 14:03:18 +02:00
k.renner f26a98688c Merge pull request 'fix(monitoring): drop apiserver high-cardinality histograms' (#537) from fix-prometheus-cardinality into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#537
2026-08-20 11:28:21 +00:00
claude-botandClaude Bot 3c770f2a8d fix(monitoring): drop apiserver high-cardinality histograms
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
Prometheus held 298,236 active series. ~108,000 of them -- over a third -- were
apiserver/etcd `_bucket` histograms. Each fans out to one series per bucket x
verb x resource x scope, so a single metric was 23,810 series on its own. Every
Home Assistant metric combined is ~4,100 for comparison.

That cardinality is the dominant driver of Prometheus write volume (~11 GB/day of
TSDB + WAL, doubled again by Ceph replication) and none of it is observable value
on three nodes: per-bucket apiserver latency quantiles are for clusters with SLAs
and dozens of nodes.

Dropped, all verified to have ZERO rule dependencies by checking each metric
against the live alert and recording rules:

  apiserver_request_duration_seconds_bucket        rules=0   23,810 series
  apiserver_request_body_size_bytes_bucket         rules=0   23,200
  etcd_request_duration_seconds_bucket             rules=0   15,840
  apiserver_watch_list_duration_seconds_bucket     rules=0   11,538
  apiserver_watch_cache_read_wait_seconds_bucket   rules=0    8,232
  apiserver_response_sizes_bucket                  rules=0    7,592
  apiserver_watch_events_sizes_bucket              rules=0    5,220
                                                            -------
                                                             95,432

KEPT deliberately: apiserver_request_sli_duration_seconds_bucket (12,144 series).
23 recording rules depend on it, including the apiserver availability SLO and the
KubeAPIErrorBudgetBurn burn-rate series. Dropping it would silently break that
alerting -- the manifest says so inline, so a later "tidy up" of the regex does
not remove it by accident.

Only `_bucket` series are dropped. The matching `_count` and `_sum` remain, so
request rates and mean latencies still work; what is lost is per-bucket
quantiles.

etcd_* is included even though kubeEtcd is disabled: those are the apiserver's
own etcd-client metrics on the apiserver endpoint, not a separate etcd scrape.

Expected: ~32% fewer active series, so roughly 11 -> 7 GB/day of Prometheus
writes, ~8 GB/day off the drives once replication is counted. Will verify against
prometheus_tsdb_head_series rather than assume.

Refs #434

Co-Authored-By: Claude Bot <noreply@anthropic.com>
2026-08-20 13:25:49 +02:00
k.renner e8ad1f704e Merge pull request 'fix(cnpg-barman): take the sidecar tag out of base64 and track it' (#536) from fix-barman-sidecar-visible into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#536
2026-08-20 10:59:27 +00:00
claude-botandClaude Bot 154c933177 fix(cnpg-barman): take the sidecar tag out of base64 and track 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
An image reference is not a secret. Encoding it as base64 `data` had two real
costs: no reviewer sees a version change in a diff, and no $imagepolicy marker
can attach to a base64 blob.

The result was a two-minor version skew in the component that performs every
base backup and WAL archive for seven Postgres clusters (#535):

  controller  plugin-barman-cloud:v0.14.0          <- auto-bumped by Flux
  sidecar     plugin-barman-cloud-sidecar:v0.12.0  <- invisible, never moved

Not stale pods: ha-pg-1 was created hours ago during the recorder migration and
still received v0.12.0.

Three changes:

  1. The Secret now uses stringData with the tag in plaintext, carrying an
     $imagepolicy marker. The Secret KIND is kept because the controller reads it
     via secretKeyRef; switching to a ConfigMap would need a matching edit to that
     reference and diverge further from upstream. stringData is accepted
     identically by the API server.

  2. ImageRepository + ImagePolicy added for the sidecar image, with the same
     filter and 0.x range as the controller, so the two cannot drift apart again.
     Only the controller was tracked before.

  3. Tag aligned to v0.14.0 to match the controller.

The Secret name carries a kustomize hash suffix from upstream's secretGenerator
but is static here, so editing the value does not change the name and the
deployment's secretKeyRef stays valid — verified in the render, which still shows
the name twice (Secret + reference).

Verified the tag EXISTS before pinning it: pulling
plugin-barman-cloud-sidecar:v0.14.0 succeeds (the pod fails later on a missing
`true` binary in that minimal image, which is the container command, not the
pull). Pinning a non-existent tag would have put all seven Postgres clusters into
ImagePullBackOff.

EXPECTED IMPACT: bumping the sidecar changes every Postgres pod spec, so all
seven CNPG clusters roll. CNPG handles this as a normal rolling restart. Backups
should be confirmed working afterwards — 113 Backup CRs exist today, newest ~3h
old and completed.

Note for future re-imports: upstream ships this Secret as base64 `data` with no
marker, so a wholesale re-import silently reintroduces the skew. The manifest now
says so inline.

Refs #535

Co-Authored-By: Claude Bot <noreply@anthropic.com>
2026-08-20 12:50:31 +02:00
k.renner eafd99faca Merge pull request 'feat(paperless): repoint data volume to RBD (phase 3)' (#534) from feat-paperless-data-repoint into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#534
2026-08-20 05:45:25 +00:00
claude-botandClaude Bot 3a279d2dcd feat(paperless): repoint data volume to RBD (phase 3)
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
Switches the Deployment's `data` volume from paperless-data (RWX,
ceph-filesystem) to paperless-data-block (RWO, ceph-block). `consume` and `media`
stay on CephFS -- the ftp-scanner writes to those from another node, so they
genuinely need RWX.

The copy is already done and verified byte-for-byte with paperless stopped:

  src files: 75
  dst files: 75
  IDENTICAL: name, size and mtime match for every file
  index segments: src 3, dst 3

mtime parity matters specifically because the Whoosh index segments carry their
own consistency expectations; `cp -a` preserved them. (The "can't preserve" lines
in the copy log were for the mount root /dst/. itself, not the files.)

Why: only this Deployment ever mounted the volume, and its contents are
single-writer state. Being on CephFS meant every access crossed the MDS --
measured with zero CI running, 22 requests/sec sustained and ~15 journal
events/sec against 2 directory commits per 180s, i.e. cap/inode churn rather
than file writes. That accounted for most of the constant 7-8 GB/day on
cephfs-metadata, which is a size 3 pool.

cephfs-metadata stays at size 3 deliberately: losing it destroys the entire
filesystem rather than the objects on one OSD, and the 275 GiB in cephfs-data0
depends on it.

paperless-data is retained (prune: disabled) as the rollback. It must NOT be
deleted until search has been confirmed returning results -- that is what proves
the index survived, not merely that paperless starts.

Refs #434

Co-Authored-By: Claude Bot <noreply@anthropic.com>
2026-08-20 07:44:07 +02:00
k.renner efb552f043 Merge pull request 'feat(paperless): provision RBD volume to move data off CephFS (phase 1)' (#533) from feat-paperless-data-block into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#533
2026-08-20 05:38:43 +00:00
k.renner 1e79bc6d2f Merge pull request 'fix(cnpg): checkpoint tuning across all Postgres clusters' (#532) from fix-cnpg-write-tuning into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#532
2026-08-20 05:21:25 +00:00
claude-botandClaude Opus 5 d2d06d8532 feat(paperless): provision RBD volume to move data off CephFS (phase 1)
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
paperless-data is declared ReadWriteMany on ceph-filesystem, but only ONE pod
mounts it -- the paperless Deployment itself. Its contents are all single-writer
state: the 50 MB Whoosh index, classification_model.pickle,
celerybeat-schedule.db, log/, migration_lock. 75 files, nothing shared. Only
`consume` and `media` genuinely need RWX, because the ftp-scanner writes to those
from another node.

It is on CephFS by default, not by requirement, and every access goes through the
MDS. Measured 2026-08-20 with ZERO CI pipelines running:

  paperless-data client   request_load_avg 22   (all other sessions 0-1)
  MDS over 180s           4,052 client requests = 22.5/s
                          2,718 journal events  = 15/s
                              2 dir commits     <- not file writes

Cap and inode churn from repeated access, not data being written. That accounts
for most of the constant 7-8 GB/day on cephfs-metadata, and since that pool is
size 3, roughly 21-24 GB/day reaches the drives.

cephfs-metadata deliberately STAYS at size 3. Losing it destroys the whole
filesystem rather than the objects on one OSD, and the 275 GiB in cephfs-data0
rides on it. Cutting writes at source is the fix; cutting replication is not.

PHASE 1 ONLY, and inert: the PVC is created empty and nothing mounts it. The data
copy and the deployment repoint are separate steps, documented in
MIGRATION-data-to-block.md along with why the copy needs paperless stopped (the
Whoosh index and celerybeat schedule are both live-written, so copying under an
active writer gives a torn read).

Honest sizing: ~21-24 GB/day off the drives against ~432 GB/day cluster-wide, so
about 5%. This is a correctness fix -- a single-writer volume does not belong on
a shared filesystem -- more than a large win. It also removes 22 req/sec of MDS
load, which benefits Woodpecker CI workspaces sharing the same MDS.

Refs #434

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 07:18:31 +02:00
claude-botandClaude Opus 5 68c8c86046 fix(cnpg): checkpoint tuning across all Postgres clusters
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
Ten Postgres instances across seven CNPG clusters are ~48 GB/day of the
cluster's ~114 GB/day of client writes, and only ha-pg carried any
write-reduction tuning. The rest ran Postgres defaults, which favour throughput
over storage that wears out.

MEASURED, not assumed. ak-pg since 2026-05-17:

  checkpoints_timed   27,624      = 291/day, i.e. one every 5 min (the default)
  buffers_checkpoint  25,801,628  = ~206 GB flushed
  database size       1,046 MB

That is the whole database rewritten roughly twice a day. The pathology is
specific to idle databases: sessions, tokens and job state re-dirty the same few
pages constantly, and checkpoint_timeout=5min forces each of them to disk 288
times a day instead of letting them coalesce. At 15min they are written a third
as often.

max_wal_size is raised alongside it. Without that, checkpoints trigger on size
instead and the timeout change does nothing. It also bounds crash recovery to at
most 2GB of WAL replay.

Applied to ak-pg, pprl-pg, tr-pg, wp-pg, im-pg, gf-pg. ha-pg already had it.

synchronous_commit is deliberately NOT changed here. It is off for ha-pg because
a state recorder is telemetry; these clusters hold credentials, documents and job
state, where losing the last few hundred ms of commits on an unclean shutdown is
not an acceptable trade for write volume.

Separately, ha-pg shared_buffers 256MB -> 512MB. After 15h on Postgres its
buffers_backend was 629,704 of 939,025 total page writes -- 67% written by
backends rather than the checkpointer or bgwriter, which means the working set
does not fit in the buffer pool and pages are written out repeatedly instead of
coalescing. Memory limit raised 1Gi -> 1536Mi to keep the same headroom (measured
383Mi in use, of which 256MB was shared_buffers).

Verified before commit by rendering every cluster and asserting the tuning landed
under spec.postgresql.parameters with the barman plugin block untouched. That
check exists because the first attempt at this matched the wrong `parameters:`
key -- the barman plugin's, at 6-space indent under plugins: -- and corrupted
wp-pg's barmanObjectName. Reverted and redone with an indent-aware match.

Refs #434

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 06:55:42 +02:00
k.renner a2239515e9 Merge pull request 'feat(monitoring): alert on CNPG WAL archiving and backup failure' (#523) from fix-cnpg-backup-alerting into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#523
2026-08-20 04:39:20 +00:00
k.renner 0d65fb0982 Merge pull request 'fix: make small scratch emptyDirs RAM-backed instead of node-disk' (#530) from fix-emptydir-tmpfs into master
Reviewed-on: k.renner/homelab#530
2026-08-20 04:38:21 +00:00
k.renner ece3c93ad5 Merge pull request 'fix(trmnl): make the renderer's /tmp RAM-backed (34 GB/day of node writes)' (#529) from fix-trmnl-renderer-tmpfs into master
Reviewed-on: k.renner/homelab#529
2026-08-20 04:33:37 +00:00
claude-botandClaude Opus 5 b34450b22e fix: make small scratch emptyDirs RAM-backed instead of node-disk
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
Follow-up to the trmnl renderer finding. An `emptyDir` without `medium: Memory`
is NODE STORAGE, which is easy to read as "ephemeral, therefore free" -- it is
ephemeral, but every byte still hits the node's disk.

Survey of the repo: 23 emptyDir declarations, only 2 RAM-backed. This converts
12 of the remaining ones.

HONEST SCOPE: this buys almost no write reduction. Ranking every pod by OS-disk
writes over 6h shows trmnl-renderer at 67 GB/day (fixed separately) and then
nothing emptyDir-driven -- the rest of the top of that list is Ceph mons
(hostPath, not emptyDir), kube-apiserver, and container logs from databases.
Every volume converted here measured 8.0K in the running pod:

  /var/lib/code-server   8.0K
  /mosquitto/auth        8.0K
  /state (z2m guards)    8.0K

So the value is correctness and, for mosquitto, keeping credential material off
the node's filesystem: that volume holds the generated passwd/acl files.

Each converted volume gets an explicit sizeLimit (16Mi where none existed), so
tmpfs cannot silently consume a container's memory limit. All are orders of
magnitude above measured usage.

Behaviour is unchanged otherwise: an emptyDir persists for the pod's lifetime on
either medium, so the mosquitto shim's assumption that the passwd file survives
a container restart still holds.

DELIBERATELY NOT CONVERTED:
  - kubernetes/talos/cilium.yaml and talos/patches/cilium-cni.yaml -- vendored
    and generated from the chart; a hand edit would be lost on upgrade.
  - trmnl/renderer.yaml -- converted separately, where the actual 34 GB/day was.

Cost: this rolls the affected pods once.

Refs #434

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 06:30:23 +02:00
claude-botandClaude Opus 5 b4f44b2002 fix(trmnl): make the renderer's /tmp RAM-backed (34 GB/day of node writes)
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 renderer was the single largest writer in the cluster -- more than every
Ceph OSD and every database combined -- and none of it needed to touch disk.

Measured 2026-08-20 from the container's own cgroup io.stat:

  253:0 (node /dev/vda5)  249,092,697,088 bytes over 7.3 days  = ~34 GB/day
  252:48 (its own PVC)             340 MB total                = negligible

So the writes were not going to its volume at all; they were going to the
node's filesystem via a disk-backed emptyDir. `emptyDir: {}` without
`medium: Memory` is node storage, which is easy to read as "ephemeral, so
free".

The cause is cadence rather than volume. schedules.template.json renders on
"* * * * *", so Chrome is launched 1440 times a day and each launch creates,
fills and deletes a /tmp/puppeteer_dev_chrome_profile-* directory. At rest /tmp
holds under 8 MB -- it is pure churn for data that never outlives the render.

tmpfs pages count against the container's memory limit, so the 512Mi cap is
kept deliberately: measured usage is 129Mi against a 2Gi limit, leaving ample
headroom even if the cap were fully consumed.

Expected effect: ~34 GB/day off the OS disks, which measured 215 GB/day over a
4h window. This is a bigger cluster-wide reduction than the Home Assistant
recorder migration achieved, for a one-line change.

Refs #434

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 06:26:38 +02:00
k.renner 3faddcadcd Merge pull request 'feat(home-assistant): render recorder_db_url from the ha-pg Secret' (#524) from feat-ha-recorder-db-url into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#524
2026-08-19 19:24:38 +00:00
claude-botandClaude Opus 5 6ae734eef4 refactor(home-assistant): use CNPG's own URI instead of rebuilding 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
Net -44 lines. The previous version assembled the connection URI from
username/password/dbname, which forced percent-encoding, YAML-safe quoting via
json.dumps, a YAML round-trip validation, and python3 from HA's image.

None of that was necessary: CNPG already publishes a finished, percent-encoded
connection string in the app secret as `uri` / `fqdn-uri`. Reading that key
removes the whole class of problem the earlier review correctly flagged --
rather than solving it more carefully.

Verified against the live secret before simplifying: fqdn-uri contains only
[A-Za-z0-9:/@._%-], so naive double-quoting is provably safe and the YAML
validation was guarding against an input that cannot occur.

What is kept, because it is the part that actually matters: secrets.yaml is PVC
state holding every other HA secret (influxdb_token and friends), so the file is
filtered-and-appended rather than rewritten, moved atomically, and guarded
against shrinking. Tested in HA's own busybox shell -- comment and pre-existing
keys preserved, idempotent across runs, no duplicate key.

Also records why an env var cannot be used at all: HA removed `!env_var` from
its YAML loader, so `!secret` is the only mechanism for recorder.db_url, and
config-sync deliberately does not manage secrets.yaml.

Back to busybox; HA's image is no longer needed for this.

Refs #434

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 21:18:27 +02:00
claude-botandClaude Opus 5 39885b9400 docs(home-assistant): record the completed statistics migration
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
Phase 2b is done: 12.77M statistics rows copied SQLite -> Postgres with HA
running and no downtime (read-only mount; SQLite WAL gives readers a consistent
snapshot).

  statistics_meta        1449       OK
  statistics             10799904   OK   153.9s
  statistics_short_term  1969804    OK    27.0s

  span in Postgres: 2024-03-13 -> 2026-08-19
  1449 entities with long-term history, 2627859 rows carrying sum
  database size 2252 MB against a 20Gi PVC

Records the high-water marks the cutover top-up must resume from, and two schema
details that cost a failed first attempt:

  - has_mean/has_sum are INTEGER in SQLite, BOOLEAN in Postgres, and Postgres
    will not coerce them.
  - the legacy created/start/last_reset DATETIME columns are 100% NULL in both
    bulk tables, which is what makes a tab-separated COPY safe.

Also documents why the schema was built from HA's own models with schema_changes
left EMPTY: HA's _initialize_database detects a new database via the
events.time_fired_ts index and writes SCHEMA_VERSION itself, so pre-stamping that
row would bypass HA's own initialisation.

ANALYZE was run on all three tables. After a bulk COPY the planner has no
statistics and HA seq-scans 10.8M rows on every history/energy view.

Refs #434

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 21:10:26 +02:00
security-bot 694389b569 chore(image-automation): bump quay.io/ceph/ceph:v19.2.6
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-08-19 15:43:41 +00:00
claude-botandClaude Opus 5 57bb39e7ee fix(home-assistant): percent-encode db_url and validate the rendered YAML
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
Review catch on #524, and it was a genuine blocker.

The first version built "postgresql://${PGUSER}:${PGPASSWORD}@host/db" in
busybox sh with no encoding. Two defects:

  1. The CNPG-generated password is random and may contain @ : / ? #, each of
     which changes how the URL parses. This repo already hit this exact issue
     with the same CNPG password-generation mechanism -- see
     kubernetes/apps/woodpecker/server.yaml, which switched to a lib/pq
     keyword DSN specifically to avoid escaping generated passwords. That
     option does not exist here: HA's recorder.db_url must be a SQLAlchemy URL.
     So the fix is percent-encoding.

  2. Worse, and the reason this was worth blocking: a quote in the password
     makes the YAML line malformed, so secrets.yaml stops parsing entirely and
     HA loses EVERY secret, not just this one. The line-count guard could never
     catch that -- the count is unchanged; the file is simply invalid.

Rewritten to run python3 from HA's own image (already pulled for the main
container, so no extra image) instead of busybox:

  - urllib.parse.quote(..., safe="") on user, password and dbname.
  - json.dumps for the YAML value: YAML is a JSON superset, so this emits a
    correctly escaped double-quoted scalar even with quotes or backslashes.
  - The file is still rewritten LINE-WISE rather than round-tripped through the
    YAML parser, so hand-maintained comments and key order survive.
  - The guard now validates the RESULT: parse the new content, assert no key
    from the old file disappeared, and assert recorder_db_url round-trips to
    exactly the URL that was built. That catches corruption the line count
    cannot.

Verified in HA's image against a deliberately hostile password
(p@ss:w/o"rd#?&\x=1) before commit:

  new version   -> parses, comment preserved, idempotent, password decodes back
                   byte-exact, host and dbname parse correctly
  naive version -> yaml.safe_load raises ParserError

i.e. the approach this replaces would have produced an unparseable
secrets.yaml.

Also updates MIGRATION-recorder-postgres.md for this step landing, including
why the ordering (init container before the configuration.yaml change) is
mandatory.

Refs #434

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 15:58:50 +02:00
claude-botandClaude Opus 5 17f913ba2f feat(home-assistant): render recorder_db_url from the ha-pg Secret
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
Phase 2a of the recorder move off SQLite (MIGRATION-recorder-postgres.md).
Adds an init container that writes `recorder_db_url` into /config/secrets.yaml
from the CNPG-generated ha-pg-app Secret.

INERT until the recorder is repointed. It only adds a key to secrets.yaml;
nothing reads it until `db_url: !secret recorder_db_url` lands in
configuration.yaml in the homeassistant repo. That ordering is REQUIRED in this
direction — HA refuses to start when a referenced secret is missing, so the
config change must not go first.

Why an init container rather than a hand-added line: the password is generated
by CNPG and can be rotated, so a hand-written entry goes stale silently and HA
then fails to start on a bad password. Re-rendering on every pod start is also
what makes a rotation take effect.

secrets.yaml is PVC runtime state that config-sync deliberately does not manage
(it holds influxdb_token and others), so this must EDIT the existing file, never
replace it. The script filters out any existing recorder_db_url line, appends
the current one, and guards the result: if the rewrite would drop any
non-recorder line it aborts rather than truncating the file holding every other
secret HA needs. Written to a temp path and moved atomically, chmod 600.

Verified in HA's own Alpine/busybox shell against a fixed sample before commit
-- pre-existing keys preserved, guard reports old=3 new=3, and a second run
produces a byte-identical 4-line file (idempotent, no duplicate key).

Refs #434

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 15:48:00 +02:00
claude-botandClaude Opus 5 1260165af1 feat(monitoring): alert on CNPG WAL archiving and backup failure
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
Seven Postgres clusters back up to Hetzner and nothing alerted if that stopped.
The only related rule was HetznerBackupEndpointDown, which probes the S3
endpoint — it proves the bucket is reachable, not that anything was written to
it. Same failure shape as the WordPress backup that failed nightly for three
days against a bucket declared in tofu but never applied: the status surface
looked fine because nothing checked the thing that mattered.

CNPGWalArchiveBacklog is the load-bearing rule. `ready` counts WAL segments
waiting in pg_wal/archive_status; steady state is 0 on every cluster. A
sustained backlog means the archiver is failing, so point-in-time recovery is
silently broken from that moment and pg_wal grows until it fills the PVC and
takes Postgres down. Threshold >5 sustained for 30m: a segment is 16 MiB, so
that is ~80 MiB of un-archived WAL — well below anything that threatens the
volume, far above idle noise. It reads only the instance-level collector, so it
works despite #522.

CNPGBackupFailed fires on a base backup failure within 24h, guarded on
timestamp > 0 so the epoch-zero default is not read as "failed in 1970".

DELIBERATELY NOT ADDED: the backup-age alert, which is the one you would most
want. `(time() - cnpg_collector_last_available_backup_timestamp) > 48h` cannot
be used today because that metric reads exactly 0 on every instance in the
cluster (verified across gf-pg, ak-pg-1/2, pprl-pg-2/3, im-pg, wp-pg, ha-pg).
CNPG has no record of any successful backup anywhere, because the Backup CRs it
derives that from are deleted within a minute of completing — #522. Adding it
now would fire permanently for all seven clusters and be silenced within a day,
which is worse than not having it.

The backups themselves DO work; verified independently by listing the bucket
with barman-cloud-backup-list and restoring a WAL segment back out of S3. The
gap is in the record, not the data.

Both expressions were validated against live Prometheus before commit rather
than written from the metric names: the WAL selector matches 8 real series (a
zero match would mean a dead rule, the defect fixed in
prometheusrule-woodpecker-rotation.yaml), and neither expression fires
currently, which is correct since archiving is healthy and the only recorded
backup failure is ~91 days old.

Refs #522

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 15:43:07 +02:00
k.renner 53172dff74 Merge pull request 'feat(home-assistant): add ha-pg for the recorder (phase 1, empty cluster)' (#521) from feat-ha-recorder-postgres into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
Reviewed-on: k.renner/homelab#521
2026-08-19 13:24:32 +00:00
claude-botandClaude Opus 5 196ebda188 feat(home-assistant): add ha-pg for the recorder (phase 1, empty cluster)
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 recorder's SQLite database is the largest client writer in the cluster.
Measured 2026-08-19 via cgroup io.stat on the home-assistant container,
corroborated by kernel diskstats and Ceph pool counters: 40 GB/day to its RBD
volume, ~26% of all client writes, against only ~5 GB/day of actual row data.

The 8x gap is the write PATTERN, not the volume:

  ~30 state changes/sec (107k rows/hour, 2.57M/day)
    x2  SQLite WAL + checkpoint -- every dirtied page written twice
    x4  ext4 journal + commit, since synchronous=FULL fsyncs every commit
        (measured: 819 KB of 4 KiB writes -> 3.28 MB at the block device)
    = ~15 KB written per recorded state change

Postgres replaces scattered fsync'd page rewrites with sequential batched WAL.
This attacks the multiplier rather than trimming what gets recorded, which would
hide the problem instead of fixing it.

PHASE 1 ONLY. The cluster comes up EMPTY and nothing consumes it -- HA stays on
SQLite. Deploying it is inert, so there is nothing to roll back if it misbehaves.
Phase 2 migrates the statistics tables and repoints recorder.db_url in the
homeassistant repo; MIGRATION-recorder-postgres.md has the sequence.

What phase 2 must not get wrong, recorded now while it is fresh: HA does NOT
migrate data between database engines. `statistics` holds 10,793,712 rows
spanning 2024-03-13 to today -- 2.4 years of energy history that is not in
InfluxDB and cannot be reconstructed. Repointing db_url at an empty database
silently discards it. `states` (18M rows) is disposable at purge_keep_days: 7.

Postgres is tuned to minimise write volume, which is the point of the move:

  synchronous_commit off  -- the recorder is telemetry, not transactional
                             record-keeping. Postgres stays crash consistent;
                             only the last few hundred ms of commits are at risk
                             on unclean shutdown. In exchange, commits stop
                             forcing an fsync -- the exact cost that makes SQLite
                             expensive here.
  wal_compression on      -- recorder writes are small inserts scattered across
                             pages, so post-checkpoint full-page writes dominate
                             WAL volume.
  checkpoint_timeout 15min / max_wal_size 2GB
                          -- fewer checkpoints means fewer repeat full-page
                             images for the hot rows the recorder rewrites.
  full_page_writes        -- deliberately LEFT ON. RBD can tear a page across a
                             4 MiB object boundary; trading that for write volume
                             risks an unrecoverable database.

Also included:
  - ObjectStore on its own s3 prefix (two clusters sharing a destinationPath
    interleave WAL streams and corrupt both).
  - ScheduledBackup at 02:50 UTC, offset from the other CNPG clusters.
  - PodMonitor added WITH the cluster rather than after it, so the migration has
    metrics from the start instead of discovering the gap later (#433).
  - Cilium policies as DEDICATED policies with their own endpointSelector, not
    rules on the namespace-wide default-deny -- that policy is endpointSelector:{}
    so any rule there applies to every pod in the namespace, and :8000 is served
    by more than the CNPG instance manager. Includes the ICMP allowance that
    attaching L4 rules to the ha-pg endpoints makes necessary for PMTUD.

The hetzner-creds ExternalSecret reuses seed/paperless/hetzner-creds, the same
coupling #397 tracks for woodpecker. Noted in the file with the exact bao command
to fix it; the mount is `homelab`, so #397's recorded 403 on `bao kv list seed`
was a wrong-path error rather than missing permissions.

Refs #434, #397

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 15:22:21 +02:00
k.renner 1d76dba474 Merge pull request 'fix(ceph): stop mon compacting on every paxos trim (240 GB/day of writes)' (#520) from fix-ceph-mon-write-amplification into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#520
2026-08-19 12:58:24 +00:00
claude-botandClaude Opus 5 63f25c1511 fix(ceph): stop mon compacting on every paxos trim (240 GB/day of writes)
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 three Ceph monitors were writing 240 GB/day between them for a ~106 MB
database. That was ~62% of all OS-disk writes on the nodes and roughly a
quarter of everything the cluster writes -- more than every client workload
combined.

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 mon 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

Cause: mon_compact_on_trim, upstream default true, "compact a certain prefix
(including paxos) when we trim its old states". With paxos_trim_min at 250 and
~99k transactions/day, that is hundreds of forced range compactions per day,
each rewriting the store.

The churn driving it is not cluster activity. 99% of paxos versions are cluster
log and mgr stats -- logm 6.7M, 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 change removes the
amplification regardless of the 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. 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.
  - mon_compact_on_start is set to true here (upstream default false), so every
    mon restart -- upgrade, node reboot, reschedule -- does a full compaction.
    Cheap on a ~106 MB store.

Baseline for verification, mon.a cgroup io.stat at 2026-08-19T12:56:29Z:
wbytes=2154257645056, wios=22509683, uptime since 2026-07-23T13:20:44Z.

Note on methodology: cAdvisor's container_fs_writes_bytes_total roughly
double-counts here -- it reported 425 GB/day for OSDs where nvme0n1 shows 212,
and 155 GB/day for mons where cgroup io.stat shows 80. It was used only to rank
writers; every figure above comes from cgroup io.stat or kernel diskstats, which
reconcile with each other and with the drives' SMART counters.

Refs #434

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 14:56:47 +02:00
k.renner 3be75f535e Merge pull request 'fix(ceph): replicapool and cephfs data to size 2, min_size 1' (#519) from fix-ceph-replica-size-2 into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#519
2026-08-19 12:40:01 +00:00
claude-botandClaude Opus 5 db460079f8 fix(ceph): replicapool and cephfs data to size 2, min_size 1
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
With 3 OSDs on 3 hosts, size=3 meant every write landed on every drive in the
cluster. Per-drive write volume equalled TOTAL cluster write volume, so all
three drives wore at an identical rate and would have reached end-of-rated-life
together: measured 2026-08-19 from the drives' own SMART counters, 80/82/87%
endurance used, advancing ~0.13 points/day, exhausted between Nov 2026 and
Jan 2027 (#434). Replication gives no protection there — the wear-out is
correlated by construction, not independent.

size 2 cuts per-drive write volume by a third and desynchronises the wear.

min_size 1 is required, not incidental. Ceph does NOT lower min_size when size
is reduced; it was 2, and leaving it would make replicapool 2/2, where a single
OSD down blocks IO for its PGs. That would hang every RBD volume in the cluster
on every rolling node reboot and every OSD restart.

Accepted trade, stated explicitly: while degraded (one OSD down), a second
drive failure loses data, and at 80%+ endurance that window is not
hypothetical. What limits the blast radius is that CNPG barman and Velero both
ship offsite to Hetzner, so exposure is the RPO of those backups rather than
total loss. Node reboots must stay serialised with HEALTH_OK between them.

CephFS metadataPool deliberately stays at size 3: 467 MiB against data0's
275 GiB, so it contributes nothing measurable to wear, while losing it destroys
the entire filesystem rather than the objects on one OSD. The wear saving comes
from data0. The RGW pools in objectstore.yaml were already size 2.

One-time cost on apply: Ceph deletes the third replica of everything, which is
background IO, and raw used drops from ~1.4 TiB to ~0.95 TiB.

Refs #434

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 14:35:58 +02:00
k.renner ef40c68b1f Merge pull request 'fix(ceph): stop deferring small writes on SSD (halves write amplification)' (#518) from fix-ceph-deferred-write-amplification into master
Reviewed-on: k.renner/homelab#518
2026-08-19 12:30:21 +00:00
claude-botandClaude Opus 5 6c31b552d1 fix(ceph): stop deferring small writes on SSD (halves write amplification)
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
bluestore_prefer_deferred_size_ssd was set to 65536 and
bluestore_deferred_batch_ops_ssd to 64 as a QLC throughput tune. Ceph's SSD
defaults are 0 and 16. Reverted to the defaults, set explicitly so the stored
`ceph config` value is overwritten rather than merely un-declared.

prefer_deferred_size defers every write SMALLER than the threshold: it lands in
RocksDB's WAL first and is applied to the object store afterwards. That is a
double write, and at 64 KiB it caught essentially every write this cluster
makes -- SQLite pages are 4 KiB, Postgres pages 8 KiB. Ceph disables deferral on
SSD by default because solid state does not need the random-write batching that
helps spinning media.

Why the original tune was the wrong trade:

  - The benchmark justifying it cannot have exercised it. The 2026-05-05
    baseline used 4 MiB writes, 64x above the 64 KiB cutoff, so none of those
    writes were ever deferred. The setting was validated by a workload it does
    not affect.

  - It doubles writes onto QLC, the lowest-endurance NAND type. Measured
    2026-08-19 from the drives' own SMART counters: all three OSD devices at
    80/82/87% endurance used, advancing ~0.13 points/day, i.e. rated life
    exhausted between Nov 2026 and Jan 2027. Because size=3 on 3 OSDs, every
    drive absorbs 100% of cluster writes, so they wear in lockstep and expire
    together (see #434).

Measured amplification before this change: 640 GB/day written to the three NVMe
devices against 117 GB/day at the RBD client layer -- 5.5x, of which 3x is
replication and ~1.8x BlueStore. Removing the double write should take the
BlueStore factor toward ~1.1x.

The cluster is write-light, so throughput was never the binding constraint;
endurance is. Both parameters are runtime-updatable, so no OSD restart.

Also corrects the drive model in the comment: the OSD devices are
CT1000P3PSSD8 (Crucial P3 Plus), not the P2 named previously.

bdev_enable_discard / bdev_async_discard are unrelated and kept as-is.

Refs #434

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 14:27:01 +02:00
k.renner d437775319 Merge pull request 'fix(alert-monitor): stop over-suppressing alerts in the noise log (#326)' (#517) from fix-326-noise-log-suppression into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#517
2026-08-19 10:54:27 +00:00
claude-botandClaude Opus 5 8a8145011a fix(alert-monitor): stop over-suppressing alerts in the noise log
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
Two defects in the noise-log dedup, both of which silently mute real alerts.

1. Prose-mentioned fingerprints became suppressions.

The extractor grepped the WHOLE issue body for 16-hex tokens. Triage reasons
routinely cite another alert's fingerprint in prose ("see the issue filed for
fingerprint <fp>"), so those citations were read as suppression entries.

Verified against the live noise log: the old extractor yields 22 fingerprints
for 21 real entries. The extra one is 2ddc41edb689fc33 -- the crowdsec alert
that HAD been filed as a genuine issue, permanently muted purely because two
other entries mention it. Now only lines in the exact shape the write-back step
emits count:

  - <fp> <ALERTNAME> — <YYYY-MM-DD>: <reason>

Re-verified with the new parser: 21, and 2ddc41edb689fc33 is no longer among
them.

2. Suppression never expired.

The fingerprint is sha256(alertname|scope) and is deliberately stable across
recurrences, so a single blip verdict muted that alertname+scope pair forever.
21 pairs were permanently muted, including openbao's KubePodNotReady,
KubeStatefulSetReplicasMismatch and KubePdbNotEnoughHealthyPods -- so a real
openbao outage would have filed no issue at all.

A noise verdict means "this occurrence was benign", not "this alert can never be
interesting again". Entries now expire after NOISE_TTL_DAYS (default 30) counted
from the date already present in each line. Re-triaging a recurrence weeks later
is cheap; missing an outage because a July blip muted it is not. If it is still
noise, triage re-adds it automatically.

Rollout is gentle by construction: every current entry is dated 2026-07-21 or
later, so at a 30d TTL the cutoff is 2026-07-20 and NOTHING un-mutes on merge --
the oldest entries lapse a day later, and only re-triage if they actually fire
again.

Unparseable lines are dropped, i.e. they stop suppressing: this fails toward
more alerting, never less.

Verified end-to-end by running the shipped snippet inside the real
ci-claude:2.1.218 image against the live noise log: cutoff 2026-07-20,
21 suppressions, prose-only fingerprint absent. Note that image ships BusyBox
date, not GNU -- `date -d '30 days ago'` errors there, so the cutoff uses epoch
arithmetic with `date -d @<epoch>`, which was confirmed working in that image.

Refs #326

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 10:11:45 +02:00
k.renner 2df54d302b Merge pull request 'fix(zigbee2mqtt): restore HA discovery after a mosquitto restart (#381)' (#516) from fix-381-z2m-discovery into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#516
2026-08-19 08:05:30 +00:00
claude-botandClaude Opus 5 da0f895b6d fix(zigbee2mqtt): bound the guard's MQTT calls with timeout
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
Review catch on #516: the restart-triggering mosquitto_pub was unbounded,
unlike the sibling call in zigbee2mqtt-interview-watch.sh.

Neither client bounds a TCP connect that HANGS rather than being refused, and a
stalled connect is most likely precisely when this guard runs -- around a broker
restart. Unbounded, the loop would block forever and stop logging, which is the
silent-failure mode the guard exists to prevent.

Applied to the reads as well, not just the publish: mosquitto_sub -W bounds the
wait for a message once CONNECTED, so it does not cover the connect phase
either. The timeout wrapper is set above -W so the protocol-level wait still
wins in the normal case.

Not mosquitto_pub -W: that flag does not exist for mosquitto_pub and makes every
publish fail on a usage error.

The cooldown stamp was already written only on a successful publish, so a failed
restart request still retries on the next cycle rather than burning the cooldown.

`timeout` is confirmed present in eclipse-mosquitto:2.0.22 (already used by
interview-watch, and exercised directly in this image).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 09:51:17 +02:00
claude-botandClaude Opus 5 5c856af578 feat(zigbee2mqtt): restore HA discovery after a broker restart
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
mosquitto runs `persistence false` with no PVC (deliberate and settled), so
every broker restart wipes the retained store. Zigbee2MQTT publishes its HA
discovery configs retained and has no way to restore them itself, so a broker
restart not followed by a Z2M restart leaves every Z2M entity unavailable in HA
indefinitely -- ~14 h on 2026-07-23.

Adds a discovery-guard sidecar to the Z2M pod that detects the wiped state and
asks Z2M to restart, which re-runs discover().

Detection. bridge/state and bridge/{info,devices,groups,definitions} are all
retained, but only bridge/state is republished on reconnect (mqtt.ts onConnect).
So "bridge/state present + bridge/groups absent" means Z2M is talking to this
broker but nothing has re-run start() since the store was wiped.

bridge/groups specifically, because the absent-half partner must not be
re-publishable by ordinary runtime events or it self-heals and the guard goes
blind while HA is still broken. bridge/devices, bridge/definitions and
bridge/info all reach inbound radio traffic via devicesChanged (receive.ts hands
deviceExposesChanged to every fromZigbee converter); bridge/health self-heals on
its own 10 min timer. bridge/groups is only republished by deliberate admin
actions, and publishGroups() always publishes, emitting `[]` when no groups
exist -- confirmed live: it currently holds a 2-byte payload, so presence is
testable.

Not AND-ed with bridge/info being absent. That would cut false positives at the
cost of false negatives, and the costs are asymmetric: a false positive is one
~60s Z2M restart, a false negative is every Zigbee entity dead until a human
notices. Tuned for sensitivity.

Spurious positives are handled by a 90s confirm re-check, which covers both the
startup race (bridge/state is published at mqtt.connect(), bridge/groups only at
the end of BridgeExtension.start()) and a dropped publish (these topics go out at
QoS 0). A 30 min cooldown keeps a Z2M that will not come up healthy from being
restarted in a loop.

Action is bridge/request/restart, which is a full in-process restart -- index.js
restart() -> stop(true) tears down every extension, then start() builds a new
Controller and re-runs HomeAssistant.start(). discover() then republishes only
missing or changed configs, which after a wipe is all of them.

Reuses the existing zigbee2mqtt broker credential (ACL `readwrite
zigbee2mqtt/#`), so no new broker user and no read grant on homeassistant/#,
which the ACL denies on purpose.

Verified in the eclipse-mosquitto:2.0.22 image the sidecar actually runs:
mosquitto_sub -C/-W, mosquitto_pub and `date -u +%s` all present; `sh -n` clean;
startup run logs correctly and enters the loop. Detection was exercised against
the live broker and correctly reports healthy (bridge/state online,
bridge/groups present) with no action.

NOTE: this rolls mosquitto, because the corrected ACL comment lives inside the
container command. That roll wipes the retained store, which the guard then
repairs within ~6 min if the Z2M roll does not already do so.

Closes #381

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 09:35:58 +02:00
claude-botandClaude Opus 5 9d69eb1a29 docs(mosquitto): correct the disproven "discovery self-heals" claim
Four comments across the broker manifests asserted that retained state is
re-populated by all clients on reconnect, and specifically that Zigbee2MQTT
re-publishes HA discovery when it sees the homeassistant/status birth message.
Both are false, and the second is the premise that kept issue #381 open.

Verified against Zigbee2MQTT 2.12.1 source:

  - discover() runs only from HomeAssistant.start(); it is never called from
    the reconnect path.
  - lib/mqtt.ts onConnect() republishes bridge/state and resubscribes, nothing
    more. republishRetainedTimer is armed only inside connect() and is never
    re-armed on reconnect.
  - lib/extension/homeassistant.ts onMQTTMessage(), on status_topic == "online",
    republishes bridge/state plus cached device states after a hardcoded 30s
    delay -- but never the discovery configs.
  - Discovery is published retain:true, and the only knob is the global
    mqtt.force_disable_retain, which upstream documents as breaking the HA
    integration. Broker retention is the sole mechanism Z2M relies on.
  - No bridge/request endpoint forces a rediscovery; upstream declined to add
    one (Koenkk/zigbee2mqtt #27458, #22977, #16950, all closed NOT_PLANNED).

The mosquitto ACL was never the blocker: `read homeassistant/status` is already
granted. That grant is still correct -- the cached-state republish is what makes
HA show current values after an HA restart -- so it is kept and the comment now
says what it actually buys.

Also records that a RollingUpdate roll is equivalent to a broker restart for
retention: maxUnavailable:0/maxSurge:1 preserves connectivity, but the new pod
starts with an empty retained store.

Comment-only. The two deployment comments are plain YAML comments outside the
container command, so the rendered Deployment is byte-identical and the broker
does not roll; the ConfigMap carries no reloader annotation, so it updates in
place without restarting mosquitto.

Refs #381

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 09:27:29 +02:00
k.renner 3d05d47239 Merge pull request 'feat(opentofu): destroy the rustfs LXCs 800/801/802' (#515) from decommission-rustfs-lxc into master
ci/woodpecker/push/deploy-infrastructure Pipeline was successful
ci/woodpecker/manual/deploy-infrastructure Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#515
2026-08-19 06:55:17 +00:00
claude-bot fd35f5f21b feat(opentofu): destroy the rustfs LXCs 800/801/802
ci/woodpecker/manual/deploy-infrastructure Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Stage 2 of the decommission the shim was written for. Deleting the file makes
the next apply destroy the three containers.

The containers were taken out of service months ago — they are stopped
(pve_up = 0 for all three) and rustfs-{1,2,3}.srv.renner.uno no longer resolve
— but the LXC objects and their disks still exist on saturn/jupiter/neptune.
Two earlier destroy attempts (#270) died instantly with "context deadline
exceeded", because the bpg provider reads timeout_delete from STATE and the
imported resources had stored 0.

That is now fixed: apply 5675 landed timeout_delete = 1800 and the rest of the
timeouts in state, in place, with 0 destroyed. The destroy has a real deadline
to work with for the first time.

Checked before removing, since the containers are tagged `state-backend` and
`terraform-backend`:

  - The tofu backend is Hetzner S3 (renner-uno-tfstate at
    fsn1.your-objectstorage.com), not rustfs. Destroying these cannot pull the
    state out from under the apply. The tags are historical, from before that
    migration.
  - No Kubernetes or Ansible resource references rustfs.
  - scripts/restore-authentik-db.sh mentions rustfs only as an `mc` alias name;
    its endpoint is s3.renner.uno:9000, the Ceph RGW `migration` store.
  - DNS for the three names is already gone.

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
2026-08-19 08:51:38 +02:00
k.renner d208c17509 Merge pull request 'docs(opentofu): record the hairpin _id diff, retract the PUT 400 warning' (#514) from document-hairpin-id-diff into master
ci/woodpecker/push/deploy-infrastructure Pipeline was successful
Reviewed-on: k.renner/homelab#514
2026-08-19 06:50:24 +00:00
claude-bot 853928b6b1 docs(opentofu): record the hairpin _id diff and retract the PUT 400 warning
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Both of these were learned the expensive way today and existed only in commit
messages and PR descriptions, which is not where anyone looks.

providers.tf carried a warning that a UniFi PUT may require _id in the body,
which restapi does not send, and that a 400 should be answered by tainting the
object so CI recreates it. That is wrong on this controller. Tested against a
live rule with exactly the body tofu sends: HTTP 200, object returned intact,
rule byte-identical before and after, 60 rules before and after. Confirmed at
scale by apply 5675 — all 60 modified, no 400s, ci.renner.uno and
immich.renner.uno still 200.

That wrong warning had a cost. A note saying an apply might break firewall
rules is one nobody argues with, so the apply did not get run, and the
renner-uno-k8s-mariadb bucket added in b976a67b sat uncreated for three days
while the WordPress backup failed nightly with "The specified bucket does not
exist". The warning is replaced with the evidence that disproves it.

unifi_nat_hairpin.tf now records that the 60-rule `- _id` diff is permanent and
cosmetic, and lists the four attempts to remove it with the plan number that
disproved each: ignore_changes_to (5661), ignore_all_server_changes (5662),
restapi 3.x ignore_server_additions (5667), and create_returns_object (5677).

The first three cannot work in principle — `data` is a configured attribute, so
Terraform core diffs it against state before provider-side ignore logic runs.
The fourth was the better theory, that PUT responses re-polluted `data`, and it
is wrong too: the diff returned on the plan immediately after the apply. That
means the response is written into `data` on the read path, and settling it
needs the provider source rather than more guesses at its schema.

No behaviour change. Documentation only, so the next person inherits the
findings instead of the dead ends.

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
2026-08-19 08:34:42 +02:00
k.renner 3fac750430 Merge pull request 'fix(opentofu): make apply safe — no container replacement, no permanent NAT diff' (#513) from fix-rustfs-shim-replace into master
ci/woodpecker/push/deploy-infrastructure Pipeline was successful
ci/woodpecker/manual/deploy-infrastructure Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#513
2026-08-19 06:15:35 +00:00
claude-bot 49cc880169 fix(unifi): capture the API response on create only, not on every write
ci/woodpecker/manual/deploy-infrastructure Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Root cause of the 60 permanently-dirty hairpin NAT rules.

The provider was configured write_returns_object = true, which makes it store
the API response into `data` after POST *and* PUT. The UDM assigns `_id`;
unifi_nat_hairpin.tf's data = jsonencode({...}) never sets it. So every write
put a field into state that the config does not have, and all 60 rules planned
as "will be updated in-place" with `- _id` as the entire diff — re-dirtying
themselves on the next write, indefinitely.

No provider-side ignore can suppress this, because `data` is a configured
attribute and Terraform compares config to state in core, before provider
logic runs. Three were tried and disproven against real plans rather than
argued about:

  ignore_changes_to = ["_id"]                       plan 5661 — 60 remained
  ignore_all_server_changes = true                  plan 5662 — 60 remained
  restapi 3.x + ignore_server_additions = true      plan 5667 — 60 remained,
                                                    plus a new api_data diff

create_returns_object keeps the part the provider genuinely needs — the POST
response is how it learns `_id` for id_attribute = "_id" — while no longer
letting updates rewrite `data` from the response.

This does not clear the existing pollution by itself: state still carries `_id`
in `data` for all 60 rules, so the plan will keep showing them until one apply
rewrites `data` to the configured value. What it changes is that the apply then
sticks, instead of the next write undoing it.

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
2026-08-19 07:55:44 +02:00
claude-bot 08b3a39878 fix(opentofu): ignore operating_system instead of declaring it
ci/woodpecker/manual/deploy-infrastructure Pipeline was successful
Plan 5657 rejected the previous commit:

  Error: Missing required argument
    on lxc_800_rustfs_decommission.tf line 98, in resource
    "proxmox_virtual_environment_container" "rustfs_decommission":
    The argument "template_file_id" is required, but no definition was found.

The plan's own state dump showed only `type = "debian"` inside
operating_system, so mirroring it looked complete. It is not:
template_file_id is create-time-only, PVE does not report the original
template for an existing container, and nothing else in opentofu/ declares
one to copy — these three are the only container resources in the repo.

Guessing a template would be a lie that stays invisible until something
recreates these containers, which is precisely the event this file exists to
prevent. So operating_system is left undeclared and ignored via lifecycle
instead, which stops it planning as a removal without asserting a value that
is not known.

The timeouts remain outside ignore_changes on purpose — they are the one
thing this shim needs to actually apply.

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
2026-08-19 07:11:39 +02:00
claude-bot a8c1af1502 fix(opentofu): make the rustfs decommission shim plan in-place, not replace
ci/woodpecker/manual/deploy-infrastructure Pipeline failed
The v2 shim's own instructions say the plan MUST show an in-place update and
no replace before applying. It does not. Pipeline 5656 planned all three
containers as `must be replaced`, which would destroy and recreate LXCs
tagged critical / state-backend / terraform-backend instead of nudging a
timeout value into state.

Cause: v2 declared only node_name, vm_id, started, unprivileged and the
timeouts, on the stated assumption that "the other attributes mirror the real
containers so the plan stays in-place". They were not declared at all, so
every absent attribute planned as a removal — and two of those removals force
replacement:

  - mount_point { ... }       # forces replacement (size, volume)
  - operating_system { ... }  # forces replacement

Declaring the full attribute set, mirrored from the values the plan itself
reported out of state: cpu, memory, disk, mount_point, network_interface,
operating_system and initialization. hostname and mac_address differ per
container, so the local map carries them per key rather than being repeated
or guessed.

tags is declared for a separate reason: it was planning as
["critical","rustfs","state-backend","terraform-backend"] -> null, which does
not force replacement but would have stripped the tags off the live
containers on apply.

This does not unblock the WordPress backup on its own. That needs
renner-uno-k8s-mariadb, which the same plan wants to create — but the bucket
cannot be applied while the plan also carries three container replacements,
and a -target apply is not an acceptable way around it: it diverges state
from config and leaves the real problem in place.

Verification is the file's own gate — a plan showing in-place only.

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
2026-08-19 07:08:47 +02:00
k.renner 38eb6b6503 Merge pull request 'fix(woodpecker): revive the rotation alert rules after the namespace move' (#512) from fix-rotator-woodpecker-url into master
ci/woodpecker/manual/deploy-infrastructure Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#512
2026-08-19 04:53:19 +00:00
claude-bot 5d542af431 fix(woodpecker): revive the rotation alert rules after the namespace move
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
Rebased onto master now that #511 landed the WOODPECKER_URL fix for both
rotators — that part is dropped from here rather than duplicated. What remains
is the half nothing else covered.

All four rules in prometheusrule-woodpecker-rotation.yaml were selecting
series that do not exist, so the whole group was silent:

- WoodpeckerServerNotReady queried kube_statefulset_status_replicas_ready.
  The server became a Deployment when the sqlite->CNPG migration dropped its
  PVC, so that metric has no series at all — a `critical` "all CI is down"
  alert that could never fire, and had been dead well before the namespace
  move. Now kube_deployment_status_replicas_ready in the woodpecker namespace.

- WoodpeckerAgentNotReady selected namespace="apps". Broken by the namespace
  migration; mine.

- The two woodpecker-secret-rotate rules selected namespace="apps", but that
  rotation runs from the openbao namespace. Broken since that move, which the
  woodpecker kustomization documents.

Verified against live Prometheus instead of reasoned about: server ready=1,
agent available=1, cronjob 1 series. The originals returned nothing, which is
exactly why they never fired rather than firing wrongly.

Two stale claims in the same file corrected while here: it pointed at
kubernetes/apps/woodpecker/rotate-agent-secret.yaml, which no longer exists,
and asserted the CronJob/Job series "stay empty until the rotation feature is
merged" — that rotation has since landed in openbao.

Also finishes the netpol comments #511 left mid-update, which still credited
woodpecker-allow-rotators to `apps/*`. gitea.apps.svc:3000 in review-bot.yaml
is deliberately untouched: gitea really is still in apps.

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
2026-08-19 06:50:42 +02:00
k.renner 05945545e8 Merge pull request 'fix: [alert] KubeJobFailed (openbao) (#510)' (#511) from alert-fix/issue-510 into master
Reviewed-on: k.renner/homelab#511
2026-08-19 04:46:25 +00:00
issue-fix-bot d9f1d6cfc8 fix: [alert] KubeJobFailed (openbao) (#510)
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-19 03:44:13 +00:00
k.renner 4b1179e37b Merge pull request 'chore(ci): bump the woodpecker lint CLI to v3.17.0-alpine' (#509) from bump-woodpecker-cli into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#509
2026-08-18 21:46:13 +00:00
63 changed files with 2349 additions and 269 deletions
+32 -2
View File
@@ -135,8 +135,38 @@ steps:
TITLES=$$(jq -c '[.[].title]' .ci-alert-monitor/open-issues.json)
NOISE_BODY=$$(jq -r 'map(select(.title == "Alert monitor: noise log")) | .[0].body // ""' \
.ci-alert-monitor/open-issues.json)
FPS=$$(printf '%s' "$$NOISE_BODY" | grep -oE '\b[0-9a-f]{16}\b' \
# Suppression entries are exactly the structured lines the write-back
# step emits: "- <fp> <ALERTNAME> — <YYYY-MM-DD>: <reason>". Parse THAT
# shape rather than grepping the whole body for 16-hex tokens. A reason
# routinely cites another alert's fingerprint in prose ("see the issue
# filed for fingerprint <fp>"), and the body-wide grep turned every such
# citation into a silent, permanent suppression of an alert that was
# never classified as noise — verified against the live noise log, which
# yielded 22 fingerprints for 21 real entries, the extra one being
# 2ddc41edb689fc33, an alert that HAD been filed as a genuine issue.
#
# Entries also expire after NOISE_TTL_DAYS. A noise verdict says "this
# occurrence was benign", not "this alert can never be interesting
# again" — but the fingerprint is sha256(alertname|scope) and therefore
# stable across recurrences, so without an expiry a single blip mutes
# that alertname+scope pair forever. Re-triaging a recurrence weeks
# later is cheap; missing a real outage because a July blip muted
# openbao's KubePodNotReady is not.
#
# Lines that fail to parse are simply dropped, i.e. they stop
# suppressing — this fails toward MORE alerting, never less.
#
# date is BusyBox here, not GNU: `date -d '30 days ago'` errors out, so
# the cutoff goes through epoch arithmetic and `date -d @<epoch>`, which
# this image does support.
NOISE_TTL_DAYS=$${NOISE_TTL_DAYS:-30}
CUTOFF=$$(date -u -d @$$(( $$(date -u +%s) - NOISE_TTL_DAYS * 86400 )) +%F)
FPS=$$(printf '%s\n' "$$NOISE_BODY" \
| sed -nE 's/^- ([0-9a-f]{16}) [^ ]+ — ([0-9]{4}-[0-9]{2}-[0-9]{2}):.*/\2 \1/p' \
| awk -v c="$$CUTOFF" 'BEGIN{gsub(/-/,"",c)} { d=$$1; gsub(/-/,"",d); if (d+0 >= c+0) print $$2 }' \
| jq -R -s -c 'split("\n") | map(select(length > 0))')
echo "Noise suppressions in effect: $$(printf '%s' "$${FPS:-[]}" | jq length)" \
"(TTL $${NOISE_TTL_DAYS}d, entries dated >= $$CUTOFF)"
# Synthesize pseudo-alerts for BLIPS: lookback series whose label set
# is not currently firing at all (checked against the PRE-filter raw
# snapshot, so live-but-null-routed alerts don't reappear as blips).
@@ -260,7 +290,7 @@ steps:
'{body: ($$old + "\n" + $$add)}' > /tmp/gitea-patch.json
gitea_write PATCH "$$API/issues/$$NOISE_NUM" /tmp/gitea-patch.json
else
printf '%s\n\n' "Fingerprints the alert-monitor triage agent classified as noise. The dedup step skips any alert whose fingerprint appears below — delete a line to make that alert eligible for triage again. Keep this issue open; it is the suppression state." > /tmp/noise-header.txt
printf '%s\n\n' "Fingerprints the alert-monitor triage agent classified as noise. The dedup step skips any alert whose fingerprint appears below — delete a line to make that alert eligible for triage again. Keep this issue open; it is the suppression state. Entries EXPIRE after NOISE_TTL_DAYS (default 30) counted from the date in the line, so a one-off blip stops suppressing its alertname+scope pair once the window passes; re-add happens automatically if it is triaged as noise again. Only lines in the exact '- <fingerprint> <ALERTNAME> — <YYYY-MM-DD>: <reason>' form suppress anything — a fingerprint mentioned in prose does not." > /tmp/noise-header.txt
jq -n --rawfile hdr /tmp/noise-header.txt --rawfile add /tmp/noise-lines.txt \
'{title: "Alert monitor: noise log", body: ($$hdr + $$add)}' > /tmp/gitea-create.json
gitea_write POST "$$API/issues" /tmp/gitea-create.json
+10 -3
View File
@@ -13,7 +13,7 @@ when:
steps:
- name: validate-secrets
image: alpine:3.21
image: alpine:3.24.1
environment:
TF_VAR_proxmox_api_url:
from_secret: proxmox_api_url
@@ -129,13 +129,20 @@ 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
# 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.
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 jupiter saturn neptune; do
for node in 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 weekly Velero restore-test (`kubernetes/infrastructure/velero/`) exercises
The monthly 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,6 +79,30 @@ 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
@@ -121,7 +121,9 @@ spec:
persistentVolumeClaim:
claimName: audiobookshelf-books
- name: le-ca
emptyDir: {}
emptyDir:
medium: Memory
sizeLimit: 16Mi
---
apiVersion: v1
kind: Service
+38
View File
@@ -43,6 +43,44 @@ spec:
parameters:
shared_buffers: "256MB"
max_connections: "400"
# Write-reduction tuning. These databases are largely idle, and the
# Postgres defaults favour throughput over storage that wears out.
#
# checkpoint_timeout is the lever. The 5min default pushes every dirty
# page to disk 288 times a day, so a small database whose same few pages
# are re-dirtied constantly (sessions, tokens, job state) gets rewritten
# over and over. Measured on ak-pg: 25.8M checkpoint buffers flushed
# (~206 GB) against a 1046 MB database -- the whole thing roughly twice a
# day. At 15min those pages coalesce and are written a third as often.
#
# max_wal_size must rise with it, or checkpoints trigger on size instead
# and the timeout change does nothing. It also bounds crash recovery to
# at most 2GB of WAL replay.
#
# NOT changed: synchronous_commit stays at the default `on`. It is off for
# ha-pg because a state recorder is telemetry; these hold credentials,
# documents and job state, where losing the last few hundred ms of commits
# on an unclean shutdown is not an acceptable trade.
checkpoint_timeout: "15min"
max_wal_size: "2GB"
# zstd, not "on": the boolean spelling resolves to pglz (2016-era, weaker
# and slower than the lz4/zstd this build also accepts). Compresses the
# full-page images that dominate WAL volume after each checkpoint.
wal_compression: "zstd"
# A WAL segment is a fixed 16 MiB file, and archive_timeout forces a switch
# on a timer -- the segment is then written and archived at its full size
# no matter how little real WAL it holds. CNPG defaults to 5min, i.e. up
# to 288 segments/day = 3.2 GB/day here, most of it padding.
#
# 15min, NOT the 30min used elsewhere in this rollout. This cluster holds
# credentials, sessions and enrolment state, so its RPO is worth more than
# the difference: 30min would save a further 0.3 GB/day, which does not
# justify doubling the worst-case loss window on this data.
#
# It does carry a synchronous standby (instances: 2), so the realistic
# exposure is losing both instances, not one -- but the conservative value
# is cheap here and the aggressive one buys almost nothing.
archive_timeout: "15min"
# Async replication makes CNPG switchover slow because the standby has
# to chase missing WAL via barman-cloud and replay queued records
# before it can promote. With one synchronous standby, the standby is
+46
View File
@@ -29,6 +29,52 @@ spec:
parameters:
shared_buffers: "128MB"
max_connections: "50"
# Write-reduction tuning. These databases are largely idle, and the
# Postgres defaults favour throughput over storage that wears out.
#
# checkpoint_timeout is the lever. The 5min default pushes every dirty
# page to disk 288 times a day, so a small database whose same few pages
# are re-dirtied constantly (sessions, tokens, job state) gets rewritten
# over and over. Measured on ak-pg: 25.8M checkpoint buffers flushed
# (~206 GB) against a 1046 MB database -- the whole thing roughly twice a
# day. At 15min those pages coalesce and are written a third as often.
#
# max_wal_size must rise with it, or checkpoints trigger on size instead
# and the timeout change does nothing. It also bounds crash recovery to
# at most 2GB of WAL replay.
#
# NOT changed: synchronous_commit stays at the default `on`. It is off for
# ha-pg because a state recorder is telemetry; these hold credentials,
# documents and job state, where losing the last few hundred ms of commits
# on an unclean shutdown is not an acceptable trade.
checkpoint_timeout: "15min"
max_wal_size: "2GB"
# zstd, not "on": the boolean spelling resolves to pglz (2016-era, weaker
# and slower than the lz4/zstd this build also accepts). Compresses the
# full-page images that dominate WAL volume after each checkpoint.
wal_compression: "zstd"
# THE dominant write source on this cluster, by three orders of magnitude.
#
# A WAL segment is a fixed 16 MiB file. archive_timeout forces a segment
# switch on a timer so the archive never lags reality by more than that
# interval -- and the switched-out segment is written and archived at its
# full 16 MiB regardless of how few bytes of real WAL it contains.
#
# CNPG defaults this to 5min, i.e. up to 288 segments/day = 4.6 GB/day.
# Measured here: this database is 21 MB and generates 4.5 MB/day of actual
# WAL, while its RBD volume takes 211 MB/hour. Essentially every byte
# written is padding -- roughly 1100x amplification.
#
# 30min cuts that to 48 segments/day. The cost is RPO: on total loss of
# the cluster, up to 30min of Grafana state is unrecoverable rather than
# 5min. Grafana's state here is dashboards, annotations and preferences,
# and the write rate says it changes ~4.5 MB/day -- so that window is
# nearly always empty anyway.
#
# Applied to gf-pg FIRST, alone, as a staged rollout: it is the lowest-risk
# cluster and has the cleanest signal, since its real WAL is small enough
# that any change in volume is unambiguously this parameter.
archive_timeout: "30min"
resources:
requests:
cpu: 100m
@@ -0,0 +1,179 @@
# Home Assistant recorder: SQLite → Postgres
## Why
The recorder's SQLite database is the single largest client writer in the
cluster. Measured 2026-08-19 (cgroup `io.stat` on the home-assistant container,
corroborated by kernel diskstats and Ceph pool counters):
| | |
|---|---|
| HA writes to its RBD volume | **40 GB/day** |
| share of all client writes | **~26%** |
| actual row data underneath | **~5 GB/day** |
The 8× gap is the write *pattern*, not the volume:
```
~30 state changes/sec (107k rows/hour, 2.57M/day)
×2 SQLite WAL + checkpoint — every dirtied page written twice
×4 ext4 journal + commit, because synchronous=FULL fsyncs every commit
(measured: 819 KB of 4 KiB writes → 3.28 MB at the block device)
= ~15 KB written per recorded state change
```
Postgres replaces scattered fsync'd page rewrites with sequential batched WAL.
This attacks the multiplier rather than trimming what gets recorded — excluding
entities would hide the problem, not fix it.
**Context:** this is one of three write-reduction changes. The other two are
merged and verified — `size 3→2` on the Ceph pools (#519) and
`mon_compact_on_trim=false` (#520, which alone removed ~228 GB/day). See #434.
## What is at risk
`states` is disposable — `purge_keep_days: 7`, so it regenerates in a week.
**`statistics` is not.** As of 2026-08-19:
| table | rows | note |
|---|---|---|
| `statistics` | 10,793,712 | **spans 2024-03-13 → today, 2.4 years** |
| `statistics_short_term` | 1,902,624 | 5-minute buckets |
| `statistics_meta` | 1,448 | entity ↔ id mapping, required by both |
| `states` | 18,289,190 | disposable, 7-day retention |
| `events` | 483,989 | disposable |
**Home Assistant does not migrate data between database engines.** Repointing
`db_url` at an empty Postgres silently starts from zero and the energy dashboard
loses its entire history. The long-term statistics are irreplaceable — they are
not in InfluxDB, which HA does not read for the energy dashboard.
## Phase 1 — deploy the cluster (this PR)
`ha-pg` comes up **empty** and nothing consumes it. HA stays on SQLite, so a
failure here is inert and there is nothing to roll back.
Verify before going further:
```sh
kubectl -n home-assistant get cluster ha-pg # Cluster in healthy state, 1/1
kubectl -n home-assistant get backup # ha-pg-daily Completed (immediate: true)
kubectl -n home-assistant logs ha-pg-1 -c postgres | grep -i archive # WAL archiving, no errors
```
Confirm the backup actually landed in the bucket rather than trusting the CR
status — the WordPress backup failed nightly for three days against a bucket
that was declared but never created (see `opentofu/object_storage.tf`).
## Phase 2b — bulk statistics copy: DONE 2026-08-19
Completed with **HA running** — the SQLite file was read through a read-only
mount, and SQLite's WAL mode gives readers a consistent snapshot, so no downtime
was needed for the bulk copy.
```
statistics_meta: sqlite=1449 pg=1449 OK
statistics: sqlite=10799904 pg=10799904 OK (153.9s)
statistics_short_term: sqlite=1969804 pg=1969804 OK ( 27.0s)
statistics span in Postgres: 2024-03-13 -> 2026-08-19
entities with long-term history: 1449
rows carrying sum (energy dashboard): 2627859
database size: 2252 MB (PVC is 20Gi)
```
`ANALYZE` was run on all three tables. That is not housekeeping: after a bulk
`COPY` the planner has no statistics, and HA queries these tables constantly for
history and energy views, so without it every view seq-scans 10.8M rows.
Two schema details that cost a failed first attempt, recorded so the top-up does
not repeat them:
- `has_mean` / `has_sum` are INTEGER in SQLite and BOOLEAN in Postgres. Postgres
will not coerce; convert explicitly and preserve NULL.
- The legacy `created` / `start` / `last_reset` DATETIME columns are 100% NULL in
both bulk tables (superseded by the `_ts` variants), which is what makes a
tab-separated `COPY` safe — nothing in those tables needs escaping.
The schema itself was created with HA's own models
(`Base.metadata.create_all`), **not** hand-written DDL, and `schema_changes` was
left EMPTY on purpose. HA's `_initialize_database` detects a new database by
looking for an index on `events.time_fired_ts`, then writes `SCHEMA_VERSION`
itself. Pre-stamping that row would skip HA's own initialisation path.
### High-water marks for the cutover top-up
Rows written after the bulk copy are not yet in Postgres. Resume from:
```
statistics max_id=13323904 max_start_ts=1787162400.0
statistics_short_term max_id=159205762 max_start_ts=1787166000.0
```
## Phase 2c — cutover (REMAINING)
Do not start until phase 1 is verified.
**Migrate statistics only.** Copying all 18M `states` rows is slow, needs a long
HA outage, and buys a table that is discarded within 7 days anyway.
1. **Snapshot first.** Velero backup of the HA PVC, plus a `.backup` copy of
`home-assistant_v2.db`. This is the rollback.
2. **Stop the recorder** — scale `deploy/home-assistant` to 0. The database must
be quiescent; copying a live SQLite file gives a torn read.
3. **Create the schema.** Start HA once against the empty Postgres so it runs
its own migrations. Never hand-create the tables — the schema version must
match the HA version exactly or HA will refuse to start or silently migrate.
4. **Copy** `statistics_meta` → `statistics` → `statistics_short_term`, in that
order (the other two carry `metadata_id` foreign keys into the first).
Sequences must be reset afterwards, or the first insert collides on a
duplicate primary key:
```sql
SELECT setval('statistics_id_seq', (SELECT MAX(id) FROM statistics));
SELECT setval('statistics_meta_id_seq', (SELECT MAX(id) FROM statistics_meta));
SELECT setval('statistics_short_term_id_seq', (SELECT MAX(id) FROM statistics_short_term));
```
5. **Repoint** `recorder.db_url` in the `homeassistant` repo — that change lives
there, not here. The `recorder_db_url` secret it references is already
rendered into `secrets.yaml` by the `render-db-url` init container in
`deployment.yaml` (phase 2a), so this order is mandatory: **the init
container must be deployed first**, or HA refuses to start on a missing
secret.
The init container percent-encodes the credentials and validates the
resulting YAML rather than counting lines. That is not incidental — the CNPG
password is random, and a naive `"postgresql://user:pass@host/db"` string
makes `secrets.yaml` unparseable the moment the password contains a quote,
which takes out *every* secret HA loads, not just this one. Verified against
a deliberately hostile password before merge.
```yaml
recorder:
db_url: !secret recorder_db_url # postgresql://homeassistant:<pw>@ha-pg-rw.home-assistant.svc.cluster.local/homeassistant
```
The password is in the CNPG-generated `ha-pg-app` Secret. It must not be
committed — render it into `secrets.yaml` the same way the Z2M MQTT password
is handled in `zigbee2mqtt-deployment.yaml`.
6. **Verify** the energy dashboard renders history back to 2024, not just today.
That is the check that proves the statistics migration worked; "HA starts and
records" does not.
### Rollback
Revert `db_url` to the SQLite path and restore the `.backup` copy. Keep the
SQLite file until the energy dashboard has been confirmed over a full day —
deleting it early removes the only rollback that preserves the 2.4 years.
## After the migration
- The HA config PVC shrinks from 5.5 GB to a few hundred MB. Do not shrink the
PVC itself: `ceph-block` allows expansion, not reduction.
- Re-measure. `kubectl -n home-assistant exec deploy/home-assistant -c
home-assistant -- cat /sys/fs/cgroup/io.stat` was 40 GB/day before. If it does
not drop substantially, the migration did not achieve its purpose and that
should be recorded rather than assumed.
- `synchronous_commit: off` in `ha-pg-cluster.yaml` is the single biggest
contributor to the reduction. It is safe for telemetry (Postgres stays crash
consistent; at most a few hundred ms of commits are lost on unclean shutdown)
but it is a deliberate trade and should not be "corrected" later without
understanding why it is set.
+65 -3
View File
@@ -36,9 +36,67 @@ spec:
# (sml2mqtt bridge owns the USB device on edge), so it no longer needs
# the edge USB device and schedules on the general cluster pool. This
# gets the heavy HA pod (8Gi) off the soon-to-be-tiny edge box.
initContainers:
# Puts CNPG's connection URI into /config/secrets.yaml as
# `recorder_db_url`, for the recorder's move off SQLite
# (MIGRATION-recorder-postgres.md).
#
# HA has no way to read a db_url from the environment — `!env_var` was
# removed from its YAML loader — so `!secret` is the only mechanism, and
# secrets.yaml is PVC state that config-sync deliberately does not
# manage. Hence a small init container rather than a env: entry.
#
# The URI comes STRAIGHT from the ha-pg-app Secret. CNPG builds and
# percent-encodes it, so there is nothing to escape here: the value is
# restricted to [A-Za-z0-9:/@._%-], verified against the live secret.
# An earlier version of this assembled the URI from user/password/dbname
# and needed percent-encoding plus YAML round-trip validation to be
# safe. That was solving a problem CNPG had already solved.
#
# Re-rendered on every pod start, so a CNPG password rotation takes
# effect rather than silently leaving a stale URI behind.
#
# INERT until the recorder is repointed: nothing reads this key until
# `db_url: !secret recorder_db_url` lands in configuration.yaml in the
# homeassistant repo. That ordering is required — HA refuses to start
# when a referenced secret is missing.
- name: render-db-url
image: busybox:1.36
command:
- sh
- -c
- |
set -eu
S=/config/secrets.yaml
T=/config/.secrets.render.tmp
[ -f "$S" ] || : > "$S"
# Filter-then-append, never rewrite in place: this file holds
# every other HA secret (influxdb_token and friends).
grep -v '^recorder_db_url:' "$S" > "$T" || true
printf 'recorder_db_url: "%s"\n' "$DB_URI" >> "$T"
# Refuse to shrink the file. Cheap guard against the one failure
# that would actually hurt: losing the other keys.
[ "$(wc -l < "$T")" -ge "$(wc -l < "$S")" ] || {
echo "FATAL: rewrite would shrink secrets.yaml; aborting" >&2; exit 1; }
chmod 600 "$T"
mv "$T" "$S"
echo "recorder_db_url rendered"
env:
- name: DB_URI
valueFrom:
secretKeyRef: {name: ha-pg-app, key: fqdn-uri}
volumeMounts:
- name: config
mountPath: /config
resources:
requests:
cpu: 10m
memory: 16Mi
limits:
memory: 32Mi
containers:
- name: home-assistant
image: ghcr.io/home-assistant/home-assistant:2026.8.2 # {"$imagepolicy": "flux-system:home-assistant"}
image: ghcr.io/home-assistant/home-assistant:2026.8.3 # {"$imagepolicy": "flux-system:home-assistant"}
env:
- name: TZ
value: Europe/Berlin
@@ -221,9 +279,13 @@ spec:
# Writable emptyDir for the nginx entrypoint's envsubst output
# (/etc/nginx/conf.d/default.conf rendered from the .template).
- name: proxy-conf-d
emptyDir: {}
emptyDir:
medium: Memory
sizeLimit: 16Mi
# code-server's settings/extensions cache. Not on the config PVC
# so installed extensions don't pollute HA's /config listing.
# Trade-off: extensions reset across pod restarts.
- name: vscode-state
emptyDir: {}
emptyDir:
medium: Memory
sizeLimit: 16Mi
@@ -0,0 +1,162 @@
---
# Postgres backend for the Home Assistant recorder.
#
# WHY THIS EXISTS
# The recorder's SQLite database is the single largest client writer in the
# cluster. Measured 2026-08-19 (cgroup io.stat on the home-assistant container,
# corroborated by kernel diskstats): 40 GB/day to its RBD volume, ~26% of all
# client writes, against only ~5 GB/day of actual row data. The gap is the write
# PATTERN, not the volume:
#
# ~30 state changes/sec (107k rows/hour, 2.57M/day)
# x2 SQLite WAL + checkpoint — every dirtied page is written twice
# x4 ext4 journal + commit, because synchronous=FULL fsyncs every commit
# (measured: 819 KB of 4 KiB writes -> 3.28 MB at the block device)
# = ~15 KB written per recorded state change
#
# Postgres replaces scattered fsync'd page rewrites with sequential, batched WAL.
# That is the point of the move — it attacks the multiplier rather than trimming
# what gets recorded, which would only hide the problem.
#
# STAGING
# Phase 1 (this manifest): the cluster comes up EMPTY and HA keeps using SQLite.
# Nothing depends on it, so a failure here is inert. Verify Ready, WAL archiving
# to Hetzner, and a successful base backup before going further.
# Phase 2: migrate the statistics tables and repoint recorder.db_url.
# See MIGRATION-recorder-postgres.md for the sequence and the rollback.
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: ha-pg
namespace: home-assistant
spec:
instances: 1
imageName: ghcr.io/cloudnative-pg/postgresql:16
storage:
storageClass: ceph-block
# Sized for the migrated statistics plus 7 days of states at the current
# rate: statistics 10.8M rows + statistics_short_term 1.9M (~1.8 GB with
# indexes in Postgres) and ~18M states rows for the purge_keep_days:7 window
# (~4 GB). 20Gi leaves room for autovacuum bloat and index churn.
# ceph-block has allowVolumeExpansion: true, so this can grow in place.
size: 20Gi
plugins:
- name: barman-cloud.cloudnative-pg.io
isWALArchiver: true
parameters:
barmanObjectName: ha-pg-store
bootstrap:
initdb:
# Explicit, not left to CNPG's `app` default: the generated ha-pg-app
# Secret and the -app Service point at whatever is named here, and the
# recorder db_url in phase 2 must match. Same trap that made the wp-pg
# namespace migration point credentials at an empty database (#433).
database: homeassistant
owner: homeassistant
postgresql:
parameters:
# 512MB, not the 256MB this shipped with. Measured after 15h on Postgres:
# buffers_backend was 629,704 of 939,025 total page writes — 67% written
# by backend processes rather than the checkpointer or bgwriter. That
# ratio means the working set does not fit in shared_buffers, so backends
# evict dirty pages themselves and the same pages are written out
# repeatedly instead of coalescing in memory.
#
# The recorder touches a wide set of pages continuously (~30 state
# changes/sec across states, state_attributes and five indexes), which is
# exactly the workload that punishes a small buffer pool.
#
# Memory headroom checked before raising: the instance was using 383Mi of
# a 1Gi limit with 256MB of that being shared_buffers, so the limit goes
# to 1.5Gi to keep the same margin.
shared_buffers: "512MB"
max_connections: "50"
# The rest of this block is tuned to MINIMISE WRITE VOLUME, which is the
# entire reason for the migration. Defaults would give a correct database
# that writes far more than it needs to on replicated Ceph storage.
#
# synchronous_commit off: the recorder is telemetry, not transactional
# record-keeping. This does NOT risk corruption — Postgres stays crash
# consistent; it only means the last few hundred ms of commits can be lost
# on an unclean shutdown, which for state history is a non-event. In
# exchange every commit stops forcing an fsync, which is precisely the
# cost that makes SQLite expensive here.
synchronous_commit: "off"
# Compress full-page images in WAL. Recorder writes are many small row
# inserts scattered across pages, so full-page writes after each
# checkpoint dominate WAL volume; compression cuts that directly.
#
# Full-page images really are ~62% of WAL here: 61.9% measured as a
# steady-state delta over one checkpoint cycle, against 62.4% lifetime.
#
# But switching pglz -> zstd changed NOTHING. Measured by
# pg_current_wal_lsn advance over 20min windows: 4911 and 4976 MB/day
# before, 5002 MB/day after.
#
# The reason is that `wal_compression: "on"` was never "no compression" --
# it is pglz, which was already compressing these page images. zstd only
# changes the ratio, and on recorder pages that margin is too small to
# measure. The original framing of this change (that the clusters "were
# not getting compression") was wrong; they were.
#
# Note the 62% figure is an upper bound: it is computed as
# wal_fpi * 8192 / wal_bytes, which assumes uncompressed 8 KiB pages. With
# compression active the true byte share is lower, which is consistent
# with there being little headroom left for a better algorithm.
#
# Kept because zstd is not worse than pglz and costs nothing. It is simply
# not a lever. The real one on this cluster was archive_timeout, below.
#
# zstd, not "on": the boolean spelling resolves to pglz, the original
# 2016-era algorithm, which is both slower and weaker than the two
# alternatives PostgreSQL 16 accepts here (lz4, zstd). zstd gives the
# best ratio on 8 KiB page images, and the CPU cost lands on a cluster
# that is IO-bound on QLC NVMe, not CPU-bound.
wal_compression: "zstd"
# A WAL segment is a fixed 16 MiB file, and archive_timeout forces a switch
# on a timer -- the segment is then written and archived at its full size
# no matter how little real WAL it holds. CNPG defaults to 5min, i.e. up
# to 288 segments/day = 4.6 GB/day that is largely padding.
#
# Verified on gf-pg first (the staged rollout): 211 -> 41 MB/hour, an 81%
# cut, with archived segments falling from ~12/hour to ~1.5/hour in step.
# Two independent signals moving together is what confirms the volume was
# padding rather than data.
#
# The cost is RPO: up to 30min of WAL is unrecoverable if this cluster and
# its volume are lost outright. This is a single-instance cluster, so the
# archive is the only off-node copy.
archive_timeout: "30min"
# Fewer checkpoints => fewer full-page writes, since a page is only
# fully-logged on its first modification after a checkpoint.
#
# That reasoning did not show up in WAL volume: 15min -> 30min moved the
# WAL rate not at all (4911/4976 -> 5002 MB/day). The likely reason is
# that this workload is append-mostly -- a page is dirtied while it fills
# and then not touched again, so its full-page image is paid once
# regardless of how often checkpoints run. Interval only helps pages that
# are re-dirtied across many cycles, which is not what a recorder does.
#
# Kept, but NOT because WAL improved. checkpoint_timeout also governs
# data-file writes at checkpoint, and those are unmeasured here: this
# volume takes ~11 GB/day against 4.9 GB/day of WAL, so most of its
# traffic is outside what the WAL measurement covers. Revisit with a
# data-file measurement before treating this value as justified.
#
# Recovery cost is bounded by how much WAL a single interval produces,
# not by the interval itself: at the measured rate 30min is well under
# 200MB of replay, i.e. seconds. max_wal_size stays 2GB precisely so the
# timeout stays the binding trigger -- if size were the binding one, the
# interval change would do nothing.
checkpoint_timeout: "30min"
max_wal_size: "2GB"
min_wal_size: "512MB"
# full_page_writes is deliberately LEFT ON (the default). RBD can tear a
# page across a 4 MiB object boundary, and turning this off would trade
# write volume for the risk of an unrecoverable database. Not worth it.
resources:
requests:
cpu: 200m
memory: 512Mi
limits:
memory: 1536Mi
@@ -0,0 +1,38 @@
---
# 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.
#
# The namespace allowlist on the openbao ClusterSecretStore
# (infrastructure/post-crds/openbao-clustersecretstore.yaml) must include this
# namespace or ESO silently never syncs (#505). home-assistant is already
# allowlisted — it has several ExternalSecrets in this directory already.
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: hetzner-s3-creds
namespace: home-assistant
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: openbao
target:
name: hetzner-s3-creds
dataFrom:
- extract:
key: seed/paperless/hetzner-creds
@@ -0,0 +1,35 @@
---
# Barman object store for ha-pg: base backups + continuous WAL archiving to
# Hetzner Object Storage, same target bucket as the other CNPG clusters.
#
# Its own prefix. Two clusters archiving WAL to one destinationPath interleave
# their streams and corrupt both — see the note in
# kubernetes/apps/woodpecker/objectstore.yaml, which learned that the hard way.
apiVersion: barmancloud.cnpg.io/v1
kind: ObjectStore
metadata:
name: ha-pg-store
namespace: home-assistant
spec:
configuration:
destinationPath: s3://renner-uno-k8s-cnpg/ha-pg
endpointURL: https://fsn1.your-objectstorage.com
s3Credentials:
accessKeyId:
name: hetzner-s3-creds
key: ACCESS_KEY_ID
secretAccessKey:
name: hetzner-s3-creds
key: ACCESS_SECRET_KEY
region:
name: hetzner-s3-creds
key: REGION
wal:
compression: gzip
data:
compression: gzip
# Matches the 30d retention the other CNPG clusters use and the
# renner-uno-k8s-cnpg bucket's noncurrent-version expiry in
# opentofu/object_storage.tf, so old S3 versions don't outlive the backups
# that superseded them.
retentionPolicy: "30d"
@@ -0,0 +1,23 @@
---
# Daily base backups for ha-pg. Continuous WAL archiving runs separately
# (ha-pg-cluster.yaml spec.plugins[].isWALArchiver); without periodic base
# backups the prefix would have no recovery point of its own, and
# ha-pg-store's retentionPolicy: 30d would have nothing to prune against, so the
# WAL chain would grow without bound.
#
# 02:50 UTC, offset from the other CNPG clusters (02:30, wp-pg at 02:40) so they
# do not all hit the Hetzner bucket at once.
apiVersion: postgresql.cnpg.io/v1
kind: ScheduledBackup
metadata:
name: ha-pg-daily
namespace: home-assistant
spec:
schedule: "0 50 2 * * *"
immediate: true
backupOwnerReference: self
cluster:
name: ha-pg
method: plugin
pluginConfiguration:
name: barman-cloud.cloudnative-pg.io
@@ -52,6 +52,14 @@ resources:
# CNP ingress rule.
- zigbee2mqtt-gate-service.yaml
- zigbee2mqtt-ingress.yaml
# Postgres backend for the recorder (MIGRATION-recorder-postgres.md).
# PHASE 1: this cluster comes up EMPTY and nothing consumes it — HA is still
# on SQLite. Deploying it is therefore inert; phase 2 migrates the statistics
# tables and repoints recorder.db_url in the homeassistant repo.
- ha-pg-hetzner-creds-externalsecret.yaml
- ha-pg-objectstore.yaml
- ha-pg-cluster.yaml
- ha-pg-scheduled-backup.yaml
configMapGenerator:
# nginx config for the reverse-proxy sidecar. Hash suffix disabled so
# the cm name stays stable; editing proxy.conf does not auto-roll the
@@ -79,5 +87,13 @@ configMapGenerator:
namespace: home-assistant
files:
- watch.sh=zigbee2mqtt-interview-watch.sh
# Z2M discovery guard sidecar script (gitea issue #381). Hash suffix disabled
# (name stays stable), so editing zigbee2mqtt-discovery-guard.sh does not
# auto-roll the pod — restart manually with
# `kubectl rollout restart deploy/zigbee2mqtt -n home-assistant`.
- name: zigbee2mqtt-discovery-guard-script
namespace: home-assistant
files:
- guard.sh=zigbee2mqtt-discovery-guard.sh
generatorOptions:
disableNameSuffixHash: true
@@ -373,8 +373,9 @@ spec:
# Broker user `zigbee2mqtt` — the Zigbee2MQTT bridge (ZHA replacement). Scoped
# to its own `zigbee2mqtt/#` subtree plus `write homeassistant/#` (so it can
# publish HA MQTT-discovery configs, exactly like the `shellies` user) and
# `read homeassistant/status` (Z2M re-publishes discovery when HA restarts —
# ACL in mosquitto-deployment.yaml). Own credential so its lifecycle is
# `read homeassistant/status` (HA's birth/will topic; on `online` Z2M
# republishes cached device states, NOT discovery configs — ACL and the full
# reasoning in mosquitto-deployment.yaml). Own credential so its lifecycle is
# independent of the others. The Z2M init container reads this password and
# renders it into /app/data/secret.yaml.
apiVersion: batch/v1
@@ -5,13 +5,31 @@ metadata:
namespace: home-assistant
data:
mosquitto.conf: |
# No on-disk persistence. Retained messages + sessions are re-populated by
# clients on reconnect (evcc republishes state, Shellies re-send their
# announce/online, HA re-publishes discovery), so the broker keeps no
# durable state. This drops the Ceph PVC dependency (the broker starts even
# if Ceph is degraded) and lets it reschedule anywhere in seconds — no RWO
# volume to fence/detach/reattach. Trade-off: a broker restart briefly
# empties retained state until clients reconnect (self-healing).
# No on-disk persistence, and no PVC of any kind for this broker. The
# broker keeps no durable state, so it starts even if Ceph is degraded and
# reschedules anywhere in seconds — no RWO volume to fence/detach/reattach.
#
# Most clients re-populate their retained state on reconnect: evcc
# republishes state, Shellies re-send their announce/online, HA re-publishes
# its own discovery.
#
# Zigbee2MQTT is the EXCEPTION and does not self-heal. Its HA discovery
# configs are published retained, and retention is the only mechanism it
# relies on to survive a broker restart — verified against Z2M 2.12.1
# source: discover() runs only from start(), the MQTT reconnect path
# (lib/mqtt.ts onConnect) republishes bridge/state and nothing else, and the
# homeassistant/status "online" handler (lib/extension/homeassistant.ts
# onMQTTMessage) republishes bridge/state + cached device states but never
# the discovery configs. There is no bridge/request endpoint that forces a
# rediscovery. Upstream has declined to add one (Koenkk/zigbee2mqtt #27458,
# #22977, #16950, all closed NOT_PLANNED).
#
# Consequence: a mosquitto restart that is not followed by a Zigbee2MQTT
# restart leaves every Z2M entity unavailable in HA until Z2M is restarted
# (`kubectl rollout restart deploy/zigbee2mqtt -n home-assistant`). This is
# a live operational hazard, tracked in gitea issue #381 — it caused a ~14 h
# outage of every Z2M entity on 2026-07-23. Enabling persistence is NOT the
# answer: this broker deliberately has no PVC (see above).
persistence false
log_dest stdout
log_type error
@@ -25,7 +25,13 @@ spec:
# a broker serving throughout an image bump / reschedule (clients on the old
# pod just reconnect to the new one); maxSurge:1 permits the brief two-pod
# overlap — two independent stateless brokers behind the LB, fine since there
# is no shared state and discovery self-heals.
# is no shared state.
#
# NOTE: connectivity survives the roll, but RETAINED STATE DOES NOT — the
# new pod starts with an empty retained store, so a roll is equivalent to a
# broker restart for every client that depends on retention. Zigbee2MQTT
# does not recover from that on its own; see mosquitto-config.yaml and
# gitea issue #381.
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
@@ -64,7 +70,9 @@ spec:
# Pinned tag, auto-bumped within the 2.x line by Flux image-automation
# (infrastructure/image-automation/mosquitto.yaml). The marker tells
# Flux where to write; patch/minor updates land as git commits and
# roll the broker (brief restart; discovery self-heals via HA).
# roll the broker. HA re-publishes its own discovery afterwards, but
# Zigbee2MQTT does not — an image bump therefore needs the same
# follow-up as any other broker restart (see mosquitto-config.yaml).
image: eclipse-mosquitto:2.0.22 # {"$imagepolicy": "flux-system:mosquitto"}
# The shim runs as root (image sets no USER) and prepares the
# auth files for the broker, which drops to user mosquitto
@@ -148,8 +156,16 @@ spec:
# Owns its own `zigbee2mqtt/#` subtree; may WRITE `homeassistant/#`
# to publish HA MQTT-discovery configs (write-only there, like the
# shellies user, to deny discovery-tree recon from a compromised
# bridge); and READs `homeassistant/status` so it re-publishes
# discovery when HA restarts (birth/will).
# bridge); and READs `homeassistant/status`, HA's birth/will topic.
#
# That read grant does NOT buy a discovery re-publish: on
# `online`, Z2M 2.12.1 republishes bridge/state and cached device
# states only (lib/extension/homeassistant.ts onMQTTMessage) —
# discover() runs solely from start(). The grant is still correct,
# because the cached-state republish is what makes HA show current
# values right after an HA restart. Restoring discovery after a
# BROKER restart is handled by the discovery-guard sidecar in
# zigbee2mqtt-deployment.yaml.
user zigbee2mqtt
topic readwrite zigbee2mqtt/#
topic write homeassistant/#
@@ -297,7 +313,9 @@ spec:
configMap:
name: mosquitto-config
- name: auth
emptyDir: {}
emptyDir:
medium: Memory
sizeLimit: 16Mi
- name: tls
secret:
secretName: mosquitto-tls
@@ -176,3 +176,38 @@ data:
friendly_name: Treppenhaus Haustür Schalter 1
'0x001788010b00d011':
friendly_name: Treppenhaus Haustür Schalter 2
# 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.
#
# Z2M reads this only at startup (requiresRestart), and this file is
# SEED-ONLY per the header — the running instance takes it from its PVC.
'0xc02cedfffe958f40':
friendly_name: Wetterstation
debounce: 2
@@ -158,7 +158,7 @@ spec:
# Pinned tag. No Flux image-automation marker yet (unlike mosquitto) —
# add an infrastructure/image-automation/zigbee2mqtt.yaml policy as a
# follow-up if desired; pinned-and-manual is fine for the initial cutover.
image: ghcr.io/koenkk/zigbee2mqtt:2.12.1
image: ghcr.io/koenkk/zigbee2mqtt:2.13.0
imagePullPolicy: IfNotPresent
env:
- name: TZ
@@ -272,6 +272,64 @@ spec:
memory: 16Mi
limits:
memory: 64Mi
# Re-runs Zigbee2MQTT's HA discovery after the broker loses its retained
# store. mosquitto runs `persistence false` with no PVC, so every broker
# restart wipes the retained discovery configs, and nothing on the Z2M
# side restores them — not reconnect, not HA's birth message, and there
# is no rediscovery endpoint. Without this, a broker restart leaves every
# Z2M entity unavailable in HA until someone restarts Z2M by hand (gitea
# issue #381; ~14 h outage on 2026-07-23). The full reasoning, including
# why bridge/groups is the sentinel and why the guard is tuned for
# sensitivity, is in the script header.
- name: discovery-guard
# Same image and rationale as interview-watch above: reused purely for
# its bundled mosquitto_sub/mosquitto_pub, nothing installed at
# runtime. Verified present in this exact image: sh, mosquitto_sub
# (incl. -C and -W), mosquitto_pub, date -u +%s, mkdir, cat.
# Deliberately NOT carrying a Flux $imagepolicy marker, so it does not
# drag the broker's image-automation policy along with it.
image: eclipse-mosquitto:2.0.22
imagePullPolicy: IfNotPresent
command: [sh, /script/guard.sh]
env:
# Reuses the Z2M broker credential: its ACL is `readwrite
# zigbee2mqtt/#`, which covers reading bridge/state/bridge/groups and
# writing bridge/request/restart. No new broker user needed.
- name: MQTT_PASSWORD
valueFrom:
secretKeyRef:
name: mosquitto-zigbee2mqtt-credentials
key: password
# Must track zigbee2mqtt-config.yaml, same as interview-watch.
- name: MQTT_HOST
value: mosquitto
- name: MQTT_USER
value: zigbee2mqtt
- name: BASE_TOPIC
value: zigbee2mqtt
- name: STATEDIR
value: /state
securityContext:
runAsNonRoot: true
runAsUser: 1883
runAsGroup: 1883
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
volumeMounts:
- name: discovery-guard-script
mountPath: /script
readOnly: true
# Restart cooldown stamp. Its own emptyDir rather than the image's
# /tmp, so it stays writable as non-root.
- name: discovery-guard-state
mountPath: /state
resources:
requests:
cpu: 10m
memory: 16Mi
limits:
memory: 64Mi
volumes:
- name: data
persistentVolumeClaim:
@@ -286,4 +344,13 @@ spec:
configMap:
name: zigbee2mqtt-interview-watch-script
- name: interview-watch-state
emptyDir: {}
emptyDir:
medium: Memory
sizeLimit: 16Mi
- name: discovery-guard-script
configMap:
name: zigbee2mqtt-discovery-guard-script
- name: discovery-guard-state
emptyDir:
medium: Memory
sizeLimit: 16Mi
@@ -0,0 +1,162 @@
#!/bin/sh
# Restores Home Assistant MQTT discovery after the broker loses its retained
# store, which mosquitto does on every restart because it runs `persistence
# false` and has no PVC (a deliberate, settled decision — see
# mosquitto-config.yaml).
#
# WHY THIS EXISTS
# Zigbee2MQTT publishes its HA discovery configs retained and relies on broker
# retention alone to survive a broker restart. Verified against Z2M 2.12.1
# source, nothing on the Z2M side restores them:
# - discover() runs only from HomeAssistant.start().
# - lib/mqtt.ts onConnect() republishes bridge/state and resubscribes; the
# republishRetainedTimer is armed only inside connect() and never re-armed
# on reconnect.
# - The homeassistant/status "online" handler republishes bridge/state plus
# cached device states, never the discovery configs.
# - There is no bridge/request endpoint that forces a rediscovery; upstream
# declined to add one (Koenkk/zigbee2mqtt #27458, #22977, #16950).
# So a broker restart not followed by a Z2M restart leaves every Z2M entity
# unavailable in HA indefinitely. That cost ~14 h of dead entities on
# 2026-07-23 (gitea issue #381).
#
# DETECTION — why bridge/groups
# bridge/state and the four bridge/{info,devices,groups,definitions} topics are
# all retained, but only bridge/state is republished on reconnect (mqtt.ts
# onConnect). So "bridge/state present + one of the others absent" means: Z2M is
# talking to this broker right now, but nothing has re-run start() since the
# retained store was wiped. That is exactly the broken state.
#
# The absent-half partner must not be re-publishable by ordinary runtime
# events, or it self-heals and the guard goes blind while HA is still broken:
# bridge/devices — republished on announce/join/leave/interview. Worst.
# bridge/definitions — republished on devicesChanged and OTA.
# bridge/info — republished on devicesChanged, which reaches inbound
# radio traffic via the deviceExposesChanged callback
# handed to every fromZigbee converter (receive.ts).
# bridge/health — self-heals on its own 10 min timer. Useless here.
# bridge/groups — only re-published by deliberate admin actions: group
# membership edits, group add/remove/rename, and scene
# changes (which come solely from an explicit
# <device>/set scene command, never an inbound report).
# Hence bridge/groups. It is also safe with no groups defined: publishGroups()
# always publishes, emitting `[]`, which is still a non-empty retained payload.
#
# DELIBERATELY NOT AND-ed with bridge/info being absent. That would cut false
# positives but introduce false negatives, and the costs are wildly asymmetric:
# a false positive is one ~60 s Z2M restart, a false negative is every Zigbee
# entity dead until a human notices. This guard is tuned for sensitivity.
#
# Two known ways to get a spurious positive, both handled by CONFIRM_DELAY:
# - Startup race. bridge/state is published at mqtt.connect() but
# bridge/groups only at the end of BridgeExtension.start(), so the signature
# is legitimately true for a few seconds during every normal Z2M boot.
# - QoS 0. The bridge/* topics are published with no qos option, so they go
# out at-most-once and a publish can in principle be dropped.
#
# ACTION
# Publishes bridge/request/restart, which is a full in-process restart: index.js
# restart() -> stop(true) tears down every extension, then start() builds a new
# Controller and re-runs HomeAssistant.start(), hence discover(). It is NOT a
# herdsman-only restart. discover() republishes only configs that are missing or
# whose payload changed, which after a wipe is all of them.
#
# Credentials: reuses the existing zigbee2mqtt broker user, whose ACL is
# `readwrite zigbee2mqtt/#` — enough to read bridge/state and bridge/groups and
# to write bridge/request/restart. No new broker user, and no read grant on
# homeassistant/# (checking for the discovery configs directly would have needed
# one, which the ACL denies on purpose).
set -eu
MQTT_HOST="${MQTT_HOST:-mosquitto}"
MQTT_PORT="${MQTT_PORT:-1883}"
MQTT_USER="${MQTT_USER:-zigbee2mqtt}"
BASE_TOPIC="${BASE_TOPIC:-zigbee2mqtt}"
# Poll cadence. Worst-case time-to-repair is CHECK_INTERVAL + CONFIRM_DELAY.
CHECK_INTERVAL="${CHECK_INTERVAL:-300}"
# Re-check gap before acting, to ride out a normal Z2M boot and a dropped QoS 0
# publish. Must comfortably exceed a Z2M cold start.
CONFIRM_DELAY="${CONFIRM_DELAY:-90}"
# Floor between two restarts, so a Z2M that will not come back healthy is not
# restarted in a loop.
COOLDOWN="${COOLDOWN:-1800}"
# How long to wait for a retained message to land before calling it absent.
READ_TIMEOUT="${READ_TIMEOUT:-5}"
# Hard ceiling on a single client invocation, as a backstop to the protocol-level
# waits below. Every MQTT call here is bounded with `timeout`, because neither
# client bounds a TCP connect that HANGS rather than being refused — and a stalled
# connect is most likely precisely when this guard runs, around a broker restart.
# Without it the loop would block forever and stop logging, which is exactly the
# silent-failure mode the guard exists to prevent. Same reasoning and the same
# PUB_TIMEOUT default as zigbee2mqtt-interview-watch.sh.
#
# NOT `mosquitto_pub -W`: that flag does not exist for mosquitto_pub (it is a
# mosquitto_sub option) and makes every publish fail on a usage error.
# mosquitto_sub keeps its -W, which bounds the wait for a message once CONNECTED;
# the `timeout` wrapper is what covers the connect phase, so it is set above -W.
PUB_TIMEOUT="${PUB_TIMEOUT:-10}"
STATEDIR="${STATEDIR:-/state}"
STAMP="$STATEDIR/last-restart"
log() { echo "[$(date -u '+%Y-%m-%dT%H:%M:%SZ')] discovery-guard: $*"; }
# Echoes the retained payload of $1, or nothing if the topic holds none.
# A retained message is delivered immediately on subscribe, so the wait is only
# ever paid when the topic is genuinely empty.
retained() {
timeout "$((READ_TIMEOUT + 5))" mosquitto_sub -h "$MQTT_HOST" -p "$MQTT_PORT" \
-u "$MQTT_USER" -P "$MQTT_PASSWORD" \
-t "$1" -C 1 -W "$READ_TIMEOUT" 2>/dev/null || true
}
# True when Z2M is connected to this broker but has not re-run start() since the
# retained store was last wiped.
discovery_lost() {
case "$(retained "$BASE_TOPIC/bridge/state")" in
*online*) ;;
*) return 1 ;;
esac
[ -z "$(retained "$BASE_TOPIC/bridge/groups")" ]
}
in_cooldown() {
[ -f "$STAMP" ] || return 1
last=$(cat "$STAMP" 2>/dev/null || echo 0)
now=$(date -u +%s)
[ $((now - last)) -lt "$COOLDOWN" ]
}
mkdir -p "$STATEDIR"
log "watching $BASE_TOPIC/bridge/groups on $MQTT_HOST:$MQTT_PORT" \
"(interval ${CHECK_INTERVAL}s, confirm ${CONFIRM_DELAY}s, cooldown ${COOLDOWN}s)"
while true; do
sleep "$CHECK_INTERVAL"
discovery_lost || continue
log "bridge/groups missing while Z2M is online — retained store looks wiped;" \
"re-checking in ${CONFIRM_DELAY}s before acting"
sleep "$CONFIRM_DELAY"
if ! discovery_lost; then
log "recovered on its own, no action taken"
continue
fi
if in_cooldown; then
log "confirmed, but a restart was requested less than ${COOLDOWN}s ago —" \
"skipping. Z2M may be failing to come up; check its logs."
continue
fi
log "confirmed — requesting a Zigbee2MQTT restart to re-run discovery"
if timeout "$PUB_TIMEOUT" mosquitto_pub -h "$MQTT_HOST" -p "$MQTT_PORT" \
-u "$MQTT_USER" -P "$MQTT_PASSWORD" \
-t "$BASE_TOPIC/bridge/request/restart" -m '' 2>/dev/null; then
date -u +%s > "$STAMP"
log "restart requested"
else
log "failed to publish the restart request; will retry next cycle"
fi
done
@@ -0,0 +1,149 @@
# 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.
+37 -1
View File
@@ -42,7 +42,43 @@ spec:
parameters:
shared_buffers: "512MB"
max_wal_size: "2GB"
wal_compression: "on"
# zstd, not "on": the boolean spelling resolves to pglz (2016-era, weaker
# and slower than the lz4/zstd this build also accepts). Compresses the
# full-page images that dominate WAL volume after each checkpoint.
wal_compression: "zstd"
# A WAL segment is a fixed 16 MiB file, and archive_timeout forces a switch
# on a timer -- the segment is then written and archived at its full size
# no matter how little real WAL it holds. CNPG defaults to 5min, i.e. up
# to 288 segments/day = 4.6 GB/day that is largely padding.
#
# Verified on gf-pg first (the staged rollout): 211 -> 41 MB/hour, an 81%
# cut, with archived segments falling from ~12/hour to ~1.5/hour in step.
# Two independent signals moving together is what confirms the volume was
# padding rather than data.
#
# The cost is RPO: up to 30min of WAL is unrecoverable if this cluster and
# its volume are lost outright. This is a single-instance cluster, so the
# archive is the only off-node copy.
archive_timeout: "30min"
# Write-reduction tuning. These databases are largely idle, and the
# Postgres defaults favour throughput over storage that wears out.
#
# checkpoint_timeout is the lever. The 5min default pushes every dirty
# page to disk 288 times a day, so a small database whose same few pages
# are re-dirtied constantly (sessions, tokens, job state) gets rewritten
# over and over. Measured on ak-pg: 25.8M checkpoint buffers flushed
# (~206 GB) against a 1046 MB database -- the whole thing roughly twice a
# day. At 15min those pages coalesce and are written a third as often.
#
# max_wal_size must rise with it, or checkpoints trigger on size instead
# and the timeout change does nothing. It also bounds crash recovery to
# at most 2GB of WAL replay.
#
# NOT changed: synchronous_commit stays at the default `on`. It is off for
# ha-pg because a state recorder is telemetry; these hold credentials,
# documents and job state, where losing the last few hundred ms of commits
# on an unclean shutdown is not an acceptable trade.
checkpoint_timeout: "15min"
resources:
requests:
cpu: 500m
+1 -1
View File
@@ -27,7 +27,7 @@ spec:
# Pinned to 7.4-alpine to match the LXC 225 compose exactly.
# Any redis major-version bump is a separate migration.
- name: redis
image: redis:7.4.10-alpine # {"$imagepolicy": "flux-system:redis-immich"}
image: redis:7.4.11-alpine # {"$imagepolicy": "flux-system:redis-immich"}
args:
- redis-server
- --save
+3 -1
View File
@@ -147,7 +147,9 @@ spec:
- key: immich-config.json
path: immich-config.json
- name: le-ca
emptyDir: {}
emptyDir:
medium: Memory
sizeLimit: 16Mi
---
apiVersion: v1
kind: Service
@@ -99,3 +99,26 @@ spec:
interval: 30s
podTargetLabels:
- role
---
# ha-pg — Postgres backend for the Home Assistant recorder
# (kubernetes/apps/home-assistant/ha-pg-cluster.yaml). Added with the cluster
# rather than after it, so the migration has metrics from the first moment
# instead of discovering the gap later, which is how wp-pg's scrape hole was
# found (#433).
apiVersion: monitoring.coreos.com/v1
kind: PodMonitor
metadata:
name: cnpg-ha-pg
namespace: home-assistant
labels:
app.kubernetes.io/name: cloudnative-pg
release: kube-prometheus-stack
spec:
selector:
matchLabels:
cnpg.io/cluster: ha-pg
podMetricsEndpoints:
- port: metrics
interval: 30s
podTargetLabels:
- role
@@ -8,11 +8,16 @@ resources:
- prometheusrule-cilium.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-velero.yaml
- prometheusrule-woodpecker-rotation.yaml
# CNPG backup health. Seven Postgres clusters ship to Hetzner and nothing
# alerted if that stopped — HetznerBackupEndpointDown only probes the S3
# endpoint, which proves reachability, not that anything was written.
- prometheusrule-cnpg-backup.yaml
- alloy-servicemonitor.yaml
- cnpg-podmonitors.yaml
- home-assistant-servicemonitor.yaml
@@ -12,18 +12,59 @@ spec:
- name: ceph.rules
rules:
- alert: CephHealthWarn
# ceph_health_status: 0 = HEALTH_OK, 1 = HEALTH_WARN, 2 = HEALTH_ERR.
# 6h is deliberately long. Ceph warns for self-clearing conditions —
# a slow BlueStore op during the Sunday restore sweep, a mon briefly
# below its disk threshold — and paging on those trains people to
# ignore the alert. What matters is a warning that does not clear.
expr: ceph_health_status == 1
# Per-check rather than ceph_health_status, so one permanently-active
# check cannot mask every other Ceph warning.
#
# AUTH_INSECURE_* is excluded: it cannot clear until the nodes run a
# kernel supporting aes256k over the CSI mount path (#527).
#
# POOL_NO_REDUNDANCY is in scope. An unreplicated pool makes
# `ceph osd ok-to-stop` refuse for every OSD, so it reports blocked
# maintenance and not just data risk — do not exclude it for a pool
# that is unreplicated deliberately.
#
# 6h: Ceph warns for self-clearing conditions. Only a warning that
# does not clear is worth paging on.
#
# BLUESTORE_SLOW_OP_ALERT breaks that premise and is still NOT
# excluded (#557). Ceph holds the check for
# 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.
#
# 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.
#
# 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).
expr: ceph_health_detail{name!~"AUTH_INSECURE_.+"} == 1
for: 6h
labels:
severity: warning
annotations:
summary: "Ceph has been HEALTH_WARN for 6h"
description: "The Ceph cluster has held HEALTH_WARN for six hours, so it is not a self-clearing blip. Get the detail with `kubectl -n rook-ceph exec deploy/rook-ceph-tools -- ceph health detail`."
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`."
- alert: CephPoolLostRedundancy
# Names the pool, which POOL_NO_REDUNDANCY does not.
#
# There is no pool-size metric, but bytes_used/stored IS the
# replication factor. The >1e6 floor drops pools too small to measure
# meaningfully, empty ones included.
#
# No pool is exempt.
expr: |
ceph_pool_bytes_used / ceph_pool_stored < 1.5 and ceph_pool_stored > 1e6
for: 30m
labels:
severity: critical
annotations:
summary: "Ceph pool {{ $labels.pool_id }} is running unreplicated"
description: "Pool {{ $labels.pool_id }} has a replication factor below 1.5, meaning a single OSD loss destroys data. No pool is meant to be unreplicated — an unreplicated pool also blocks ALL OSD maintenance cluster-wide, since `ceph osd ok-to-stop` refuses while its PGs would go offline. Check with `kubectl -n rook-ceph exec deploy/rook-ceph-tools -- ceph osd pool ls detail`."
- alert: CephHealthError
# HEALTH_ERR means data availability or redundancy is at risk.
@@ -0,0 +1,78 @@
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: cnpg-backup
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: cnpg-backup.rules
rules:
# WHY THIS FILE EXISTS
# Seven Postgres clusters back up to Hetzner and NOTHING alerted if that
# stopped. The only related rule was HetznerBackupEndpointDown, which
# probes the S3 endpoint — it proves the bucket is reachable, not that
# anything was written to it. Same failure shape as the WordPress backup
# that failed nightly for three days against a bucket that was declared
# in tofu but never applied: the status surface looked fine because
# nothing checked the thing that mattered.
- alert: CNPGWalArchiveBacklog
# `ready` counts WAL segments sitting in pg_wal/archive_status waiting
# to be archived. Steady state is 0 across every cluster (verified
# 2026-08-19). A sustained backlog means the archiver is failing:
# point-in-time recovery is silently broken from that moment, and
# pg_wal keeps growing until it fills the PVC and takes Postgres down.
#
# This is the load-bearing alert of the two. It depends only on the
# instance-level collector, so unlike the backup-age metrics below it
# is unaffected by the missing Backup CRs (#522).
#
# Threshold: >5 segments sustained for 30m. Brief non-zero values are
# normal under write bursts — a segment is 16 MiB, so 5 is roughly
# 80 MiB of un-archived WAL, well below anything that threatens the
# volume but far above idle noise.
expr: cnpg_collector_pg_wal_archive_status{value="ready"} > 5
for: 30m
labels:
severity: warning
annotations:
summary: "CNPG WAL archiving is falling behind on {{ $labels.namespace }}/{{ $labels.pod }}"
description: "{{ $value }} WAL segments have been waiting to archive for over 30 minutes. Point-in-time recovery is broken for anything after the last archived segment, and pg_wal will grow until the PVC fills. Check the barman-cloud sidecar logs and the ObjectStore credentials: `kubectl -n {{ $labels.namespace }} logs {{ $labels.pod }} -c plugin-barman-cloud`."
- alert: CNPGBackupFailed
# cnpg_collector_last_failed_backup_timestamp is a unix timestamp, 0
# when no failure is recorded. Guard on > 0 so the epoch-zero default
# is not read as "failed in 1970".
expr: |
cnpg_collector_last_failed_backup_timestamp > 0
and
(time() - cnpg_collector_last_failed_backup_timestamp) < 86400
for: 5m
labels:
severity: warning
annotations:
summary: "CNPG backup failed in the last 24h on {{ $labels.namespace }}/{{ $labels.pod }}"
description: "A base backup for {{ $labels.namespace }}/{{ $labels.pod }} failed within the last 24 hours. Verify what is actually in the bucket rather than trusting CR status — see the barman-cloud-backup-list recipe in kubernetes/apps/home-assistant/MIGRATION-recorder-postgres.md."
# DELIBERATELY ABSENT: a backup-age alert, which is the one you would
# most want here.
#
# The natural expression is
# (time() - cnpg_collector_last_available_backup_timestamp) > 48*3600
# and it CANNOT be used today: that metric reads exactly 0 on every
# instance in the cluster (verified 2026-08-19 across gf-pg, ak-pg-1/2,
# pprl-pg-2/3, im-pg, wp-pg, ha-pg). CNPG has no record of any
# successful backup anywhere, because the Backup CRs it derives that
# from are being deleted within a minute of completing — see #522.
#
# Backups themselves DO work; that was verified independently by
# listing the bucket with barman-cloud-backup-list and restoring a WAL
# segment back out of S3. The gap is in the record, not the data.
#
# Adding the rule now would fire permanently for all seven clusters and
# be silenced within a day, which is worse than not having it. Add it as
# part of resolving #522, once the metric reflects reality.
@@ -0,0 +1,51 @@
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: mariadb-backup
namespace: monitoring
labels:
# kube-prometheus-stack's Prometheus selects on this label.
release: kube-prometheus-stack
spec:
groups:
- name: mariadb-backup.rules
rules:
- alert: WordpressMariadbBackupStale
# The nightly 02:45 logical dump (kubernetes/apps/wordpress/mariadb.yaml)
# is the only transaction-consistent copy of www.renner.uno's database
# — Velero snapshots the PVC, which for a running DB is not a restore
# point you would trust.
#
# KubeJobFailed already covers a failed Job, but only while the Job
# object survives. On #550 it cleared when the failed Job was
# garbage-collected, not when a backup succeeded — so a failure that
# is GC'd before anyone looks leaves no signal at all.
# lastSuccessfulTime lives on the CronJob and outlives Job GC.
#
# 26h = one missed cycle plus 2h slack, matching NetbirdVpsBackupStale.
# It counts any Job owned by the CronJob, so a manual
# `kubectl create job --from=cronjob/...` re-run clears it too.
expr: |
time() - kube_cronjob_status_last_successful_time{namespace="wordpress", cronjob="wordpress-mariadb-backup"} > 26 * 3600
for: 30m
labels:
severity: warning
annotations:
summary: "WordPress MariaDB backup has not succeeded in 26h+"
description: "The wordpress-mariadb-backup CronJob's last successful run is over 26 hours old. Check the newest Job's pod logs in the wordpress namespace: `kubectl -n wordpress get jobs`. The 2026-08-19 failure was `The specified bucket does not exist.` against renner-uno-k8s-mariadb while the endpoint itself probed healthy, so a green HetznerBackupEndpointDown does not clear this."
- alert: WordpressMariadbBackupNeverRan
# The staleness expression above goes blind when the metric is
# absent, which is what happens once the operator recreates the
# CronJob. Without this guard "never backed up" is quieter than
# "backed up late".
expr: |
kube_cronjob_created{namespace="wordpress", cronjob="wordpress-mariadb-backup"}
unless
kube_cronjob_status_last_successful_time{namespace="wordpress", cronjob="wordpress-mariadb-backup"}
for: 6h
labels:
severity: warning
annotations:
summary: "WordPress MariaDB backup has no recorded successful run"
description: "The wordpress-mariadb-backup CronJob exists but has no lastSuccessfulTime: either it has not completed once since the Backup CR was recreated, or every run since has failed. Check `kubectl -n wordpress get jobs` and that the hetzner-s3-creds ExternalSecret is synced."
@@ -12,37 +12,37 @@ spec:
groups:
- name: woodpecker-rotation.rules
# Convergence safety net for the automated WOODPECKER_AGENT_SECRET rotation
# (kubernetes/apps/woodpecker/rotate-agent-secret.yaml). That rotation Job is
# (kubernetes/infrastructure/openbao/rotation/woodpecker.yaml). That rotation Job is
# fire-and-forget: it writes OpenBao and exits WITHOUT verifying ESO re-synced
# or that both consumers restarted healthy. These alerts close that gap. ESO
# metrics are not scraped in this cluster, so everything here is built on
# kube-state-metrics. The CronJob/Job series stay empty until the rotation
# feature (branch feat/woodpecker-secret-auto-rotation) is merged — harmless.
# kube-state-metrics. The rotation has since landed: the CronJob lives in the
# openbao namespace, not apps, which is where the last two rules look.
rules:
- alert: WoodpeckerServerNotReady
# 0 ready replicas well past the 10m ESO refresh + Reloader restart cycle.
expr: kube_statefulset_status_replicas_ready{namespace="apps",statefulset="woodpecker-server"} == 0
expr: kube_deployment_status_replicas_ready{namespace="woodpecker",deployment="woodpecker-server"} == 0
for: 20m
labels:
severity: critical
annotations:
summary: "Woodpecker CI server has no ready replica"
description: >-
StatefulSet woodpecker-server (ns apps) has had 0 ready replicas for 20m;
all CI is down (agents cannot reach woodpecker.apps.svc:9000). If this
followed a secret rotation, the server pod failed to return after its
Reloader-triggered restart — check `kubectl -n apps describe pod
woodpecker-server-0` (PVC attach / crashloop) and the woodpecker-secret
ExternalSecret sync status.
Deployment woodpecker-server (ns woodpecker) has had 0 ready replicas
for 20m; all CI is down (agents cannot reach
woodpecker.woodpecker.svc:9000). If this followed a secret rotation, the
server pod failed to return after its Reloader-triggered restart — check
`kubectl -n woodpecker describe deploy woodpecker-server` and the
woodpecker-secret ExternalSecret sync status.
- alert: WoodpeckerAgentNotReady
expr: kube_deployment_status_replicas_available{namespace="apps",deployment="woodpecker-agent"} == 0
expr: kube_deployment_status_replicas_available{namespace="woodpecker",deployment="woodpecker-agent"} == 0
for: 20m
labels:
severity: warning
annotations:
summary: "Woodpecker CI agent has no available replica"
description: >-
Deployment woodpecker-agent (ns apps) has had 0 available replicas for
Deployment woodpecker-agent (ns woodpecker) has had 0 available replicas for
20m; no pipelines will run. If this followed a secret rotation, the agent
did not return after its Reloader restart, or its WOODPECKER_AGENT_SECRET
no longer matches the server's — check the agent pod logs for a gRPC auth
@@ -50,29 +50,29 @@ spec:
- alert: WoodpeckerSecretRotationJobFailed
# Named, clearer signal than the generic KubeJobFailed for the OpenBao-write
# step. Fires while a failed rotation Job is retained (ttl 3d).
expr: kube_job_status_failed{namespace="apps",job_name=~"woodpecker-secret-rotate.*"} > 0
expr: kube_job_status_failed{namespace="openbao",job_name=~"woodpecker-secret-rotate.*"} > 0
for: 15m
labels:
severity: warning
annotations:
summary: "Woodpecker agent-secret rotation Job failed"
description: >-
A woodpecker-secret-rotate Job (ns apps) failed at the OpenBao-write step;
A woodpecker-secret-rotate Job (ns openbao) failed at the OpenBao-write step;
the agent secret was NOT rotated. Most common cause: the one-time OpenBao
`rotator` role/policy bootstrap was never applied (see
kubernetes/infrastructure/openbao/policies/rotator.hcl). Check
`kubectl -n apps logs job/<name>`.
`kubectl -n openbao logs job/<name>`.
- alert: WoodpeckerSecretRotationStale
# Quarterly cadence (~91d) + grace. The series is ABSENT until the first
# successful run, so this cannot false-fire before rotation has ever worked.
expr: (time() - kube_cronjob_status_last_successful_time{namespace="apps",cronjob="woodpecker-secret-rotate"}) > 100 * 24 * 3600
expr: (time() - kube_cronjob_status_last_successful_time{namespace="openbao",cronjob="woodpecker-secret-rotate"}) > 100 * 24 * 3600
for: 1h
labels:
severity: warning
annotations:
summary: "Woodpecker agent secret has not rotated in >100 days"
description: >-
The quarterly woodpecker-secret-rotate CronJob (ns apps) has not completed
The quarterly woodpecker-secret-rotate CronJob (ns openbao) has not completed
successfully in over 100 days — rotation has silently stopped (CronJob
suspended/deleted, or every run failing). Investigate the CronJob and its
recent Jobs.
@@ -402,6 +402,52 @@ spec:
limits:
memory: 256Mi
# Drop the apiserver's high-cardinality latency histograms.
#
# Measured 2026-08-20: Prometheus held 298,236 active series, of which
# ~108,000 — over a third — were apiserver/etcd `_bucket` histograms. Each
# histogram fans out to one series per bucket x verb x resource x scope, so
# a single metric like apiserver_request_duration_seconds_bucket was 23,810
# series on its own. For comparison, every Home Assistant metric combined is
# ~4,100.
#
# That cardinality is the dominant driver of Prometheus write volume
# (~11 GB/day of TSDB + WAL, doubled again by Ceph replication), and none of
# it is observable value here: per-bucket apiserver latency quantiles are for
# clusters with SLAs and dozens of nodes, not three.
#
# ONLY metrics with ZERO rule dependencies are dropped. Verified against the
# live rule set before writing this — each metric checked individually for
# references in alert and recording rules:
#
# apiserver_request_duration_seconds_bucket rules=0 23,810 series
# apiserver_request_body_size_bytes_bucket rules=0 23,200
# etcd_request_duration_seconds_bucket rules=0 15,840
# apiserver_watch_list_duration_seconds_bucket rules=0 11,538
# apiserver_watch_cache_read_wait_seconds_bucket rules=0 8,232
# apiserver_response_sizes_bucket rules=0 7,592
# apiserver_watch_events_sizes_bucket rules=0 5,220
#
# apiserver_request_sli_duration_seconds_bucket is DELIBERATELY KEPT: 23
# recording rules depend on it, including the apiserver availability SLO and
# the KubeAPIErrorBudgetBurn burn-rate series. Dropping it would silently
# break that alerting, which is the failure mode this comment exists to
# prevent if someone later "tidies up" this regex.
#
# Only the `_bucket` series are dropped. The matching `_count` and `_sum`
# remain, so request rates and mean latencies still work — what is lost is
# per-bucket quantiles.
#
# etcd_* appears here even though kubeEtcd is disabled below: these are the
# apiserver's own etcd-client metrics, exposed on the apiserver endpoint, not
# a separate etcd scrape.
kubeApiServer:
serviceMonitor:
metricRelabelings:
- action: drop
sourceLabels: [__name__]
regex: (apiserver_request_duration_seconds_bucket|apiserver_request_body_size_bytes_bucket|etcd_request_duration_seconds_bucket|apiserver_watch_list_duration_seconds_bucket|apiserver_watch_cache_read_wait_seconds_bucket|apiserver_response_sizes_bucket|apiserver_watch_events_sizes_bucket)
# Disable etcd/scheduler/controller-manager scraping (Talos doesn't expose them by default)
kubeEtcd:
enabled: false
@@ -64,7 +64,9 @@ spec:
secret:
secretName: pve-exporter-config
- name: tmp
emptyDir: {}
emptyDir:
medium: Memory
sizeLimit: 16Mi
---
apiVersion: v1
kind: Service
@@ -0,0 +1,97 @@
# paperless-data: CephFS → RBD
## Why
`paperless-data` is `ReadWriteMany` on `ceph-filesystem`, but **only the paperless
Deployment mounts it**. Verified 2026-08-20:
```
$ pods mounting paperless-data
paperless-68cb8b8744-7hjb8 <- one, and only one
paperless-data ReadWriteMany ceph-filesystem
paperless-media ReadWriteMany ceph-filesystem <- genuinely shared (ftp-scanner)
paperless-consume ReadWriteMany ceph-filesystem <- genuinely shared (ftp-scanner)
```
Its contents are single-writer state — the 50 MB Whoosh index,
`classification_model.pickle`, `celerybeat-schedule.db`, `log/`,
`migration_lock`, `protonmail.pem`. 75 files. Nothing else reads or writes them.
It is on CephFS by default, not by requirement.
## What it costs
Every access goes through the MDS. Measured with **zero CI pipelines running**:
```
paperless-data client request_load_avg 22 (all other sessions 0-1)
MDS over 180s 4,052 client requests = 22.5/s
2,718 journal events = 15/s
2 dir commits <- almost none: not file writes
```
So it is cap/inode churn from repeated access, not data being written. That is
most of the constant **7-8 GB/day** written to `cephfs-metadata`, and since that
pool is `size: 3`, roughly **21-24 GB/day** reaches the drives.
`cephfs-metadata` deliberately stays at size 3. Losing it destroys the entire
filesystem rather than the objects on one OSD, and the 275 GiB in `cephfs-data0`
depends on it. Cutting the writes at source is correct; cutting the replication
is not.
## Sequence
Staged deliberately: the PVC lands first and empty, so merging it changes
nothing. The copy and the repoint are separate.
### Phase 1 — provision (this PR)
`paperless-data-block` (RWO, `ceph-block`, 10Gi) is created and mounted by
nothing. Inert.
Verify: `kubectl -n paperless get pvc paperless-data-block` → Bound.
### Phase 2 — copy
The Whoosh index must come across intact. Paperless *can* rebuild it
(`document_index reindex`), but that reprocesses every document, so copying is
both faster and less disruptive.
1. Scale paperless to 0. The source must be quiescent — `celerybeat-schedule.db`
and the Whoosh index are both live-written, and copying them under an active
writer gives a torn read.
2. Run a one-off pod that mounts BOTH PVCs and copies:
```
cp -a /src/. /dst/
```
The RWO target pins the pod to one node; the RWX source will follow.
3. Verify file count and index size match:
```
find /src -type f | wc -l == find /dst -type f | wc -l
du -sh /src/index == du -sh /dst/index
```
### Phase 3 — repoint
Change the Deployment's `data` volume to `claimName: paperless-data-block`,
merge, let it roll.
Verify afterwards:
- paperless starts, search returns results (proves the Whoosh index survived)
- `ceph tell mds.cephfs-b session ls` no longer shows a busy `paperless-data`
client
- `rate(ceph_pool_wr_bytes{pool_id="3"})` drops from its 7-8 GB/day baseline
**Do not delete `paperless-data` until search has been confirmed working.** It is
the rollback, and it is `prune: disabled` so Flux will not remove it.
## Expected effect
~7-8 GB/day off `cephfs-metadata`, so ~21-24 GB/day off the drives once
replication is counted. Against ~432 GB/day cluster-wide that is roughly 5% —
worth doing, but it is a correctness fix (a single-writer volume does not belong
on a shared filesystem) more than a large win.
It also removes 22 req/sec of MDS load, which benefits the workloads that
genuinely need CephFS — Woodpecker CI workspaces share the same MDS.
+36
View File
@@ -34,6 +34,42 @@ spec:
parameters:
shared_buffers: "256MB"
max_connections: "200"
# Write-reduction tuning. These databases are largely idle, and the
# Postgres defaults favour throughput over storage that wears out.
#
# checkpoint_timeout is the lever. The 5min default pushes every dirty
# page to disk 288 times a day, so a small database whose same few pages
# are re-dirtied constantly (sessions, tokens, job state) gets rewritten
# over and over. Measured on ak-pg: 25.8M checkpoint buffers flushed
# (~206 GB) against a 1046 MB database -- the whole thing roughly twice a
# day. At 15min those pages coalesce and are written a third as often.
#
# max_wal_size must rise with it, or checkpoints trigger on size instead
# and the timeout change does nothing. It also bounds crash recovery to
# at most 2GB of WAL replay.
#
# NOT changed: synchronous_commit stays at the default `on`. It is off for
# ha-pg because a state recorder is telemetry; these hold credentials,
# documents and job state, where losing the last few hundred ms of commits
# on an unclean shutdown is not an acceptable trade.
checkpoint_timeout: "15min"
max_wal_size: "2GB"
# zstd, not "on": the boolean spelling resolves to pglz (2016-era, weaker
# and slower than the lz4/zstd this build also accepts). Compresses the
# full-page images that dominate WAL volume after each checkpoint.
wal_compression: "zstd"
# A WAL segment is a fixed 16 MiB file, and archive_timeout forces a switch
# on a timer -- the segment is then written and archived at its full size
# no matter how little real WAL it holds. CNPG defaults to 5min, i.e. up
# to 288 segments/day = 4.1 GB/day that is largely padding.
#
# Verified on gf-pg first (the staged rollout): 211 -> 41 MB/hour, an 81%
# cut, with archived segments falling from ~12/hour to ~1.5/hour in step.
#
# The cost is RPO: up to 30min of WAL is unrecoverable if both instances
# are lost. The documents themselves live on the media PVC, not in
# Postgres -- this database holds their metadata.
archive_timeout: "30min"
# See ak-pg cluster.yaml for the rationale on synchronous replication.
synchronous:
method: any
+20 -2
View File
@@ -208,9 +208,25 @@ spec:
seccompProfile:
type: RuntimeDefault
volumes:
# paperless-data-block (RWO, ceph-block), NOT paperless-data
# (RWX, ceph-filesystem). Only this Deployment ever mounted the volume,
# and its contents are single-writer state — the Whoosh index,
# classification model, celerybeat schedule, logs. Being on CephFS meant
# every access went through the MDS: 22 requests/sec sustained with zero
# CI running, ~15 journal events/sec against 2 directory commits per
# 180s, i.e. cap/inode churn rather than file writes. That was most of
# the constant 7-8 GB/day on cephfs-metadata, and that pool is size 3.
#
# `consume` and `media` below STAY on CephFS: the ftp-scanner writes to
# those from another node, so they genuinely need RWX.
#
# paperless-data is retained (prune: disabled) as the rollback. Do not
# delete it until search has been confirmed working — that is what
# proves the Whoosh index survived the copy, not merely that paperless
# starts. Migration record: MIGRATION-data-to-block.md.
- name: data
persistentVolumeClaim:
claimName: paperless-data
claimName: paperless-data-block
- name: media
persistentVolumeClaim:
claimName: paperless-media
@@ -218,7 +234,9 @@ spec:
persistentVolumeClaim:
claimName: paperless-consume
- name: bridge-cert
emptyDir: {}
emptyDir:
medium: Memory
sizeLimit: 16Mi
---
apiVersion: v1
kind: Service
+39
View File
@@ -18,6 +18,45 @@ spec:
requests:
storage: 10Gi
---
# Replacement for paperless-data, on RBD instead of CephFS.
#
# WHY: paperless-data is declared ReadWriteMany on ceph-filesystem, but only ONE
# pod ever mounts it — the paperless Deployment itself. Its contents are all
# single-writer state: the 50 MB Whoosh index, classification_model.pickle,
# celerybeat-schedule.db, logs, migration_lock. Nothing shares it. Only `consume`
# and `media` genuinely need RWX, because the FTP scanner writes to those from a
# different node.
#
# COST OF LEAVING IT: every access goes through the MDS. Measured 2026-08-20 with
# zero CI running, this volume's client was doing 22 MDS requests/sec sustained,
# and the MDS logged ~15 journal events/sec against only 2 directory commits per
# 180s — i.e. cap and inode churn from repeated access, not file writes. That is
# most of the constant 7-8 GB/day written to cephfs-metadata, which is a size 3
# pool, so ~21-24 GB/day actually hits the drives.
#
# cephfs-metadata deliberately STAYS at size 3 (see storageclass.yaml): losing
# the metadata pool destroys the whole filesystem, not just the objects on one
# OSD, so the 275 GiB in cephfs-data0 rides on it. Reducing the writes at source
# is the right fix; reducing the replication is not.
#
# MIGRATION: this PVC is created EMPTY and nothing mounts it yet. The data copy
# and the deployment repoint are a separate, staged step — see
# MIGRATION-data-to-block.md. Merging this alone is inert.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: paperless-data-block
namespace: paperless
annotations:
kustomize.toolkit.fluxcd.io/prune: disabled
spec:
accessModes:
- ReadWriteOnce
storageClassName: ceph-block
resources:
requests:
storage: 10Gi
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
+1 -1
View File
@@ -25,7 +25,7 @@ spec:
type: RuntimeDefault
containers:
- name: redis
image: redis:8.10.0-alpine # {"$imagepolicy": "flux-system:redis-paperless"}
image: redis:8.10.1-alpine # {"$imagepolicy": "flux-system:redis-paperless"}
args:
- redis-server
- --save
+38 -1
View File
@@ -32,7 +32,44 @@ spec:
parameters:
shared_buffers: "128MB"
max_connections: "50"
wal_compression: "on"
# zstd, not "on": the boolean spelling resolves to pglz (2016-era, weaker
# and slower than the lz4/zstd this build also accepts). Compresses the
# full-page images that dominate WAL volume after each checkpoint.
wal_compression: "zstd"
# A WAL segment is a fixed 16 MiB file, and archive_timeout forces a switch
# on a timer -- the segment is then written and archived at its full size
# no matter how little real WAL it holds. CNPG defaults to 5min, i.e. up
# to 288 segments/day = 4.6 GB/day that is largely padding.
#
# Verified on gf-pg first (the staged rollout): 211 -> 41 MB/hour, an 81%
# cut, with archived segments falling from ~12/hour to ~1.5/hour in step.
# Two independent signals moving together is what confirms the volume was
# padding rather than data.
#
# The cost is RPO: up to 30min of WAL is unrecoverable if this cluster and
# its volume are lost outright. This is a single-instance cluster, so the
# archive is the only off-node copy.
archive_timeout: "30min"
# Write-reduction tuning. These databases are largely idle, and the
# Postgres defaults favour throughput over storage that wears out.
#
# checkpoint_timeout is the lever. The 5min default pushes every dirty
# page to disk 288 times a day, so a small database whose same few pages
# are re-dirtied constantly (sessions, tokens, job state) gets rewritten
# over and over. Measured on ak-pg: 25.8M checkpoint buffers flushed
# (~206 GB) against a 1046 MB database -- the whole thing roughly twice a
# day. At 15min those pages coalesce and are written a third as often.
#
# max_wal_size must rise with it, or checkpoints trigger on size instead
# and the timeout change does nothing. It also bounds crash recovery to
# at most 2GB of WAL replay.
#
# NOT changed: synchronous_commit stays at the default `on`. It is off for
# ha-pg because a state recorder is telemetry; these hold credentials,
# documents and job state, where losing the last few hundred ms of commits
# on an unclean shutdown is not an acceptable trade.
checkpoint_timeout: "15min"
max_wal_size: "2GB"
resources:
requests:
cpu: 100m
+16
View File
@@ -362,6 +362,22 @@ spec:
name: trmnl-renderer-schedule
- name: tmp
emptyDir:
# RAM-backed, NOT disk. Without `medium: Memory` an emptyDir lands
# on the node's filesystem, and this one was the single largest
# writer in the cluster: 249 GB over 7.3 days = ~34 GB/day onto
# /dev/vda5, more than every Ceph OSD and every database combined
# (measured 2026-08-20 from the container's cgroup io.stat).
#
# The cause is cadence, not volume. schedules.template.json renders
# every 60 s, so Chrome is launched 1440 times a day and each launch
# creates, fills and deletes a /tmp/puppeteer_dev_chrome_profile-*
# directory. At rest /tmp holds under 8 MB; it is pure churn for
# data that never outlives the render.
#
# tmpfs pages count against the container's memory limit, which is
# why the cap stays at 512Mi: measured usage is 129Mi against a 2Gi
# limit, so even the full cap leaves ample headroom.
medium: Memory
sizeLimit: 512Mi
---
apiVersion: v1
+33 -6
View File
@@ -34,7 +34,7 @@ spec:
# Image automation, range 3.x (see image-automation/woodpecker.yaml).
# MUST stay version-matched with woodpecker-server (gRPC parity) — both
# track the same range/filter so they bump together on each release.
image: woodpeckerci/woodpecker-agent:v3.17.0 # {"$imagepolicy": "flux-system:woodpecker-agent"}
image: woodpeckerci/woodpecker-agent:v3.18.0 # {"$imagepolicy": "flux-system:woodpecker-agent"}
env:
- name: WOODPECKER_SERVER
value: woodpecker.woodpecker.svc.cluster.local:9000 # in-cluster gRPC, replaces LXC :9000
@@ -50,12 +50,39 @@ spec:
# clone + subsequent steps share the workspace PVC and the k8s backend
# can schedule them on different nodes -> an RWO volume then fails to
# mount on the second node. CephFS (RWX) lets the workspace follow
# the pipeline across nodes. The -ephemeral variant (reclaim Delete,
# rook-ceph-cluster/storageclass.yaml) is REQUIRED here: workspaces
# are throwaway, and the Retain class leaked one Released PV per
# finished pipeline (issue #262 follow-up).
# the pipeline across nodes. Reclaim MUST be Delete: workspaces are
# throwaway, and a Retain class leaked one Released PV per finished
# pipeline (issue #262 follow-up).
#
# ceph-filesystem-ci, not ceph-filesystem-ephemeral: a dedicated
# CephFS whose pools are BOTH size 1 (infrastructure/
# rook-ceph-cluster/cephfs-ci.yaml). The shared filesystem
# replicates data0 2x and metadata 3x, which is the correct trade
# for real data and pure waste for a git clone that is deleted
# minutes later.
#
# Measured 2026-08-21, polling both pools every 15s (a 5-minute
# interval misses an entire pipeline -- PVC created, used and
# deleted inside ~2 minutes):
# CI active 1.5min: data0 143.2 MB, metadata 17.2 MB
# CI idle 16.0min: data0 0.0 MB, metadata 11.0 MB
# data0 write volume is 100% CI. Per pipeline this moves the drive
# cost from 71.6x2 + 8.2x3 = ~168 MB to ~80 MB.
#
# ceph-filesystem-ephemeral is NOT repointed, and the reason is a
# technical constraint rather than a safety one. Its other consumer
# is velero/restore-test.sh, whose probe PVCs are snapshot CLONES:
# `ceph fs subvolume snapshot clone` operates within one filesystem,
# so a snapshot taken in `cephfs` cannot be cloned into `cephfs-ci`.
#
# Those probes are just as throwaway as a CI workspace -- the
# 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
# CI's ~96 pipelines/day, so it is not worth the complexity.
- name: WOODPECKER_BACKEND_K8S_STORAGE_CLASS
value: ceph-filesystem-ephemeral
value: ceph-filesystem-ci
- name: WOODPECKER_BACKEND_K8S_STORAGE_RWX
value: "true"
# MUST be a JSON object literal — the backend json.Unmarshal's this
+38
View File
@@ -56,6 +56,44 @@ spec:
parameters:
shared_buffers: "128MB"
max_connections: "50"
# Write-reduction tuning. These databases are largely idle, and the
# Postgres defaults favour throughput over storage that wears out.
#
# checkpoint_timeout is the lever. The 5min default pushes every dirty
# page to disk 288 times a day, so a small database whose same few pages
# are re-dirtied constantly (sessions, tokens, job state) gets rewritten
# over and over. Measured on ak-pg: 25.8M checkpoint buffers flushed
# (~206 GB) against a 1046 MB database -- the whole thing roughly twice a
# day. At 15min those pages coalesce and are written a third as often.
#
# max_wal_size must rise with it, or checkpoints trigger on size instead
# and the timeout change does nothing. It also bounds crash recovery to
# at most 2GB of WAL replay.
#
# NOT changed: synchronous_commit stays at the default `on`. It is off for
# ha-pg because a state recorder is telemetry; these hold credentials,
# documents and job state, where losing the last few hundred ms of commits
# on an unclean shutdown is not an acceptable trade.
checkpoint_timeout: "15min"
max_wal_size: "2GB"
# zstd, not "on": the boolean spelling resolves to pglz (2016-era, weaker
# and slower than the lz4/zstd this build also accepts). Compresses the
# full-page images that dominate WAL volume after each checkpoint.
wal_compression: "zstd"
# A WAL segment is a fixed 16 MiB file, and archive_timeout forces a switch
# on a timer -- the segment is then written and archived at its full size
# no matter how little real WAL it holds. CNPG defaults to 5min, i.e. up
# to 288 segments/day = 4.6 GB/day that is largely padding.
#
# Verified on gf-pg first (the staged rollout): 211 -> 41 MB/hour, an 81%
# cut, with archived segments falling from ~12/hour to ~1.5/hour in step.
# Two independent signals moving together is what confirms the volume was
# padding rather than data.
#
# The cost is RPO: up to 30min of WAL is unrecoverable if this cluster and
# its volume are lost outright. This is a single-instance cluster, so the
# archive is the only off-node copy.
archive_timeout: "30min"
resources:
requests:
cpu: 100m
+1 -1
View File
@@ -40,7 +40,7 @@ spec:
# minor + patch auto-update within 3.x; only the 4.0 major is held for
# a reviewed bump. Safe now the DB is postgres (the old sqlite-surprise
# -bump hazard is gone). Stays in lockstep with the agent.
image: woodpeckerci/woodpecker-server:v3.17.0 # {"$imagepolicy": "flux-system:woodpecker-server"}
image: woodpeckerci/woodpecker-server:v3.18.0 # {"$imagepolicy": "flux-system:woodpecker-server"}
env:
- name: WOODPECKER_HOST
value: https://ci.renner.uno
+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 Sunday restore-test sweep holds the slot, even this 1 GiB
# applies: while the 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"),
@@ -917,11 +917,27 @@ subjects:
name: plugin-barman-cloud
namespace: database
---
# stringData, NOT base64 `data`. An image reference is not a secret, and
# encoding it here had two real costs: no reviewer sees a version change in a
# diff, and no $imagepolicy marker can be attached to a base64 blob. The result
# was that automation advanced the controller to v0.14.0 while this sidecar sat
# at v0.12.0 — a two-minor skew in the component that performs every base backup
# and WAL archive, invisible until someone read the pod spec (#535).
#
# The Secret KIND is kept because the controller reads it via secretKeyRef
# (deployment env SIDECAR_IMAGE); changing it to a ConfigMap would need a matching
# edit to that reference and diverge further from upstream. stringData is
# accepted identically by the API server and serialised into `data` on write.
#
# The name carries a kustomize hash suffix from upstream's secretGenerator. It is
# static here, so editing the value does NOT change the name and the deployment's
# secretKeyRef stays valid.
#
# IF YOU RE-IMPORT THE UPSTREAM MANIFEST: upstream ships this as base64 `data`
# with no marker. Re-apply this block, or the skew silently returns.
apiVersion: v1
data:
SIDECAR_IMAGE: |
Z2hjci5pby9jbG91ZG5hdGl2ZS1wZy9wbHVnaW4tYmFybWFuLWNsb3VkLXNpZGVjYXI6dj
AuMTIuMA==
stringData:
SIDECAR_IMAGE: ghcr.io/cloudnative-pg/plugin-barman-cloud-sidecar:v0.14.0 # {"$imagepolicy": "flux-system:cnpg-barman-plugin-sidecar"}
kind: Secret
metadata:
name: plugin-barman-cloud-kg5c2795f7
@@ -87,6 +87,16 @@ 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
@@ -122,6 +132,12 @@ 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
@@ -28,3 +28,40 @@ spec:
policy:
semver:
range: "0.x"
---
# The SIDECAR image, tracked separately from the controller above.
#
# These are two DIFFERENT images that must move together:
# plugin-barman-cloud the controller, in the database namespace
# plugin-barman-cloud-sidecar injected into every Postgres pod
#
# Only the controller was tracked, so automation walked it from v0.12.0 to
# v0.14.0 while the sidecar stayed at v0.12.0 — a two-minor skew in the
# component that performs every base backup and WAL archive (#535). The skew was
# invisible because the sidecar tag was base64-encoded inside a Secret, where no
# $imagepolicy marker can reach it and no reviewer would see it in a diff.
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImageRepository
metadata:
name: cnpg-barman-plugin-sidecar
namespace: flux-system
spec:
image: ghcr.io/cloudnative-pg/plugin-barman-cloud-sidecar
interval: 1h
---
# Same filter and range as the controller policy deliberately: the two images
# are released together and must not be allowed to drift apart again.
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImagePolicy
metadata:
name: cnpg-barman-plugin-sidecar
namespace: flux-system
spec:
imageRepositoryRef:
name: cnpg-barman-plugin-sidecar
filterTags:
pattern: '^v(?P<ver>\d+\.\d+\.\d+)$'
extract: '$ver'
policy:
semver:
range: "0.x"
@@ -156,4 +156,6 @@ spec:
# each pod enrolls its own peer and a restart re-enrolls cleanly
# (the old peer is reaped).
- name: state
emptyDir: {}
emptyDir:
medium: Memory
sizeLimit: 16Mi
@@ -358,3 +358,67 @@ spec:
- ports:
- port: "8123"
protocol: TCP
---
# ha-pg (CNPG) — instance-manager access for the CloudNativePG operator.
#
# DEDICATED policy with its own endpointSelector, NOT another rule on the
# namespace-wide default-deny above. That policy has endpointSelector:{}, so any
# rule added there applies to EVERY pod in the namespace — and :8000 is served
# both by the CNPG instance manager and by other workloads, so a namespace-wide
# :8000 grant would expose far more than intended. Same reasoning as
# wp-pg-allow-cnpg-operator in woodpecker-ns.yaml, which was corrected during
# review for exactly this (#433).
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: ha-pg-allow-cnpg-operator
namespace: home-assistant
spec:
endpointSelector:
matchLabels:
cnpg.io/cluster: ha-pg
ingress:
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": database
app.kubernetes.io/name: cloudnative-pg
toPorts:
- ports:
- port: "8000"
protocol: TCP
# ICMP to the ha-pg pods. REQUIRED because the L4 rules in this policy
# select those endpoints, and Cilium drops ICMP to an endpoint selected by
# any layer 4 policy unless the traffic is CT-related. Without this, PMTUD
# Destination Unreachable / Packet Too Big toward ha-pg are dropped
# silently, and the symptom is stalled transfers rather than a policy
# denial — the failure mode documented in woodpecker-ns.yaml.
- fromEntities:
- cluster
icmps:
- fields:
- { type: 3, family: IPv4 } # Destination Unreachable — PMTUD
- { type: 1, family: IPv6 } # Destination Unreachable
- { type: 2, family: IPv6 } # Packet Too Big — PMTUD, mandatory
---
# ha-pg — Postgres access for the Home Assistant recorder.
#
# Scoped to the HA pod on both ends: only home-assistant reaches ha-pg, and only
# on 5432. Nothing else in this namespace has any business in the recorder
# database.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: ha-pg-allow-home-assistant
namespace: home-assistant
spec:
endpointSelector:
matchLabels:
cnpg.io/cluster: ha-pg
ingress:
- fromEndpoints:
- matchLabels:
app.kubernetes.io/name: home-assistant
toPorts:
- ports:
- port: "5432"
protocol: TCP
@@ -14,8 +14,10 @@
# immediately:
# kubectl -n openbao create job --from=cronjob/claude-oauth-secret-sync claude-oauth-sync-now
#
# woodpecker is reached in-cluster at woodpecker.apps.svc:8000. Needs the netpol
# halves woodpecker-allow-rotators (apps/*) + rotator-claude-oauth-egress
# woodpecker is reached in-cluster at woodpecker.woodpecker.svc:8000 (it moved
# out of `apps` in the namespace migration — see
# kubernetes/apps/woodpecker/MIGRATION-namespace.md). Needs the netpol
# halves woodpecker-allow-rotators (woodpecker ns) + rotator-claude-oauth-egress
# (openbao-ns.yaml). OpenBao policy/role rotator-claude-oauth + the bootstraps
# (woodpecker-bot token, the seeded OAuth value) are provisioned by hand — see
# policies/rotator-claude-oauth.hcl.
@@ -49,7 +51,15 @@ spec:
startingDeadlineSeconds: 3600
jobTemplate:
spec:
backoffLimit: 1
# 4 (not the sibling rotators' 1-2) because this is the only DAILY
# rotator — every other CronJob here is quarterly, so it takes ~90x as
# many runs and is correspondingly more exposed to per-run transients.
# backoffLimit: 1 gave two attempts ~11 s apart, and on 2026-08-19 a
# sub-minute resolver transient took both out (Job
# claude-oauth-secret-sync-29785100, BackoffLimitExceeded), costing a
# day's sync. 4 spreads the attempts over ~150 s (10/20/40/80 s backoff),
# still well inside activeDeadlineSeconds.
backoffLimit: 4
activeDeadlineSeconds: 300
ttlSecondsAfterFinished: 259200 # 3 d
template:
@@ -89,7 +99,7 @@ spec:
- name: BAO_FIELD
value: setup-token
- name: WOODPECKER_URL
value: http://woodpecker.apps.svc.cluster.local:8000
value: http://woodpecker.woodpecker.svc.cluster.local:8000
- name: WOODPECKER_ORG
value: k.renner
- name: WOODPECKER_SECRET_NAME
@@ -25,9 +25,10 @@
# but a failed sync should be fixed before the next run rather than left.
#
# gitea is reached in-cluster at gitea.apps.svc:3000, woodpecker at
# woodpecker.apps.svc:8000 (both hairpin off the ingress LB from a pod). Needs
# woodpecker.woodpecker.svc:8000 (it moved out of `apps` in the namespace
# migration — see kubernetes/apps/woodpecker/MIGRATION-namespace.md). Needs
# the netpol halves: gitea-allow-review-bot-rotator + woodpecker-allow-rotators
# (apps/*) and rotator-review-bot-egress (openbao-ns.yaml).
# (woodpecker ns) and rotator-review-bot-egress (openbao-ns.yaml).
#
# OpenBao policy/role rotator-review-bot + the bootstraps (review-bot password,
# woodpecker-bot token, seed review-bot#token) are provisioned by hand — see
@@ -147,7 +148,7 @@ spec:
- name: BAO_FIELD
value: token
- name: WOODPECKER_URL
value: http://woodpecker.apps.svc.cluster.local:8000
value: http://woodpecker.woodpecker.svc.cluster.local:8000
- name: WOODPECKER_ORG
value: k.renner
- name: WOODPECKER_SECRET_NAME
@@ -5,7 +5,7 @@ metadata:
namespace: rook-ceph
spec:
cephVersion:
image: quay.io/ceph/ceph:v19.2.5 # {"$imagepolicy": "flux-system:ceph"}
image: quay.io/ceph/ceph:v19.2.6 # {"$imagepolicy": "flux-system:ceph"}
allowUnsupported: false
dataDirHostPath: /var/lib/rook
mon:
@@ -17,6 +17,17 @@ spec:
modules:
- name: pg_autoscaler
enabled: true
# Rotates daemon keys to aes256k for CVE-2025-30156 (#527). Opt-in; disabled
# by default. Increment keyGeneration to rotate again.
#
# Daemon keys only — aes256k over the CSI mount path needs kernel 7.0+ and
# these nodes run 6.18, so CSI stays on aes and the client-key checks stay
# raised. keyType is unset so Rook picks it.
security:
cephx:
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
@@ -45,18 +56,92 @@ spec:
# 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.
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.
mon_compact_on_start: "true"
mgr:
mgr/volumes/max_concurrent_clones: "16"
# QLC tune for the Crucial P2 NVMe per host. 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). bdev_*_discard need an OSD restart
# to take effect (init-time); the bluestore_* params are runtime.
# See ceph-speed-baseline-2026-05-05 in the wiki.
# 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.
#
# 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.
osd:
bdev_enable_discard: "true"
bdev_async_discard: "true"
bluestore_prefer_deferred_size_ssd: "65536"
bluestore_deferred_batch_ops_ssd: "64"
# Ceph defaults, set explicitly so the previous override is actively
# overwritten rather than merely removed — `ceph config` retains a value
# that is only deleted from this file.
bluestore_prefer_deferred_size_ssd: "0"
bluestore_deferred_batch_ops_ssd: "16"
dashboard:
enabled: true
ssl: false
@@ -79,7 +164,15 @@ spec:
useAllNodes: true
useAllDevices: false
devices:
- name: nvme0n1 # NVMe drive PCI-passed-through from each PVE node
# On k8s-2/k8s-3 this is the NVMe drive PCI-passed-through from saturn
# and neptune, and it is the only block device the guest can see. k8s-1
# is bare metal on jupiter and sees two: the OSD drive in MS-01 slot 1
# (PCI 01:00.0) and the Talos system disk in slot 2 (58:00.0). Slot 1
# enumerates first, so nvme0n1 is still the OSD drive there — but that
# is enumeration order, not a guarantee. The system disk is protected
# from the other direction instead: talos/patches/k8s-1.yaml pins the
# installer to it by serial, and Rook skips a disk carrying partitions.
- name: nvme0n1
config:
osdsPerDevice: "1"
# Storage layer must never be evicted by kubelet under node pressure
@@ -0,0 +1,64 @@
---
# A SEPARATE CephFS whose only purpose is Woodpecker CI workspaces. Each
# pipeline gets a PVC that is created, filled and deleted within ~2 minutes;
# keeping that churn off `cephfs` spares its size-3 metadataPool.
#
# size 2, not 1: an unreplicated pool makes `ceph osd ok-to-stop` refuse for
# every OSD, blocking all OSD maintenance cluster-wide. size 1 is viable only
# once these pools have OSDs of their own.
apiVersion: ceph.rook.io/v1
kind: CephFilesystem
metadata:
name: cephfs-ci
namespace: rook-ceph
spec:
metadataPool:
replicated:
size: 2
dataPools:
- name: data0
replicated:
size: 2
metadataServer:
activeCount: 1
# No standby-replay, unlike `cephfs`. A standby exists there so a failed
# MDS does not hang every CephFS mount cluster-wide; here the only casualty
# is a running pipeline, and a second daemon to protect throwaway work is
# not worth its memory.
activeStandby: false
resources:
# Deliberately NOT the 4Gi that `cephfs` needs. That figure is driven by
# immich's millions of cached inodes; a CI workspace is a few thousand
# short-lived files that vanish with the pipeline.
requests:
cpu: 100m
memory: 512Mi
limits:
memory: 1Gi
---
# CI-only class. `ceph-filesystem-ephemeral` is deliberately left pointing at
# the main filesystem: velero/restore-test.sh uses it to restore REAL data for
# verification, which must not land on unreplicated pools.
#
# A new class rather than repointing the old one — StorageClass.parameters are
# immutable, so `fsName` cannot be edited in place.
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: ceph-filesystem-ci
provisioner: rook-ceph.cephfs.csi.ceph.com
parameters:
clusterID: rook-ceph
fsName: cephfs-ci
pool: cephfs-ci-data0
csi.storage.k8s.io/provisioner-secret-name: rook-csi-cephfs-provisioner
csi.storage.k8s.io/provisioner-secret-namespace: rook-ceph
csi.storage.k8s.io/controller-expand-secret-name: rook-csi-cephfs-provisioner
csi.storage.k8s.io/controller-expand-secret-namespace: rook-ceph
csi.storage.k8s.io/node-stage-secret-name: rook-csi-cephfs-node
csi.storage.k8s.io/node-stage-secret-namespace: rook-ceph
# Delete, for the same reason ceph-filesystem-ephemeral uses it: with Retain,
# every finished pipeline leaks a Released PV pinning a subvolume (191 of them
# by 2026-07-16, issue #262 follow-up).
reclaimPolicy: Delete
allowVolumeExpansion: true
@@ -4,5 +4,6 @@ resources:
- cephcluster.yaml
- toolbox.yaml
- storageclass.yaml
- cephfs-ci.yaml
- volumesnapshotclass.yaml
- objectstore.yaml
@@ -5,8 +5,31 @@ metadata:
namespace: rook-ceph
spec:
failureDomain: host
# size 2, NOT 3. With 3 OSDs on 3 hosts, size=3 meant every write landed on
# every drive in the cluster: per-drive write volume equalled total cluster
# write volume, and all three drives wore at an identical rate and would have
# reached end-of-rated-life together (measured 2026-08-19: 80/82/87% endurance
# used, ~0.13 points/day, all three exhausted between Nov 2026 and Jan 2027 —
# see #434). Replication does not protect against that failure mode, because
# wear-out here is correlated by construction rather than independent.
#
# size 2 cuts per-drive write volume by a third and desynchronises the wear.
#
# min_size 1 is REQUIRED and deliberate. Ceph does not lower min_size when
# size is reduced — it was 2 here, and leaving it would make the pool 2/2,
# where any single OSD down (every rolling node reboot, every OSD restart)
# blocks IO for its PGs and hangs every RBD volume in the cluster.
#
# The trade this accepts: while the pool is degraded (one OSD down), a second
# drive failing loses data, and with all three drives at 80%+ endurance that
# window is not hypothetical. Mitigations that already exist: CNPG barman and
# Velero both ship offsite to Hetzner (opentofu/object_storage.tf), so the
# exposure is the RPO of those backups, not total loss. Keep node reboots
# serialised and wait for HEALTH_OK between them.
replicated:
size: 3
size: 2
parameters:
min_size: "1"
---
apiVersion: storage.k8s.io/v1
kind: StorageClass
@@ -64,13 +87,35 @@ 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:
replicated:
size: 3
size: 2
parameters:
min_size: "1"
dataPools:
- name: data0
# size 2 / min_size 1 for the same reasons as replicapool above — see
# the comment there for the full rationale and the accepted trade.
replicated:
size: 3
size: 2
parameters:
min_size: "1"
metadataServer:
activeCount: 1
activeStandby: true
@@ -38,7 +38,7 @@ spec:
serviceAccountName: rook-ceph-default
containers:
- name: rook-ceph-tools
image: quay.io/ceph/ceph:v19.2.5 # {"$imagepolicy": "flux-system:ceph"}
image: quay.io/ceph/ceph:v19.2.6 # {"$imagepolicy": "flux-system:ceph"}
command:
- /bin/bash
- -c
@@ -164,7 +164,9 @@ spec:
name: rook-config-override
optional: true
- name: ceph-config
emptyDir: {}
emptyDir:
medium: Memory
sizeLimit: 16Mi
tolerations:
- key: "node.kubernetes.io/unreachable"
operator: "Exists"
@@ -1,19 +1,30 @@
---
# Weekly Velero restore-test CronJob.
# Monthly Velero restore-test CronJob — 08:00 UTC on the 1st.
#
# 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
# 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
# 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; no other day
# shows it.
# DataUpload not starting until 06:18. Both are Sundays, the only day the
# sweep ran then; no other day shows it.
#
# 08:00 + the 6 h activeDeadlineSeconds cap still lands well clear of
# Monday's 03:15.
# 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.
#
# Concurrency policy Forbid: the immich restore (~180 GiB, millions of
# small files) alone can run multiple hours, so the full sweep is sized
@@ -29,7 +40,7 @@ metadata:
name: restore-test
namespace: velero
spec:
schedule: "0 8 * * 0"
schedule: "0 8 1 * *"
timeZone: Etc/UTC
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 2
@@ -39,7 +50,7 @@ spec:
spec:
backoffLimit: 0
activeDeadlineSeconds: 21600 # 6 h hard cap (immich alone can take ~3 h)
ttlSecondsAfterFinished: 1209600 # 14 d — enough to inspect after the next weekly run
ttlSecondsAfterFinished: 3456000 # 40 d — enough to inspect after the next monthly run
template:
spec:
serviceAccountName: restore-test
@@ -85,4 +96,6 @@ spec:
name: restore-test-script
defaultMode: 0755
- name: tmp
emptyDir: {}
emptyDir:
medium: Memory
sizeLimit: 16Mi
@@ -1,5 +1,5 @@
---
# RBAC for the weekly restore-test CronJob.
# RBAC for the monthly 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
# Weekly verification that Velero PVC backups are actually restorable.
# Monthly 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 weekly run leaks O(100 GiB) of CephFS
# iteration. Without this, each 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
+89 -50
View File
@@ -1,62 +1,101 @@
# 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 both
# 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:
# 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`.
# 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.
net.ipv6.conf.ens19.disable_ipv6: "1"
install:
disk: /dev/vda
# Both NVMe drives are now ordinary block devices to Talos: the Kingston
# OM8PGP41024Q-A0 system disk and the Samsung PM983 that Rook claims as the
# Ceph OSD. Selecting the installer target by serial means it cannot land
# on the OSD drive regardless of which one the kernel enumerates first.
diskSelector:
serial: 50026B7382B48B86
certSANs:
- k8s-1.srv.renner.uno
nodeLabels:
topology.kubernetes.io/zone: jupiter
cluster:
allowSchedulingOnControlPlanes: 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.
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 enp89s0.
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
-43
View File
@@ -1,43 +0,0 @@
# TEMPORARY shims v2 — rustfs LXCs 800/801/802, decommission in progress.
#
# Stage 2 (#270) failed: both destroy attempts died instantly with
# "context deadline exceeded" on the pre-destroy status GET, while the PVE
# API answers the same call in <1s. Cause: the bpg provider reads
# `timeout_delete` from STATE at destroy time, and importing a resource
# whose config declares no timeouts stored 0 — a zero-second context.
# (The import/plan read path uses its own timeout, which is why stage 1
# planned clean.)
#
# Fix: declare the timeouts explicitly, apply ONCE so they land in state
# (in-place no-API update), then delete this file again and the next apply
# destroys the containers. No lifecycle ignore_changes this time — the
# timeout update must actually apply; the other attributes mirror the real
# containers so the plan stays in-place.
#
# 1. merge; run PIPELINE=deploy-infrastructure (plan MUST show in-place
# update only, no replace), then TOFU_APPLY=true
# 2. follow-up PR deletes this file; plan shows 3 destroys; apply.
locals {
rustfs_decommission = {
"800" = "saturn"
"801" = "jupiter"
"802" = "neptune"
}
}
resource "proxmox_virtual_environment_container" "rustfs_decommission" {
for_each = local.rustfs_decommission
node_name = each.value
vm_id = tonumber(each.key)
started = false
unprivileged = true
start_on_boot = true
timeout_create = 1800
timeout_clone = 1800
timeout_update = 1800
timeout_delete = 1800
timeout_start = 300
}
+39 -6
View File
@@ -59,13 +59,46 @@ provider "unifi" {
# UniFi Network v2 API (policy-table NAT rules). Same controller and API key
# as the unifi provider above; the v2 endpoints are simply not covered by it.
# UniFi PUT quirk: updates may require _id in the body, which restapi does
# not send — if an in-place update 400s, taint the object so CI recreates it.
# UniFi PUT quirk — DISPROVEN 2026-08-19, left here because it was wrong for
# long enough to do damage. The claim was that a UniFi PUT may require _id in
# the body, which restapi does not send, and that a 400 should be answered by
# tainting the object so CI recreates it. Tested directly against a live rule
# with exactly the body tofu sends:
#
# PUT /proxy/network/v2/api/site/default/nat/6a61c7d7bbd7b2ca90981f51
# body: the object, _id removed -> HTTP 200, object returned intact
# rule re-read and diffed before/after -> byte-identical
# rule count -> 60 before, 60 after
#
# Confirmed again at scale by apply 5675: all 60 rules "Modifications complete
# after 0s", no 400s, ci.renner.uno and immich.renner.uno both still 200.
#
# This mattered. A warning that applying might break firewall rules is a
# warning nobody argues with, so the apply did not get run — and the
# renner-uno-k8s-mariadb bucket added in b976a67b sat uncreated for three days
# while the WordPress backup failed nightly with "The specified bucket does not
# exist". Applying these NAT rules is safe. Do not taint them.
#
# create_returns_object, NOT write_returns_object. Both make the provider store
# the API response into `data`; write_returns_object does it for PUT as well as
# POST. Since `data` in unifi_nat_hairpin.tf never sets the UDM-assigned `_id`,
# every write put a field into state that config does not have, so all 60 rules
# planned as "will be updated in-place" with `- _id` as the whole diff — and
# re-dirtied themselves on the next write, forever.
#
# No provider-side ignore can suppress that: `data` is a configured attribute,
# compared by Terraform core before provider logic runs. Tried and disproven —
# ignore_changes_to = ["_id"] (plan 5661), ignore_all_server_changes (5662),
# and ignore_server_additions on restapi 3.x (5667). All three left the 60.
#
# create_returns_object keeps what the provider actually needs — the POST
# response is how it learns `_id` for id_attribute below — without letting
# updates rewrite `data`.
provider "restapi" {
uri = "${var.unifi_api_url}/proxy/network/v2/api/site/default"
insecure = var.unifi_insecure
write_returns_object = true
id_attribute = "_id"
uri = "${var.unifi_api_url}/proxy/network/v2/api/site/default"
insecure = var.unifi_insecure
create_returns_object = true
id_attribute = "_id"
headers = {
"X-API-KEY" = var.unifi_api_key
"Content-Type" = "application/json"
+44 -10
View File
@@ -107,17 +107,51 @@ resource "restapi_object" "hairpin" {
for_each = local.hairpin_rules
path = "/nat"
# KNOWN PERMANENT DIFF — cosmetic. Do not try to fix it with a provider
# setting; four have been tried and none work. Recorded here so the next
# person does not spend a day rediscovering that.
#
# Every plan shows all 60 of these as "will be updated in-place" with the
# entire diff being one line:
#
# ~ data = jsonencode( ~ { - _id = "6a61c7d7bbd7b2ca90981f51"
# # (15 unchanged attributes hidden) } )
#
# The UDM assigns _id and returns it; the provider puts the response into
# `data`; the `data` below never sets it. So state carries a field the config
# does not. It survives an apply: 5675 rewrote all 60, and the very next plan
# (5677) showed the same 60 again.
#
# Tried, with the plan that disproved each — do not repeat these:
#
# ignore_changes_to = ["_id"] 5661 60 remained
# ignore_all_server_changes = true 5662 60 remained
# restapi 3.x + ignore_server_additions = true 5667 60 remained, and
# added an api_data diff
# write_returns_object -> create_returns_object 5677 60 remained
#
# The first three cannot work in principle: `data` is a configured attribute,
# so Terraform core diffs config against state before any provider-side ignore
# logic runs. The fourth was the better theory — that PUT responses were
# re-polluting `data` — and it is wrong too, which means the response is
# written into `data` on the READ path. Settling it properly needs the
# provider source, not more guesses at the schema.
#
# It is safe to apply through. Verified twice: a single PUT without _id
# returns 200 and leaves the rule byte-identical (see providers.tf), and
# apply 5675 modified all 60 with no errors and no service impact.
data = jsonencode({
description = "Hairpin ${each.key}"
type = "DNAT"
enabled = true
exclude = false
in_interface = each.value.network_id
ip_address = each.value.endpoint.target
ip_version = each.value.endpoint.ip_version
protocol = "tcp_udp"
rule_index = each.value.rule_index
logging = false
description = "Hairpin ${each.key}"
type = "DNAT"
enabled = true
exclude = false
in_interface = each.value.network_id
ip_address = each.value.endpoint.target
ip_version = each.value.endpoint.ip_version
protocol = "tcp_udp"
rule_index = each.value.rule_index
logging = false
is_predefined = false
pppoe_use_base_interface = false
setting_preference = "manual"
-51
View File
@@ -1,51 +0,0 @@
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
}