Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b34450b22e | ||
|
|
3faddcadcd | ||
|
|
6ae734eef4 | ||
|
|
39885b9400 | ||
|
|
694389b569 | ||
|
|
57bb39e7ee | ||
|
|
17f913ba2f | ||
|
|
53172dff74 | ||
|
|
196ebda188 | ||
|
|
1d76dba474 | ||
|
|
63f25c1511 | ||
|
|
3be75f535e | ||
|
|
db460079f8 | ||
|
|
ef40c68b1f | ||
|
|
6c31b552d1 | ||
|
|
d437775319 | ||
|
|
8a8145011a | ||
|
|
2df54d302b | ||
|
|
da0f895b6d | ||
|
|
5c856af578 | ||
|
|
9d69eb1a29 | ||
|
|
3d05d47239 | ||
|
|
fd35f5f21b | ||
|
|
d208c17509 | ||
|
|
853928b6b1 | ||
|
|
3fac750430 |
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user