diff --git a/scripts/deploy-stack.sh b/scripts/deploy-stack.sh index 2085c65..fd3152d 100755 --- a/scripts/deploy-stack.sh +++ b/scripts/deploy-stack.sh @@ -163,17 +163,31 @@ for entry in "${PAIRS[@]}"; do tmp_out=$(mktemp) tmp_err=$(mktemp) rc=0 - # --no-o --no-g --omit-dir-times: the deploy tree is root:docker 2775 (setgid) since the + # THE RULE: the deploy syncs CONTENT; the conventions own METADATA. + # + # --no-o --no-g --no-perms --omit-dir-times. 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. - # --omit-dir-times is the same problem one layer along: a non-root identity - # cannot utime() a root-owned directory either, which produced the identical - # "exit 23 after a successful transfer" on the next deploy. Both halves are - # needed; fixing only the group half looks fixed until the next run. - rsync -az --no-o --no-g --omit-dir-times --delete --dry-run \ + # This was fixed three times in one session before the rule above was + # written down, because `-a` is `-rlptgoD` and a non-root identity cannot + # apply ANY of owner, group, permissions or times to a root-owned directory. + # Each patch fixed one letter and the next deploy failed on the next one: + # chgrp failed -> --no-o --no-g + # failed to set times -> --omit-dir-times + # failed to set perms -> --no-perms + # Every one of them exited 23 AFTER transferring the content — a loud error + # on a deploy that had succeeded — so each partial fix looked complete until + # the next run. Enumerate the flag set, don't chase the symptom. + # + # ⚠ --no-perms means a NEW file lands with the remote umask rather than the + # source's mode, so a stack shipping an executable script needs its +x set by + # the convention (playbooks/normalize-docker-tree.yaml preserves exec bits), + # not by the deploy. Existing files keep their modes. + rsync -az --no-o --no-g --no-perms --omit-dir-times --delete --dry-run \ "${RSYNC_REMOTE[@]}" \ --out-format='%i %n' \ "${EXCLUDES[@]}" "${extra[@]}" \ @@ -295,7 +309,7 @@ for entry in "${PAIRS[@]}"; do extra=() [ "$kind" = compose ] && extra+=(--exclude='conf/') printf 'pushing %s → %s\n' "$src" "$dest" - rsync -az --no-o --no-g --omit-dir-times --delete \ + rsync -az --no-o --no-g --no-perms --omit-dir-times --delete \ "${RSYNC_REMOTE[@]}" \ "${EXCLUDES[@]}" "${extra[@]}" \ "$src" "$dest" diff --git a/stacks/esphome/README.md b/stacks/esphome/README.md index 8022cbe..9c43f97 100644 --- a/stacks/esphome/README.md +++ b/stacks/esphome/README.md @@ -18,6 +18,23 @@ ssh infra-ops@esh-docker-vm 'cd /opt/docker/compose/esphome && sudo -n docker co 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. +## Remote build is off + +Device Builder 1.0.0 ships remote-build **on by default** and it binds +`0.0.0.0:6055` with mDNS advertisement. Disabled 2026-09-14 via the dashboard's +own `/ws` API — `remote_build/set_settings {"enabled": false}` — persisted at +`_remote_build.enabled` in `/config/.device-builder.json`. + +⚠ **There are two switches and only one closes the port.** +`remote_build/set_offloader_settings {remote_builds_enabled}` is the *outbound* +half; setting it false persists, reads back false, and leaves the receiver +listening. `remote_build/set_settings {enabled}` is the receiver-side master +switch. The one whose name reads like the master switch is not. + +`ESPHOME_REMOTE_BUILD_HOST=127.0.0.1` is kept as a backstop: the off state lives +in one JSON file whose in-code default is `True`, so a lost or corrupt settings +file re-enables the feature silently. + ## Three things not to undo - **`privileged: true` and `network_mode: host` are required.** USB flashing diff --git a/stacks/esphome/compose.yaml b/stacks/esphome/compose.yaml index 135cb5f..3e2ef03 100644 --- a/stacks/esphome/compose.yaml +++ b/stacks/esphome/compose.yaml @@ -49,12 +49,26 @@ services: # unrequested LAN reachability on a privileged host-network container # whose job is writing firmware to devices. # - # ⚠ This binds it to loopback; it does NOT disable it. The real off - # switch is a Settings toggle in the UI, and its enabled flag has no - # on-disk representation until it is changed, so there is nothing to set - # from a playbook without inventing a schema. Flip it in Settings and - # this line can go. (ha-dev's stated preference, correctly, is not - # enabling a feature over restricting its bind address.) + # RESOLVED 2026-09-14: remote-build is now genuinely OFF — + # `remote_build/set_settings {enabled: false}` over the dashboard's /ws + # API, persisted at `_remote_build.enabled` in /config/.device-builder.json. + # Verified across a restart: no listener, no mDNS advertisement. + # + # ⚠ Two switches, and only one of them closes the port. The obvious + # candidate — `remote_build/set_offloader_settings {remote_builds_enabled}` + # — is the OUTBOUND half (this dashboard sending builds to peers). Setting + # it to false persists, reads back false, and leaves the receiver + # listening. The port belongs to `ReceiverController.set_settings`, whose + # own docstring calls it "the receiver-side `enabled` master switch". + # Turning off the one named `remote_builds_enabled` looks like the fix and + # is not. + # + # This line is now the BACKSTOP, kept deliberately. The off state lives in + # a single JSON file whose in-code default is `enabled: True` + # (controllers/remote_build/_state.py), and this module's stores + # soft-recover to an empty model on a malformed blob rather than erroring + # — so a lost or corrupt settings file silently re-enables remote-build. + # With this set, that regression binds loopback instead of 0.0.0.0. - ESPHOME_REMOTE_BUILD_HOST=127.0.0.1 labels: - homepage.group=Apps