Compare commits

...
Author SHA1 Message Date
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
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
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
27 changed files with 1047 additions and 171 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
@@ -121,7 +121,9 @@ spec:
persistentVolumeClaim:
claimName: audiobookshelf-books
- name: le-ca
emptyDir: {}
emptyDir:
medium: Memory
sizeLimit: 16Mi
---
apiVersion: v1
kind: Service
@@ -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.
+64 -2
View File
@@ -36,6 +36,64 @@ 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"}
@@ -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,90 @@
---
# 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:
shared_buffers: "256MB"
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.
wal_compression: "on"
# Fewer checkpoints => fewer full-page writes, since a page is only
# fully-logged on its first modification after a checkpoint. 15min with a
# 2GB ceiling keeps recovery time sane while cutting repeat full-page
# images for the hot rows the recorder rewrites constantly.
checkpoint_timeout: "15min"
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: 1Gi
@@ -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
@@ -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
+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
@@ -64,7 +64,9 @@ spec:
secret:
secretName: pve-exporter-config
- name: tmp
emptyDir: {}
emptyDir:
medium: Memory
sizeLimit: 16Mi
---
apiVersion: v1
kind: Service
+3 -1
View File
@@ -218,7 +218,9 @@ spec:
persistentVolumeClaim:
claimName: paperless-consume
- name: bridge-cert
emptyDir: {}
emptyDir:
medium: Memory
sizeLimit: 16Mi
---
apiVersion: v1
kind: Service
@@ -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
@@ -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:
@@ -45,18 +45,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
@@ -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,23 @@ metadata:
name: cephfs
namespace: rook-ceph
spec:
# metadataPool STAYS at size 3 while the data pool drops to 2. It holds
# 467 MiB against the data pool's 275 GiB, so it contributes nothing
# measurable to drive wear — the entire reason for the size reduction — while
# losing it would destroy the whole filesystem, not just the objects on the
# lost OSD. Paying 3x on half a gigabyte to keep that intact is the right
# trade; the wear saving comes from data0.
metadataPool:
replicated:
size: 3
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"
@@ -85,4 +85,6 @@ spec:
name: restore-test-script
defaultMode: 0755
- name: tmp
emptyDir: {}
emptyDir:
medium: Memory
sizeLimit: 16Mi
-122
View File
@@ -1,122 +0,0 @@
# TEMPORARY shims v3 — 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.
#
# v3: v2 declared ONLY the ids, `started`, `unprivileged` and the timeouts,
# claiming "the other attributes mirror the real containers so the plan stays
# in-place". They did not. Everything absent from config planned as a removal,
# and two of those removals — `mount_point` and `operating_system` — force
# replacement, so the plan came back `must be replaced` for all three. Applying
# that would have destroyed and recreated containers tagged `critical`,
# `state-backend`, `terraform-backend` instead of nudging a timeout into state.
#
# So the blocks below are not decoration: each one exists to keep an attribute
# out of the diff. They mirror the live containers, read from the plan's own
# state values. `tags` is declared for the same reason — without it the apply
# strips the tags off the real containers.
#
# 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 {
# Per-container values mirrored from live state. hostname and mac_address
# differ per container; declaring them wrong would itself force a diff.
rustfs_decommission = {
"800" = { node = "saturn", hostname = "rustfs-1.srv.renner.uno", mac = "BC:24:11:0B:E5:7B" }
"801" = { node = "jupiter", hostname = "rustfs-2.srv.renner.uno", mac = "BC:24:11:EA:1D:38" }
"802" = { node = "neptune", hostname = "rustfs-3.srv.renner.uno", mac = "BC:24:11:8F:F4:67" }
}
}
resource "proxmox_virtual_environment_container" "rustfs_decommission" {
for_each = local.rustfs_decommission
node_name = each.value.node
vm_id = tonumber(each.key)
started = false
unprivileged = true
start_on_boot = true
tags = [
"critical",
"rustfs",
"state-backend",
"terraform-backend",
]
timeout_create = 1800
timeout_clone = 1800
timeout_update = 1800
timeout_delete = 1800
timeout_start = 300
cpu {
architecture = "amd64"
cores = 1
units = 1024
}
memory {
dedicated = 1024
swap = 0
}
disk {
datastore_id = "local"
size = 10
}
# forces replacement if absent — this is the block that broke v2.
mount_point {
path = "/mnt/data"
size = "50G"
volume = "local:${each.key}/vm-${each.key}-disk-1.raw"
backup = true
replicate = true
}
network_interface {
name = "eth0"
bridge = "v40srv"
enabled = true
firewall = false
mac_address = each.value.mac
}
initialization {
hostname = each.value.hostname
ip_config {
ipv4 {
address = "dhcp"
}
}
}
# operating_system is NOT declared, and must not be. The provider requires
# template_file_id inside it, which is a create-time-only value: PVE does not
# report the original template for an existing container, so it is absent
# from state and cannot be mirrored. Declaring the block without it fails
# validation ("Missing required argument"); declaring it with a guessed
# template would be a lie that only surfaces if something ever recreates
# these.
#
# Left undeclared it would plan as a removal, and removing operating_system
# forces replacement — the exact failure this file exists to avoid. So it is
# ignored instead. The timeouts above are deliberately NOT ignored: they are
# the one thing this shim needs to actually apply.
lifecycle {
ignore_changes = [operating_system]
}
}
+19 -2
View File
@@ -59,8 +59,25 @@ 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
+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"