Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4f44b2002 | ||
|
|
3faddcadcd | ||
|
|
6ae734eef4 | ||
|
|
39885b9400 | ||
|
|
57bb39e7ee | ||
|
|
17f913ba2f |
@@ -66,7 +66,52 @@ 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 2 — migrate the data (NOT in this PR)
|
||||
## 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.
|
||||
|
||||
@@ -90,7 +135,18 @@ HA outage, and buys a table that is discarded within 7 days anyway.
|
||||
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:
|
||||
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
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -362,6 +362,22 @@ spec:
|
||||
name: trmnl-renderer-schedule
|
||||
- name: tmp
|
||||
emptyDir:
|
||||
# RAM-backed, NOT disk. Without `medium: Memory` an emptyDir lands
|
||||
# on the node's filesystem, and this one was the single largest
|
||||
# writer in the cluster: 249 GB over 7.3 days = ~34 GB/day onto
|
||||
# /dev/vda5, more than every Ceph OSD and every database combined
|
||||
# (measured 2026-08-20 from the container's cgroup io.stat).
|
||||
#
|
||||
# The cause is cadence, not volume. schedules.template.json renders
|
||||
# every 60 s, so Chrome is launched 1440 times a day and each launch
|
||||
# creates, fills and deletes a /tmp/puppeteer_dev_chrome_profile-*
|
||||
# directory. At rest /tmp holds under 8 MB; it is pure churn for
|
||||
# data that never outlives the render.
|
||||
#
|
||||
# tmpfs pages count against the container's memory limit, which is
|
||||
# why the cap stays at 512Mi: measured usage is 129Mi against a 2Gi
|
||||
# limit, so even the full cap leaves ample headroom.
|
||||
medium: Memory
|
||||
sizeLimit: 512Mi
|
||||
---
|
||||
apiVersion: v1
|
||||
|
||||
Reference in New Issue
Block a user