diff --git a/scripts/deploy-stack.sh b/scripts/deploy-stack.sh index 7df28e2..d50156b 100755 --- a/scripts/deploy-stack.sh +++ b/scripts/deploy-stack.sh @@ -163,7 +163,13 @@ for entry in "${PAIRS[@]}"; do tmp_out=$(mktemp) tmp_err=$(mktemp) rc=0 - rsync -az --delete --dry-run \ + # --no-o --no-g: the deploy tree is root:docker 2775 (setgid) since the + # 2026-09-14 fleet normalisation, and plain -a makes rsync try to chgrp the + # destination as the deploy identity, which is not root. It fails with + # "chgrp ... Operation not permitted" and exits 23 AFTER transferring the + # content — a loud error on a deploy that actually succeeded. The setgid bit + # already assigns the right group, so rsync should not be fighting it. + rsync -az --no-o --no-g --delete --dry-run \ "${RSYNC_REMOTE[@]}" \ --out-format='%i %n' \ "${EXCLUDES[@]}" "${extra[@]}" \ @@ -285,7 +291,7 @@ for entry in "${PAIRS[@]}"; do extra=() [ "$kind" = compose ] && extra+=(--exclude='conf/') printf 'pushing %s → %s\n' "$src" "$dest" - rsync -az --delete \ + rsync -az --no-o --no-g --delete \ "${RSYNC_REMOTE[@]}" \ "${EXCLUDES[@]}" "${extra[@]}" \ "$src" "$dest" diff --git a/stacks/esphome/.env.example b/stacks/esphome/.env.example new file mode 100644 index 0000000..d6f7ed9 --- /dev/null +++ b/stacks/esphome/.env.example @@ -0,0 +1,5 @@ +# Real values live on the host at /opt/docker/compose/esphome/.env (root:docker +# 0640) and in Vaultwarden as `esh-docker-vm/esphome-dashboard`. +# Retrieve with: secret get esh-docker-vm/esphome-dashboard +ESPHOME_USERNAME=admin +ESPHOME_PASSWORD=change-me diff --git a/stacks/esphome/README.md b/stacks/esphome/README.md new file mode 100644 index 0000000..8022cbe --- /dev/null +++ b/stacks/esphome/README.md @@ -0,0 +1,34 @@ +# esphome — ESPHome Device Builder + +**Host:** esh-docker-vm (10.0.50.45) · **UI:** http://10.0.50.45:6052 · +**Config:** `/opt/docker/conf/esphome` · **Credentials:** Vaultwarden +`esh-docker-vm/esphome-dashboard` + +Firmware build + OTA dashboard for the ESH ESP32/ESP8266 fleet. As of +ESPHome 2026.6.0 the old dashboard is replaced by **ESPHome Device Builder +1.0.0**, a ground-up rewrite — same function, different UI. Not a fault. + +## Deploy + +```bash +scripts/deploy-stack.sh esh-docker-vm esphome +ssh infra-ops@esh-docker-vm 'cd /opt/docker/compose/esphome && sudo -n docker compose up -d' +``` + +The `.env` is never synced in either direction (see the root `CLAUDE.md` +exclusion list). It is created on the host once, from the vault. + +## Three things not to undo + +- **`privileged: true` and `network_mode: host` are required.** USB flashing + needs the first, mDNS discovery the second. Removing either breaks device + adoption and OTA. +- **Keep the image tag pinned.** A bare `ghcr.io/esphome/esphome` is how this + container sat on 2025.8.2 for a year — docker pulled `latest` once at + creation and never again. Every current Everything Presence sensor (Pro, One, + Lite) failed `esphome config` on that build. +- **`.esphome/platformio` and `.esphome/build` are excluded from restic** + (`/etc/restic/profiles.yaml`). They are 539 MB of regenerable PlatformIO + toolchain and build artifacts against 3 KB of actual config; without the + exclude, relocating this dir inflates the `/opt/docker` backup source ~45×. + PlatformIO re-downloads them on demand. diff --git a/stacks/esphome/compose.yaml b/stacks/esphome/compose.yaml new file mode 100644 index 0000000..dcb7769 --- /dev/null +++ b/stacks/esphome/compose.yaml @@ -0,0 +1,47 @@ +# ESPHome Device Builder — IoT firmware dashboard for the ESH ESP32/ESP8266 fleet. +# Host: esh-docker-vm (10.0.50.45). UI: http://10.0.50.45:6052 +# +# ⚠ privileged + network_mode: host are REQUIRED and must not be "hardened" away: +# USB flashing needs the former, mDNS device discovery the latter. Removing +# either breaks adoption and OTA. +# +# ⚠ The image tag is pinned deliberately. A bare `ghcr.io/esphome/esphome` +# drifted this container a full year: docker pulled `latest` once when the +# container was created (2026-04-20, from a layer already cached 2025-08-29) and +# never re-pulled, leaving it on 2025.8.2 — twelve releases behind — which +# silently failed config validation for every current Everything Presence +# sensor. Bump this line on purpose; do not un-pin it. +services: + esphome: + container_name: esphome + image: ghcr.io/esphome/esphome:2026.8.2 + volumes: + # Was /path/to/esphome/config — the upstream template placeholder, taken + # literally by docker. Nothing was broken (compose and the container + # agreed) but /path/to is in none of restic's sources, so the configs and + # secrets.yaml were absent from file-level backup. Moved 2026-09-14 to + # match the sibling mosquitto pattern. + - /opt/docker/conf/esphome:/config + - /etc/localtime:/etc/localtime:ro + restart: always + privileged: true + network_mode: host + environment: + # Values live in .env beside this file on the host (root:docker 0640) and + # in Vaultwarden at esh-docker-vm/esphome-dashboard. Never inline them + # here — this file is git-tracked. + # + # ⚠ Use the ESPHOME_-prefixed names, not bare USERNAME / PASSWORD. 2026.8.2 + # still honours the bare names but logs a DEPRECATION on every start and + # says they "will stop working in a future release" — i.e. a silent auth + # loss on some later bump, on a privileged host-network container that can + # flash firmware to anything on the LAN. Observed in the container log on + # the 2025.8.2 -> 2026.8.2 upgrade, 2026-09-14. + - ESPHOME_USERNAME=${ESPHOME_USERNAME} + - ESPHOME_PASSWORD=${ESPHOME_PASSWORD} + labels: + - homepage.group=Apps + - homepage.name=ESPHome + - homepage.icon=si-esphome + - homepage.description=IoT firmware dashboard (ESP32/ESP8266) + - homepage.href=http://10.0.50.45:6052