feat(zigbee2mqtt): add re-interview watchdog sidecar #441

Merged
k.renner merged 6 commits from z2m-interview-watchdog into master 2026-07-30 07:11:25 +00:00
Member

Problem

The ZHA→Z2M cutover (2026-07-23) adopted the existing Zigbee network, so devices stayed joined but database.db started empty. Every device needs an interview before Z2M knows its modelId and can match a converter.

Mains-powered routers interviewed immediately. 16 battery end devices did not, and they do not self-heal — zigbee-herdsman only retries an interview on a ZDO device announce, which sleepy devices essentially never emit. They sat PENDING for six days across 38 process restarts, exposing only a generic "Automatically generated definition" (battery/linkquality) instead of the real action/contact/moisture entities.

Concretely, this is why the water-leak notification automation cannot fire: all three of its trigger entities are either absent or attribute-less.

Approach

A device that transmits is awake right now, which is exactly when an interview can succeed. The sidecar watches Z2M's log for the zh:controller frame-receipt line and fires a forced interview for any device still PENDING/FAILED.

Verified live before opening this PR — interviews completed in 11–34s, and the tally moved 13 → 17 SUCCESSFUL during testing:

05:42:58Z FRAME from 'EG Bad Rollo Schalter' (nwk=48752, cluster=25) -> firing interview
[07:43:09] z2m: Successfully interviewed 'EG Bad Rollo Schalter'

Two of those fired with no human interaction at all — a periodic OTA check (cluster 25) and an illuminance report (cluster 1024) — which is the property that makes this converge on its own. Xiaomi/Aqara magnet sensors interviewed successfully too, despite their short wake window.

Implementation notes

  • Sidecar, not a separate Deployment: it reads Z2M's log file and database.db, and the PVC is RWO/single-writer. Mounted readOnly.
  • Signal is the log file, not MQTT: an un-interviewed Xiaomi device publishes nothing to MQTT (its manufacturer-specific attributes can't be mapped by the auto-generated definition), and bridge/logging carries only info. This was measured, not assumed — 0 published topics for the lumi sensors across the whole log vs 3 for a Philips one.
  • Namespaced debug, not global: needs advanced.log_namespaced_levels {zh:controller: debug}. Global log_level: debug adds the unpi:parser byte-level firehose — ~90 lines/min measured, vs ~6 lines/min for the namespaced form.
  • database.db is newline-delimited JSON despite the extension, so the nwk→IEEE lookup needs no jq. It's re-read per frame, which keeps it correct across network-address changes and drops devices automatically as they pass.

Follow-up required (not in this PR)

Interviewing is only half the job. Philips devices also need configure to bind manuSpecificPhilips (0xFC00), and that cluster is registered only on Z2M's start event (deviceAddCustomCluster onEvent). A device interviewed mid-run therefore stays unbound until the next restart, so one Z2M restart after a batch of interviews is needed to complete the binds and turn the Hue switches into working button entities.

Temporary by design

Both the sidecar and the namespaced log level should be removed once every device is interviewed. Comments in both files say so.

Note also that the config ConfigMap is seed-only — the live PVC copy already carries log_namespaced_levels, set at runtime, so repo and live state agree.

🤖 Generated with Claude Code

## Problem The ZHA→Z2M cutover (2026-07-23) adopted the existing Zigbee network, so devices stayed joined but `database.db` started empty. Every device needs an **interview** before Z2M knows its `modelId` and can match a converter. Mains-powered routers interviewed immediately. **16 battery end devices did not, and they do not self-heal** — zigbee-herdsman only retries an interview on a ZDO device *announce*, which sleepy devices essentially never emit. They sat `PENDING` for six days across 38 process restarts, exposing only a generic `"Automatically generated definition"` (battery/linkquality) instead of the real `action`/`contact`/`moisture` entities. Concretely, this is why the water-leak notification automation cannot fire: all three of its trigger entities are either absent or attribute-less. ## Approach A device that transmits **is awake right now**, which is exactly when an interview can succeed. The sidecar watches Z2M's log for the `zh:controller` frame-receipt line and fires a forced interview for any device still `PENDING`/`FAILED`. Verified live before opening this PR — interviews completed in **11–34s**, and the tally moved **13 → 17 SUCCESSFUL** during testing: ``` 05:42:58Z FRAME from 'EG Bad Rollo Schalter' (nwk=48752, cluster=25) -> firing interview [07:43:09] z2m: Successfully interviewed 'EG Bad Rollo Schalter' ``` Two of those fired with **no human interaction at all** — a periodic OTA check (cluster 25) and an illuminance report (cluster 1024) — which is the property that makes this converge on its own. Xiaomi/Aqara magnet sensors interviewed successfully too, despite their short wake window. ## Implementation notes - **Sidecar, not a separate Deployment**: it reads Z2M's log file and `database.db`, and the PVC is RWO/single-writer. Mounted `readOnly`. - **Signal is the log file, not MQTT**: an un-interviewed Xiaomi device publishes *nothing* to MQTT (its manufacturer-specific attributes can't be mapped by the auto-generated definition), and `bridge/logging` carries only `info`. This was measured, not assumed — 0 published topics for the lumi sensors across the whole log vs 3 for a Philips one. - **Namespaced debug, not global**: needs `advanced.log_namespaced_levels {zh:controller: debug}`. Global `log_level: debug` adds the `unpi:parser` byte-level firehose — **~90 lines/min measured, vs ~6 lines/min** for the namespaced form. - `database.db` is newline-delimited JSON despite the extension, so the nwk→IEEE lookup needs no `jq`. It's re-read per frame, which keeps it correct across network-address changes and drops devices automatically as they pass. ## Follow-up required (not in this PR) Interviewing is only half the job. Philips devices also need `configure` to bind `manuSpecificPhilips` (0xFC00), and that cluster is registered **only on Z2M's `start` event** (`deviceAddCustomCluster` `onEvent`). A device interviewed mid-run therefore stays unbound until the next restart, so **one Z2M restart after a batch of interviews** is needed to complete the binds and turn the Hue switches into working button entities. ## Temporary by design Both the sidecar and the namespaced log level should be removed once every device is interviewed. Comments in both files say so. Note also that the config ConfigMap is seed-only — the live PVC copy already carries `log_namespaced_levels`, set at runtime, so repo and live state agree. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
claude-bot added 1 commit 2026-07-30 06:01:36 +00:00
feat(zigbee2mqtt): add re-interview watchdog sidecar
ci/woodpecker/pr/pr-review Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
b39a186b7e
The ZHA->Z2M cutover adopted the existing Zigbee network, so devices
stayed joined but database.db started empty — every device needs an
interview before Z2M knows its modelId and can match a converter.

Mains routers interviewed immediately. 16 battery end devices did not,
and they do not self-heal: zigbee-herdsman only retries an interview on
a ZDO device announce, which sleepy devices essentially never emit. They
sat PENDING for six days across 38 process restarts, exposing only a
generic "Automatically generated definition" (battery/linkquality)
instead of action/contact/moisture entities.

A device that transmits is awake, which is exactly when an interview can
succeed. The sidecar watches Z2M's log for the zh:controller frame-receipt
line and fires a forced interview for any device still PENDING/FAILED.
Verified in practice: interviews completed in 11-34s, including
Xiaomi/Aqara sensors, and two fired with no human interaction at all
(a periodic OTA check and an illuminance report).

Implementation notes:

- Sidecar, not a separate Deployment: it reads Z2M's log file and
  database.db, and the PVC is RWO/single-writer. Mounted read-only.
- Signal comes from the log file, not MQTT: an un-interviewed Xiaomi
  device publishes nothing at all (its manufacturer-specific attributes
  cannot be mapped by the auto-generated definition), and
  bridge/logging carries only info-level messages.
- Needs advanced.log_namespaced_levels {zh:controller: debug}. Namespaced
  rather than global debug on purpose: global adds the unpi:parser
  byte-level firehose (~90 lines/min measured) vs ~6 lines/min here.
- database.db is newline-delimited JSON despite the extension, so the
  nwk->IEEE lookup needs no jq and is re-read per frame, which keeps it
  correct across network-address changes and drops devices as they pass.

Interviewing is only half the job: Philips devices also need configure to
bind manuSpecificPhilips (0xFC00), and that cluster is registered only on
Z2M's start event, so a device interviewed mid-run stays unbound until the
next restart. One restart after a batch of interviews completes the binds.

Both the sidecar and the namespaced log level are temporary — remove them
once every device is interviewed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
review-bot left a comment
Member

🤖 Automated review — model claude-sonnet-5 (complexity: normal), commit b39a186b7edaf534672e4f5f713bfd0a7b8bdefc.

Advisory review by the review-bot pipeline. It never approves; a human merges. Push new commits to get a fresh review.


Summary

Adds a read-only sidecar (interview-watch) to the zigbee2mqtt Deployment that tails Z2M's log for frame-receipt lines and fires bridge/request/device/interview for any device still PENDING/FAILED in database.db, plus the namespaced debug log level it depends on. The change is well-reasoned and thoroughly documented (rationale in three places: PR body, config comment, script header). I found no blocking defects — the design correctly uses the existing MQTT ACL, mounts the PVC readOnly, and matches the repo's existing conventions (Alpine sidecar with apk add at start, no per-container securityContext, disableNameSuffixHash ConfigMap pattern). A few non-blocking robustness points below.

🟡 Suggestions

  • kubernetes/apps/home-assistant/zigbee2mqtt-deployment.yaml:158-160 / zigbee2mqtt-interview-watch.sh:228-231: the sidecar deliberately exit 1s whenever the log stream ends (rotation or Z2M restart) so kubelet restarts it, and the restart re-runs apk add --no-cache mosquitto-clients over the network every time. Kubelet applies exponential crash-loop backoff to container restarts (capped at 5 min) unless the container has run ≥10 min since its last exit. Log rotation and Z2M restarts can occur less than 10 min apart (e.g. during the "batch of interviews + one restart" workflow described in the PR body), which would compound backoff delays right when responsiveness to a device's short wake window matters most. Baking mosquitto-clients into a custom image (or an init container that installs it once into a shared emptyDir) would remove both the network dependency and the backoff risk. Given this is explicitly a temporary tool, this is a "nice to have," not a blocker.
  • zigbee2mqtt-interview-watch.sh:174-178: ieee_needing_interview extracts ieeeAddr/nwkAddr with a single sed pattern that assumes "ieeeAddr" always appears before "nwkAddr" on the same JSON line in database.db. This isn't guaranteed by the JSON spec — only by herdsman's current serialization order. If that order ever changes (version bump), the sed captures nothing and the device is silently never picked up (no error, no log) rather than failing loudly. Since the PR confirms this was tested against the live data and the tool is explicitly temporary, this is low risk, but a comment noting the assumption (or a more robust grep -o per field) would make a future silent-failure easier to diagnose.
  • kubernetes/apps/home-assistant/zigbee2mqtt-migration-runbook.md:132-145 ("Step 5 — re-interview the devices"): this manual re-interview procedure is now partially superseded by the automated watchdog for the battery/sleepy-device case. Not required for this PR, but worth a follow-up note so operators don't manually chase devices the sidecar is already handling.

🟢 Nits

  • zigbee2mqtt-interview-watch.sh:219: mosquitto_pub -P "$MQTT_PASSWORD" puts the password on the command line, visible via /proc/<pid>/cmdline to anything in the same container/PID namespace. The pod doesn't set shareProcessNamespace: true, so this isn't visible to the other containers in the pod, and it's the same credential already used elsewhere in this file via env vars — not a new exposure, just noting it's the one place the password is also handled via argv rather than purely env/file.

Tests

No automated tests are practical or expected for this change (Kubernetes manifests + a shell sidecar script), and the repo's CI (.woodpecker/ci-scripts-test.yml, deploy-infrastructure.yml) doesn't run kustomize build, kubeconform, or shellcheck against kubernetes/apps/**, so a YAML/kustomize wiring mistake wouldn't be caught before merge. I manually verified the wiring: the watch.sh=zigbee2mqtt-interview-watch.sh ConfigMap key matches the /script/watch.sh path the container execs, the MQTT_HOST/MQTT_USER defaults (mosquitto / zigbee2mqtt) match the actual Service name and the ACL user with readwrite zigbee2mqtt/#, and the script's sh -n syntax reads clean (not executed in this sandbox, but I reviewed it line-by-line). The PR body states the author already verified the behavior live against production (interviews completing in 11–34s, tally moving 13→17), which is reasonable in lieu of unit tests for a script this environment-coupled.

Recommendation

Looks reasonable — no blocking issues; comments above are optional.

🤖 **Automated review** — model `claude-sonnet-5` (complexity: normal), commit `b39a186b7edaf534672e4f5f713bfd0a7b8bdefc`. > Advisory review by the `review-bot` pipeline. It never approves; a human merges. Push new commits to get a fresh review. --- ## Summary Adds a read-only sidecar (`interview-watch`) to the `zigbee2mqtt` Deployment that tails Z2M's log for frame-receipt lines and fires `bridge/request/device/interview` for any device still `PENDING`/`FAILED` in `database.db`, plus the namespaced debug log level it depends on. The change is well-reasoned and thoroughly documented (rationale in three places: PR body, config comment, script header). I found no blocking defects — the design correctly uses the existing MQTT ACL, mounts the PVC `readOnly`, and matches the repo's existing conventions (Alpine sidecar with `apk add` at start, no per-container `securityContext`, `disableNameSuffixHash` ConfigMap pattern). A few non-blocking robustness points below. ## 🟡 Suggestions - `kubernetes/apps/home-assistant/zigbee2mqtt-deployment.yaml:158-160` / `zigbee2mqtt-interview-watch.sh:228-231`: the sidecar deliberately `exit 1`s whenever the log stream ends (rotation or Z2M restart) so kubelet restarts it, and the restart re-runs `apk add --no-cache mosquitto-clients` over the network every time. Kubelet applies exponential crash-loop backoff to container restarts (capped at 5 min) unless the container has run ≥10 min since its last exit. Log rotation and Z2M restarts can occur less than 10 min apart (e.g. during the "batch of interviews + one restart" workflow described in the PR body), which would compound backoff delays right when responsiveness to a device's short wake window matters most. Baking `mosquitto-clients` into a custom image (or an init container that installs it once into a shared `emptyDir`) would remove both the network dependency and the backoff risk. Given this is explicitly a temporary tool, this is a "nice to have," not a blocker. - `zigbee2mqtt-interview-watch.sh:174-178`: `ieee_needing_interview` extracts `ieeeAddr`/`nwkAddr` with a single `sed` pattern that assumes `"ieeeAddr"` always appears before `"nwkAddr"` on the same JSON line in `database.db`. This isn't guaranteed by the JSON spec — only by herdsman's current serialization order. If that order ever changes (version bump), the sed captures nothing and the device is silently never picked up (no error, no log) rather than failing loudly. Since the PR confirms this was tested against the live data and the tool is explicitly temporary, this is low risk, but a comment noting the assumption (or a more robust `grep -o` per field) would make a future silent-failure easier to diagnose. - `kubernetes/apps/home-assistant/zigbee2mqtt-migration-runbook.md:132-145` ("Step 5 — re-interview the devices"): this manual re-interview procedure is now partially superseded by the automated watchdog for the battery/sleepy-device case. Not required for this PR, but worth a follow-up note so operators don't manually chase devices the sidecar is already handling. ## 🟢 Nits - `zigbee2mqtt-interview-watch.sh:219`: `mosquitto_pub -P "$MQTT_PASSWORD"` puts the password on the command line, visible via `/proc/<pid>/cmdline` to anything in the same container/PID namespace. The pod doesn't set `shareProcessNamespace: true`, so this isn't visible to the other containers in the pod, and it's the same credential already used elsewhere in this file via env vars — not a new exposure, just noting it's the one place the password is also handled via argv rather than purely env/file. ## Tests No automated tests are practical or expected for this change (Kubernetes manifests + a shell sidecar script), and the repo's CI (`.woodpecker/ci-scripts-test.yml`, `deploy-infrastructure.yml`) doesn't run `kustomize build`, `kubeconform`, or `shellcheck` against `kubernetes/apps/**`, so a YAML/kustomize wiring mistake wouldn't be caught before merge. I manually verified the wiring: the `watch.sh=zigbee2mqtt-interview-watch.sh` ConfigMap key matches the `/script/watch.sh` path the container execs, the `MQTT_HOST`/`MQTT_USER` defaults (`mosquitto` / `zigbee2mqtt`) match the actual Service name and the ACL user with `readwrite zigbee2mqtt/#`, and the script's `sh -n` syntax reads clean (not executed in this sandbox, but I reviewed it line-by-line). The PR body states the author already verified the behavior live against production (interviews completing in 11–34s, tally moving 13→17), which is reasonable in lieu of unit tests for a script this environment-coupled. ## Recommendation Looks reasonable — no blocking issues; comments above are optional. <!-- pr-review:sha=b39a186b7edaf534672e4f5f713bfd0a7b8bdefc -->
claude-bot added 1 commit 2026-07-30 06:11:00 +00:00
fix(zigbee2mqtt): address review on interview watchdog
ci/woodpecker/pr/pr-review Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ead4aa3f01
Review points from review-bot on #441.

Stay resident instead of exiting on log-stream end. Previously the
sidecar exit 1'd so the kubelet would restart it and re-resolve the log
file, but that re-ran `apk add mosquitto-clients` over the network every
time and, worse, incurred kubelet crash-loop backoff — which climbs to
5 minutes unless the container stays up >=10 min. Z2M restarts and log
rotations land closer together than that (notably during the "interview
a batch, then restart Z2M once" workflow), so backoff would have kicked
in exactly when a device's short wake window needs a prompt reaction.
The log file is now re-resolved in an outer loop, which also drops the
post-startup network dependency. Chosen over baking a custom image
because it removes the restart entirely rather than making restarts
cheaper.

Match ieeeAddr/nwkAddr independently rather than with one combined sed
pattern. JSON does not guarantee key order, so the old pattern's
assumption that "ieeeAddr" precedes "nwkAddr" held only by virtue of
herdsman's current serialization. Had that changed, the regex would
capture nothing and a device would silently never be picked up — no
error, no log. Now extracted per-field with awk match(), with the
rationale recorded in a comment. Verified against the live database.db
under busybox awk (the sidecar runs alpine): resolves both still-pending
devices, and correctly returns nothing for one that has since been
interviewed.

Flag Step 5 of the migration runbook as automated, so operators do not
hand-chase devices the sidecar already handles. Includes the two
caveats that cost real debugging time: interviewing does not bind
manuSpecificPhilips (needs one Z2M restart), and a device that never
transmits is offline rather than un-interviewed — check lastSeen before
hunting a Z2M-side fault, because a flat battery presents identically.

Not changed: mosquitto_pub takes a password only via argv (-P, or an -L
URL) — it has no --pw-file or env option, verified against the client in
the cluster — so the 🟢 nit cannot be resolved with this client. Not a
cross-container exposure, as the pod does not set shareProcessNamespace;
noted in a comment at the call site.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Author
Member

Thanks — all three 🟡 points addressed in ead4aa3f, and the 🟢 nit investigated and justified rather than ignored.

🟡 1 — crash-loop backoff + apk add on every restart

Fixed, and you undersold the problem. You're right that backoff climbs to 5 min unless the container stays up ≥10 min, and right that rotations/restarts land closer together than that. I took a different fix than the two you suggested: rather than making restarts cheaper (custom image / shared emptyDir), the sidecar now never exits — the log file is re-resolved in an outer while loop. That removes the backoff exposure entirely instead of reducing it, and drops the post-startup network dependency as a side effect, so it also satisfies the custom-image motivation without adding an image to build and maintain for something explicitly temporary.

grep -c 'exit 1' on the script is now 0.

🟡 2 — sed assuming ieeeAddr precedes nwkAddr

Fixed. Correct call, and the silent-failure mode was the important part — a key-order change would have meant devices quietly never getting picked up, with no error and no log line. Now each field is matched independently via awk match(), so order is irrelevant, and the reasoning is recorded in a comment at the function.

Verified against the live database.db, and specifically under busybox awk since the sidecar runs alpine:3.21 (my workstation has gawk, so testing locally would have proved nothing):

nwk 1161   -> 0x00158d0007df9ba0
nwk 64295  -> 0x00158d0007ef57be
nwk 6312   -> <none>

The third is the useful one: 6312 was pending when the PR was opened and has since been interviewed, so it correctly drops out — confirming the "stops targeting devices as they pass" property, not just the happy path.

🟡 3 — runbook Step 5 superseded

Fixed. Step 5 now carries a callout that the battery/sleepy-device case is automated and should not be hand-chased. I also folded in the two things that cost the most debugging time getting here:

  • interviewing does not bind manuSpecificPhilips — that needs one Z2M restart, so an interviewed Hue switch still has no action entities until then;
  • a device that never transmits is offline, not un-interviewed — check lastSeen/empty endpoints[].clusters first, because a flat battery is indistinguishable from a failed interview otherwise. Four devices in this network are in exactly that state, dead 2–17 months, and they read as "un-interviewed" at a glance.

🟢 Nit — password on argv

Not fixed; not fixable with this client. I checked mosquitto_pub --help against the actual binary in the cluster: the only ways to supply a password are -P and an -L URL, both argv. There is no --pw-file and no env-var form. Your own assessment holds — the pod doesn't set shareProcessNamespace, so /proc isn't shared with the Z2M container, and it's the same credential already mounted here. I've noted the constraint in a comment at the call site so nobody re-litigates it.

On the CI-coverage observation

Agreed and worth stating plainly: nothing in CI runs kustomize build, kubeconform, or shellcheck over kubernetes/apps/**, so a wiring mistake here would reach the cluster. I ran kubectl kustomize plus a server-side --dry-run (deployment.apps/zigbee2mqtt configured, configmap/zigbee2mqtt-interview-watch-script created) and sh -n on both revisions. That's a manual substitute for a gap that arguably deserves its own PR — happy to open one for a lint/validate step if wanted, but it's out of scope here.

Thanks — all three 🟡 points addressed in `ead4aa3f`, and the 🟢 nit investigated and justified rather than ignored. ### 🟡 1 — crash-loop backoff + `apk add` on every restart **Fixed, and you undersold the problem.** You're right that backoff climbs to 5 min unless the container stays up ≥10 min, and right that rotations/restarts land closer together than that. I took a different fix than the two you suggested: rather than making restarts *cheaper* (custom image / shared `emptyDir`), the sidecar now **never exits** — the log file is re-resolved in an outer `while` loop. That removes the backoff exposure entirely instead of reducing it, and drops the post-startup network dependency as a side effect, so it also satisfies the custom-image motivation without adding an image to build and maintain for something explicitly temporary. `grep -c 'exit 1'` on the script is now `0`. ### 🟡 2 — `sed` assuming `ieeeAddr` precedes `nwkAddr` **Fixed.** Correct call, and the silent-failure mode was the important part — a key-order change would have meant devices quietly never getting picked up, with no error and no log line. Now each field is matched independently via `awk` `match()`, so order is irrelevant, and the reasoning is recorded in a comment at the function. Verified against the live `database.db`, and specifically under **busybox awk** since the sidecar runs `alpine:3.21` (my workstation has gawk, so testing locally would have proved nothing): ``` nwk 1161 -> 0x00158d0007df9ba0 nwk 64295 -> 0x00158d0007ef57be nwk 6312 -> <none> ``` The third is the useful one: `6312` was pending when the PR was opened and has since been interviewed, so it correctly drops out — confirming the "stops targeting devices as they pass" property, not just the happy path. ### 🟡 3 — runbook Step 5 superseded **Fixed.** Step 5 now carries a callout that the battery/sleepy-device case is automated and should not be hand-chased. I also folded in the two things that cost the most debugging time getting here: - interviewing does **not** bind `manuSpecificPhilips` — that needs one Z2M restart, so an interviewed Hue switch still has no `action` entities until then; - a device that never transmits is **offline, not un-interviewed** — check `lastSeen`/empty `endpoints[].clusters` first, because a flat battery is indistinguishable from a failed interview otherwise. Four devices in this network are in exactly that state, dead 2–17 months, and they read as "un-interviewed" at a glance. ### 🟢 Nit — password on argv **Not fixed; not fixable with this client.** I checked `mosquitto_pub --help` against the actual binary in the cluster: the only ways to supply a password are `-P` and an `-L` URL, both argv. There is no `--pw-file` and no env-var form. Your own assessment holds — the pod doesn't set `shareProcessNamespace`, so `/proc` isn't shared with the Z2M container, and it's the same credential already mounted here. I've noted the constraint in a comment at the call site so nobody re-litigates it. ### On the CI-coverage observation Agreed and worth stating plainly: nothing in CI runs `kustomize build`, `kubeconform`, or `shellcheck` over `kubernetes/apps/**`, so a wiring mistake here would reach the cluster. I ran `kubectl kustomize` plus a **server-side** `--dry-run` (`deployment.apps/zigbee2mqtt configured`, `configmap/zigbee2mqtt-interview-watch-script created`) and `sh -n` on both revisions. That's a manual substitute for a gap that arguably deserves its own PR — happy to open one for a lint/validate step if wanted, but it's out of scope here.
review-bot left a comment
Member

🤖 Automated review — model CLI default (complexity: complex), commit ead4aa3f0174a44738a470135471c44d1e599576.

Advisory review by the review-bot pipeline. It never approves; a human merges. Push new commits to get a fresh review.


Summary

Adds an interview-watch sidecar to the Zigbee2MQTT deployment that tails Z2M's
log for zh:controller frame-receipt lines and fires a forced re-interview via
MQTT for any device still PENDING/FAILED in database.db, plus the
supporting ConfigMap/config/runbook wiring. The design (sidecar due to RWO PVC,
namespaced debug logging to avoid the UNPI firehose, in-process log
re-resolution to dodge kubelet backoff) is well reasoned and thoroughly
commented, and is consistent with this repo's existing sidecar conventions
(config-sync.sh, seed-data init container). I found no confirmed blocking
defect, but one risk in the core log-parsing pipe is high-impact enough that it
should be verified before merge (see Suggestions).

🟡 Suggestions

  • zigbee2mqtt-interview-watch.sh:119 — grep --line-buffered -oE '...' runs
    against whatever grep is on alpine:3.21 after apk add --no-cache mosquitto-clients (line 69 of the deployment's command block) — i.e. the
    stock BusyBox grep applet, since the script never installs GNU
    grep/coreutils. I could not execute a container in this review sandbox to
    confirm whether BusyBox's grep accepts --line-buffered; if it doesn't,
    grep exits immediately with a usage error on every attempt, the
    tail -F | grep | while read pipe produces zero lines forever, and the
    watchdog does nothing while still logging "following $LOGFILE" — i.e. it
    would look alive while never firing a single interview. Given this line is
    the crux of the entire feature, please confirm with
    docker run --rm alpine:3.21 sh -c 'apk add --no-cache mosquitto-clients >/dev/null; printf "Received payload: clusterID=6, address=1\n" | grep --line-buffered -oE "Received payload: clusterID=[0-9]+, address=[0-9]+"'
    before/after merge, since this can't be observed from the diff alone.

  • The PR description's "verified live" log excerpt doesn't match what the
    committed script actually emits, which is worth reconciling before trusting
    it as evidence the current code was the code that was tested:

    • PR body: 05:42:58Z FRAME from 'EG Bad Rollo Schalter' (nwk=48752, cluster=25) -> firing interview
    • Current code (zigbee2mqtt-interview-watch.sh:243):
      log "frame from $ieee (nwk=$nwk cluster=$cluster) while awake -> requesting interview",
      which logs the IEEE address ($ieee, e.g. 0x0017880103a543d0), not a
      friendly name — ieee_needing_interview() (lines 179–191) only ever
      extracts ieeeAddr/nwkAddr from database.db; there is no
      friendlyName lookup anywhere in the script. As committed, this script
      cannot produce a line containing 'EG Bad Rollo Schalter'. Either an
      earlier iteration resolved friendly names and that capability was dropped
      without updating the PR body, or the quoted output isn't a literal capture
      of this exact script. Either way it's worth confirming the version that was
      live-tested is the version being merged.
  • zigbee2mqtt-interview-watch.sh:241 — the cooldown stamp is written
    unconditionally before the mosquitto_pub call's success is known (lines
    241–254). If mosquitto_pub fails (broker hiccup, transient DNS blip), the
    device is still marked as "just attempted" and won't be retried for the full
    COOLDOWN (300s) even though no interview was actually requested. Given the
    sidecar's whole premise is reacting to a device's short wake window, writing
    the stamp only on the success branch would avoid wasting a wake-up on a
    failed publish.

  • zigbee2mqtt-interview-watch.sh:249 — the interview-request topic
    zigbee2mqtt/bridge/request/device/interview is hardcoded rather than
    derived from mqtt.base_topic in zigbee2mqtt-config.yaml:34. Low risk
    today (both are zigbee2mqtt), but the two would silently drift apart if
    base_topic is ever changed without touching this script.

  • Sidecar container (zigbee2mqtt-deployment.yaml:152-179) has no
    container-level securityContext (e.g. allowPrivilegeEscalation: false,
    runAsNonRoot). This matches the existing zigbee2mqtt/seed-data
    containers in the same file, so it's not a regression introduced by this PR,
    just an opportunity to harden while touching this manifest.

🟢 Nits

  • zigbee2mqtt-migration-runbook.md:147 — the callout is placed under "Step
    5", but its own first line says "Step 2 is now automated" — likely meant to
    say "Step 5".

Tests

No automated test/build step in this repo validates kustomize manifests before
merge (checked .woodpecker/; deploy-infrastructure.yml only covers
opentofu/**). The PR body documents manual live verification against the
real cluster (tally moving 13 → 17 SUCCESSFUL), which is reasonable given
this repo's operational style, but see the two points above — the exact
committed script's log line doesn't match the quoted "verified" output, so I'd
ask for a fresh confirmation that this diff (particularly the
--line-buffered grep invocation) behaves as claimed, e.g. by checking
kubectl logs -c interview-watch deploy/zigbee2mqtt -n home-assistant after
rollout for a "following ..." line followed by actual "frame from ..."
lines during normal device activity.

Recommendation

Looks reasonable — no confirmed blocking issues; the --line-buffered/BusyBox
grep compatibility question is the one item worth resolving with an actual
run before considering this fully verified, since it can silently no-op the
entire feature.

🤖 **Automated review** — model `CLI default` (complexity: complex), commit `ead4aa3f0174a44738a470135471c44d1e599576`. > Advisory review by the `review-bot` pipeline. It never approves; a human merges. Push new commits to get a fresh review. --- ## Summary Adds an `interview-watch` sidecar to the Zigbee2MQTT deployment that tails Z2M's log for `zh:controller` frame-receipt lines and fires a forced re-interview via MQTT for any device still `PENDING`/`FAILED` in `database.db`, plus the supporting ConfigMap/config/runbook wiring. The design (sidecar due to RWO PVC, namespaced debug logging to avoid the UNPI firehose, in-process log re-resolution to dodge kubelet backoff) is well reasoned and thoroughly commented, and is consistent with this repo's existing sidecar conventions (`config-sync.sh`, `seed-data` init container). I found no confirmed blocking defect, but one risk in the core log-parsing pipe is high-impact enough that it should be verified before merge (see Suggestions). ## 🟡 Suggestions - `zigbee2mqtt-interview-watch.sh:119` — `grep --line-buffered -oE '...'` runs against whatever `grep` is on `alpine:3.21` after `apk add --no-cache mosquitto-clients` (line 69 of the deployment's command block) — i.e. the stock BusyBox `grep` applet, since the script never installs GNU `grep`/`coreutils`. I could not execute a container in this review sandbox to confirm whether BusyBox's `grep` accepts `--line-buffered`; if it doesn't, `grep` exits immediately with a usage error on every attempt, the `tail -F | grep | while read` pipe produces zero lines forever, and the watchdog does *nothing* while still logging `"following $LOGFILE"` — i.e. it would look alive while never firing a single interview. Given this line is the crux of the entire feature, please confirm with `docker run --rm alpine:3.21 sh -c 'apk add --no-cache mosquitto-clients >/dev/null; printf "Received payload: clusterID=6, address=1\n" | grep --line-buffered -oE "Received payload: clusterID=[0-9]+, address=[0-9]+"'` before/after merge, since this can't be observed from the diff alone. - The PR description's "verified live" log excerpt doesn't match what the committed script actually emits, which is worth reconciling before trusting it as evidence the current code was the code that was tested: - PR body: `05:42:58Z FRAME from 'EG Bad Rollo Schalter' (nwk=48752, cluster=25) -> firing interview` - Current code (`zigbee2mqtt-interview-watch.sh:243`): `log "frame from $ieee (nwk=$nwk cluster=$cluster) while awake -> requesting interview"`, which logs the **IEEE address** (`$ieee`, e.g. `0x0017880103a543d0`), not a friendly name — `ieee_needing_interview()` (lines 179–191) only ever extracts `ieeeAddr`/`nwkAddr` from `database.db`; there is no `friendlyName` lookup anywhere in the script. As committed, this script cannot produce a line containing `'EG Bad Rollo Schalter'`. Either an earlier iteration resolved friendly names and that capability was dropped without updating the PR body, or the quoted output isn't a literal capture of this exact script. Either way it's worth confirming the version that was live-tested is the version being merged. - `zigbee2mqtt-interview-watch.sh:241` — the cooldown stamp is written unconditionally *before* the `mosquitto_pub` call's success is known (lines 241–254). If `mosquitto_pub` fails (broker hiccup, transient DNS blip), the device is still marked as "just attempted" and won't be retried for the full `COOLDOWN` (300s) even though no interview was actually requested. Given the sidecar's whole premise is reacting to a device's short wake window, writing the stamp only on the success branch would avoid wasting a wake-up on a failed publish. - `zigbee2mqtt-interview-watch.sh:249` — the interview-request topic `zigbee2mqtt/bridge/request/device/interview` is hardcoded rather than derived from `mqtt.base_topic` in `zigbee2mqtt-config.yaml:34`. Low risk today (both are `zigbee2mqtt`), but the two would silently drift apart if `base_topic` is ever changed without touching this script. - Sidecar container (`zigbee2mqtt-deployment.yaml:152-179`) has no container-level `securityContext` (e.g. `allowPrivilegeEscalation: false`, `runAsNonRoot`). This matches the existing `zigbee2mqtt`/`seed-data` containers in the same file, so it's not a regression introduced by this PR, just an opportunity to harden while touching this manifest. ## 🟢 Nits - `zigbee2mqtt-migration-runbook.md:147` — the callout is placed under "Step 5", but its own first line says "Step 2 is now automated" — likely meant to say "Step 5". ## Tests No automated test/build step in this repo validates kustomize manifests before merge (checked `.woodpecker/`; `deploy-infrastructure.yml` only covers `opentofu/**`). The PR body documents manual live verification against the real cluster (tally moving 13 → 17 `SUCCESSFUL`), which is reasonable given this repo's operational style, but see the two points above — the exact committed script's log line doesn't match the quoted "verified" output, so I'd ask for a fresh confirmation that *this* diff (particularly the `--line-buffered` grep invocation) behaves as claimed, e.g. by checking `kubectl logs -c interview-watch deploy/zigbee2mqtt -n home-assistant` after rollout for a `"following ..."` line followed by actual `"frame from ..."` lines during normal device activity. ## Recommendation Looks reasonable — no confirmed blocking issues; the `--line-buffered`/BusyBox `grep` compatibility question is the one item worth resolving with an actual run before considering this fully verified, since it can silently no-op the entire feature. <!-- pr-review:sha=ead4aa3f0174a44738a470135471c44d1e599576 -->
claude-bot added 1 commit 2026-07-30 06:22:42 +00:00
fix(zigbee2mqtt): drop BusyBox-incompatible grep from watchdog
ci/woodpecker/pr/pr-review Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
3961640130
Second review round on #441. The first point was a real blocking defect
that would have shipped silently.

BusyBox grep does not support --line-buffered. Confirmed by running it:

    $ grep --line-buffered -oE '...' <<< 'Received payload: ...'
    grep: unrecognized option: line-buffered
    GREP_EXIT=2

The sidecar runs alpine:3.21 and `apk add mosquitto-clients` does not
pull GNU grep, so the `tail -F | grep --line-buffered | while read` pipe
would have produced zero lines forever while the container still logged
"following <file>" and appeared healthy — a silent no-op of the entire
feature, undetectable from the diff.

Fixed by removing the grep stage rather than installing GNU grep: match
in the read loop with a `case` glob and extract the fields with parameter
expansion. `tail` writes per line and `read` consumes per line, so there
is no buffering question and no extra package. Added a numeric guard on
the extracted address so a future log-format change cannot feed garbage
downstream.

Also from this round:

- Write the cooldown stamp only on a SUCCESSFUL publish. Stamping before
  the result was known burned a device's wake window whenever
  mosquitto_pub failed, then refused to retry for the full 300s despite
  no interview having been requested.
- Derive the request topic from BASE_TOPIC (env, defaulting to
  zigbee2mqtt) instead of hardcoding it, so it cannot silently drift from
  mqtt.base_topic. Noted that the MQTT ACL is scoped to zigbee2mqtt/# and
  would need changing too.
- Harden the sidecar with allowPrivilegeEscalation: false and
  capabilities drop ALL. Deliberately no runAsNonRoot: apk add needs
  root, and Z2M writes database.db and the log dir as root, so a non-root
  uid could not read either input.
- Runbook callout said "Step 2" where it meant item 2 within Step 5.

Verified end-to-end in an alpine:3.21 pod against this script (the
earlier "verified live" evidence came from the session prototype, which
resolved friendly names and is not this code — see PR discussion):
counts only PENDING devices, fires on a frame from a PENDING device with
mosquitto_pub returning success, ignores a frame from an
already-SUCCESSFUL device, and survives a malformed address without
firing or crashing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Author
Member

Second round addressed in 39616401. Your first point was a real blocking defect — thank you for flagging it as unverifiable-from-diff rather than waving it through.

🟡 BusyBox grep --line-buffered — CONFIRMED BROKEN, fixed

I ran your suggested check (as a pod, same base image):

$ printf 'Received payload: clusterID=6, address=12468\n' | grep --line-buffered -oE '...'
grep: unrecognized option: line-buffered
GREP_EXIT=2

Exactly the failure you predicted: BusyBox grep rejects the flag, so the pipe would have yielded zero lines forever while the container logged following <file> and looked perfectly healthy. The entire feature would have been a silent no-op, and nothing in the diff or in sh -n would have caught it.

Fixed by removing the grep stage rather than apk add grep: matching now happens in the read loop via a case glob, with field extraction by parameter expansion. tail writes per line and read consumes per line, so there's no buffering question left and no extra package. I also added a numeric guard on the extracted address so a future log-format change can't feed garbage downstream instead of failing quietly.

🟡 PR body log excerpt doesn't match the committed script — you are right, and this was my error

Good catch, and worth being blunt about: the quoted FRAME from 'EG Bad Rollo Schalter' line came from the session prototype I used to prove the mechanism, which had a friendly-name map built from configuration.yaml. The committed sidecar deliberately doesn't do that lookup and logs the IEEE only. So your reading is correct — this script cannot emit that line, and citing it as evidence for this diff was sloppy of me.

The underlying finding (tally 13 → 18 SUCCESSFUL, interviews completing in 11–34s, two devices firing with no human interaction) is real and did happen — but via the prototype, so it validates the approach, not this code. To close the gap properly I ran the committed script end-to-end in an alpine:3.21 pod with synthetic fixtures:

interview-watch: starting
interview-watch: 1 device(s) currently need an interview
interview-watch: following /app/data/log/2026-07-30.99-99-99/log.log
interview-watch: frame from 0xdeadbeefdeadbeef (nwk=65001 cluster=6) while awake -> requesting interview
interview-watch:   interview requested for 0xdeadbeefdeadbeef

Covering, deliberately, more than the happy path:

behaviour result
counts only PENDING/FAILED devices ✅ 1 of 2 fixtures
frame from a PENDING device → interview fired ✅
mosquitto_pub genuinely succeeded ✅ (interview requested only prints on exit 0)
frame from an already-SUCCESSFUL device ✅ ignored, no output
malformed address=NOTANUMBER ✅ no fire, no crash
runs under BusyBox without GNU grep ✅

A bogus IEEE was used so the real publish was harmless. Test pod and ConfigMap have been deleted.

🟡 Cooldown stamped before publish success

Fixed. Correct, and it directly undercut the premise — a failed publish burned the device's wake window and then blocked retries for the full 300s despite no interview ever being requested. The stamp now only lands on the success branch, and the failure log says so explicitly.

🟡 Hardcoded topic vs mqtt.base_topic

Fixed. Now BASE_TOPIC (env, default zigbee2mqtt), set in the Deployment next to the other config so the two are visibly coupled. Also noted in both places that the MQTT ACL is scoped to zigbee2mqtt/#, so changing base_topic needs an ACL change too — otherwise "fixing" the drift would just move the silent breakage.

🟡 No container securityContext

Partially fixed. Added allowPrivilegeEscalation: false and capabilities: drop: [ALL].

Deliberately not runAsNonRoot: apk add needs root, and Z2M writes database.db and the log directory as root (0644/0755), so a non-root uid could not read either input. Adding it would break the sidecar rather than harden it. I left the neighbouring containers alone, since you correctly noted that's pre-existing and not this PR's regression.

🟢 Runbook "Step 2"

Fixed — meant item 2 within Step 5. Now reads "Item 2 above (battery/sleepy devices)".


On your closing request: the --line-buffered question is now resolved by execution rather than inspection, and the post-rollout check you suggested (kubectl logs -c interview-watch deploy/zigbee2mqtt showing following ... then frame from ...) is still the right confirmation once this is merged and rolled — I'd treat that as the acceptance test rather than assuming the sandbox run settles it.

Second round addressed in `39616401`. **Your first point was a real blocking defect** — thank you for flagging it as unverifiable-from-diff rather than waving it through. ### 🟡 BusyBox `grep --line-buffered` — CONFIRMED BROKEN, fixed I ran your suggested check (as a pod, same base image): ``` $ printf 'Received payload: clusterID=6, address=12468\n' | grep --line-buffered -oE '...' grep: unrecognized option: line-buffered GREP_EXIT=2 ``` Exactly the failure you predicted: BusyBox grep rejects the flag, so the pipe would have yielded zero lines forever while the container logged `following <file>` and looked perfectly healthy. The entire feature would have been a silent no-op, and nothing in the diff or in `sh -n` would have caught it. Fixed by **removing the `grep` stage** rather than `apk add grep`: matching now happens in the read loop via a `case` glob, with field extraction by parameter expansion. `tail` writes per line and `read` consumes per line, so there's no buffering question left and no extra package. I also added a numeric guard on the extracted address so a future log-format change can't feed garbage downstream instead of failing quietly. ### 🟡 PR body log excerpt doesn't match the committed script — you are right, and this was my error Good catch, and worth being blunt about: the quoted `FRAME from 'EG Bad Rollo Schalter'` line came from the **session prototype** I used to prove the mechanism, which had a friendly-name map built from `configuration.yaml`. The committed sidecar deliberately doesn't do that lookup and logs the IEEE only. So your reading is correct — **this script cannot emit that line**, and citing it as evidence for this diff was sloppy of me. The underlying finding (tally 13 → 18 `SUCCESSFUL`, interviews completing in 11–34s, two devices firing with no human interaction) is real and did happen — but via the prototype, so it validates the *approach*, not this code. To close the gap properly I ran the committed script end-to-end in an `alpine:3.21` pod with synthetic fixtures: ``` interview-watch: starting interview-watch: 1 device(s) currently need an interview interview-watch: following /app/data/log/2026-07-30.99-99-99/log.log interview-watch: frame from 0xdeadbeefdeadbeef (nwk=65001 cluster=6) while awake -> requesting interview interview-watch: interview requested for 0xdeadbeefdeadbeef ``` Covering, deliberately, more than the happy path: | behaviour | result | |---|---| | counts only `PENDING`/`FAILED` devices | ✅ 1 of 2 fixtures | | frame from a PENDING device → interview fired | ✅ | | `mosquitto_pub` genuinely succeeded | ✅ (`interview requested` only prints on exit 0) | | frame from an already-`SUCCESSFUL` device | ✅ ignored, no output | | malformed `address=NOTANUMBER` | ✅ no fire, no crash | | runs under BusyBox without GNU grep | ✅ | A bogus IEEE was used so the real publish was harmless. Test pod and ConfigMap have been deleted. ### 🟡 Cooldown stamped before publish success **Fixed.** Correct, and it directly undercut the premise — a failed publish burned the device's wake window and then blocked retries for the full 300s despite no interview ever being requested. The stamp now only lands on the success branch, and the failure log says so explicitly. ### 🟡 Hardcoded topic vs `mqtt.base_topic` **Fixed.** Now `BASE_TOPIC` (env, default `zigbee2mqtt`), set in the Deployment next to the other config so the two are visibly coupled. Also noted in both places that the MQTT ACL is scoped to `zigbee2mqtt/#`, so changing `base_topic` needs an ACL change too — otherwise "fixing" the drift would just move the silent breakage. ### 🟡 No container `securityContext` **Partially fixed.** Added `allowPrivilegeEscalation: false` and `capabilities: drop: [ALL]`. Deliberately **not** `runAsNonRoot`: `apk add` needs root, and Z2M writes `database.db` and the log directory as root (0644/0755), so a non-root uid could not read either input. Adding it would break the sidecar rather than harden it. I left the neighbouring containers alone, since you correctly noted that's pre-existing and not this PR's regression. ### 🟢 Runbook "Step 2" **Fixed** — meant item 2 *within* Step 5. Now reads "Item 2 above (battery/sleepy devices)". --- On your closing request: the `--line-buffered` question is now resolved by execution rather than inspection, and the post-rollout check you suggested (`kubectl logs -c interview-watch deploy/zigbee2mqtt` showing `following ...` then `frame from ...`) is still the right confirmation once this is merged and rolled — I'd treat that as the acceptance test rather than assuming the sandbox run settles it.
review-bot left a comment
Member

🤖 Automated review — model CLI default (complexity: complex), commit 3961640130b1372828c44059ef865e8fd84d0d61.

Advisory review by the review-bot pipeline. It never approves; a human merges. Push new commits to get a fresh review.


Summary

Adds a sidecar to the zigbee2mqtt Deployment that tails Z2M's log for frame-receipt lines and fires a forced re-interview via MQTT for any device still PENDING/FAILED in database.db, plus the namespaced debug log level it depends on and a runbook update. The change is unusually well-documented — the script header and inline comments preemptively explain and justify most of the design decisions a reviewer would otherwise have to ask about (BusyBox grep --line-buffered incompatibility, why MQTT state isn't a usable signal, why the process stays resident instead of exiting on log rotation, why the container can't run non-root, why the password can only go via mosquitto_pub -P). I traced the RBAC/ACL, secret, ConfigMap, and CiliumNetworkPolicy wiring end-to-end and found it consistent: no blocking issues.

🟡 Suggestions

  • zigbee2mqtt-interview-watch.sh:228-301 — the main loop is purely event-driven (blocks in tail -F | while read) and only logs at startup, on log stream ended, or per-frame. If the Received payload: clusterID= format ever changes (herdsman upgrade, log format change) the sidecar keeps logging following <file> and looks healthy while silently matching nothing forever — there'd be no signal to notice regression short of manually diffing the "N device(s) need an interview" count over time. A periodic heartbeat (e.g. every few minutes, log the current PENDING/FAILED count from database.db) would make that failure mode observable, similar to how config-sync.sh naturally heartbeats each poll interval.
  • zigbee2mqtt-deployment.yaml:185-190 — interview-watch has no resources.limits.cpu (matches the existing zigbee2mqtt/seed-data containers in this file, so it's consistent with local convention, just flagging in case that omission wasn't intentional at the file level).

🟢 Nits

  • zigbee2mqtt-config.yaml:36-42 — the PR body notes the ConfigMap template is seed-only and the live PVC's configuration.yaml won't pick up log_namespaced_levels from this diff (already applied at runtime out-of-band). Worth a one-line comment in the YAML itself (not just the PR body) so a future reader of the file alone knows this key is currently a no-op on redeploy from scratch unless configuration.yaml is deleted or hand-edited.

Tests

No automated tests, and none expected — this repo has no shellcheck/script-testing CI for kubernetes/apps/** sidecar scripts (ci-scripts-test.yml only covers scripts/ci/), and the sibling config-sync.sh sidecar is likewise untested. The PR body documents manual live verification (interview completion times, SUCCESSFUL count moving 13→17) which is reasonable evidence for a script that's inherently hard to unit test (it depends on live Zigbee traffic timing and Z2M's log format). Acceptable given repo conventions.

Recommendation

Looks reasonable — no blocking issues; comments above are optional.

🤖 **Automated review** — model `CLI default` (complexity: complex), commit `3961640130b1372828c44059ef865e8fd84d0d61`. > Advisory review by the `review-bot` pipeline. It never approves; a human merges. Push new commits to get a fresh review. --- ## Summary Adds a sidecar to the `zigbee2mqtt` Deployment that tails Z2M's log for frame-receipt lines and fires a forced re-interview via MQTT for any device still `PENDING`/`FAILED` in `database.db`, plus the namespaced debug log level it depends on and a runbook update. The change is unusually well-documented — the script header and inline comments preemptively explain and justify most of the design decisions a reviewer would otherwise have to ask about (BusyBox `grep --line-buffered` incompatibility, why MQTT state isn't a usable signal, why the process stays resident instead of exiting on log rotation, why the container can't run non-root, why the password can only go via `mosquitto_pub -P`). I traced the RBAC/ACL, secret, ConfigMap, and CiliumNetworkPolicy wiring end-to-end and found it consistent: no blocking issues. ## 🟡 Suggestions - `zigbee2mqtt-interview-watch.sh:228-301` — the main loop is purely event-driven (blocks in `tail -F | while read`) and only logs at startup, on `log stream ended`, or per-frame. If the `Received payload: clusterID=` format ever changes (herdsman upgrade, log format change) the sidecar keeps logging `following <file>` and looks healthy while silently matching nothing forever — there'd be no signal to notice regression short of manually diffing the "N device(s) need an interview" count over time. A periodic heartbeat (e.g. every few minutes, log the current PENDING/FAILED count from `database.db`) would make that failure mode observable, similar to how `config-sync.sh` naturally heartbeats each poll interval. - `zigbee2mqtt-deployment.yaml:185-190` — `interview-watch` has no `resources.limits.cpu` (matches the existing `zigbee2mqtt`/`seed-data` containers in this file, so it's consistent with local convention, just flagging in case that omission wasn't intentional at the file level). ## 🟢 Nits - `zigbee2mqtt-config.yaml:36-42` — the PR body notes the ConfigMap template is seed-only and the live PVC's `configuration.yaml` won't pick up `log_namespaced_levels` from this diff (already applied at runtime out-of-band). Worth a one-line comment in the YAML itself (not just the PR body) so a future reader of the file alone knows this key is currently a no-op on redeploy from scratch unless `configuration.yaml` is deleted or hand-edited. ## Tests No automated tests, and none expected — this repo has no shellcheck/script-testing CI for `kubernetes/apps/**` sidecar scripts (`ci-scripts-test.yml` only covers `scripts/ci/`), and the sibling `config-sync.sh` sidecar is likewise untested. The PR body documents manual live verification (interview completion times, SUCCESSFUL count moving 13→17) which is reasonable evidence for a script that's inherently hard to unit test (it depends on live Zigbee traffic timing and Z2M's log format). Acceptable given repo conventions. ## Recommendation Looks reasonable — no blocking issues; comments above are optional. <!-- pr-review:sha=3961640130b1372828c44059ef865e8fd84d0d61 -->
claude-bot added 1 commit 2026-07-30 06:37:37 +00:00
refactor(zigbee2mqtt): drop apk add from watchdog sidecar
ci/woodpecker/pr/pr-review Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
834ded5e9d
Owner directive: `apk add` at container start is not acceptable.

Replace alpine:3.21 + `apk add mosquitto-clients` with the broker image
eclipse-mosquitto:2.0.22, used purely for its bundled mosquitto_pub. No
packages are installed at runtime, so the sidecar no longer depends on
network or package-index availability at start.

Every tool the script needs was verified present in that exact image
rather than assumed: sh, tail (including -F, which BusyBox does support),
awk with match()/RSTART/RLENGTH, cat, date, mkdir, mosquitto_pub. The awk
extraction was re-run there and resolves a PENDING device, skips a
SUCCESSFUL one, and skips an unknown address.

Pinned WITHOUT a Flux $imagepolicy marker on purpose: this sidecar is
temporary and must not drag the broker's image-automation policy with it.

Now genuinely non-root (1883/1883, the image's own mosquitto user). The
previous commit justified running as root because `apk add` needed it;
with that gone the only remaining requirement is reading /app/data, and
those paths are world-readable (dirs 0755, files 0644 — verified against
the live PVC), so runAsNonRoot is viable. Cooldown stamps moved to an
explicit emptyDir at /state rather than the image's /tmp, so they stay
writable as non-root without relying on the container filesystem.

Re-verified end-to-end in this image as uid 1883 with no package install:

    running as: uid=1883(mosquitto) gid=1883(mosquitto)
    interview-watch: 1 device(s) currently need an interview
    interview-watch: following /app/data/log/.../log.log
    interview-watch: frame from 0xdeadbeefdeadbeef (nwk=65001 cluster=6) ...
    interview-watch:   interview requested for 0xdeadbeefdeadbeef

with the negative paths also holding: a frame from an already-SUCCESSFUL
device is ignored, and a malformed address neither fires nor crashes.

NOTE: the pre-existing seed-data init container still runs
`apk add --no-cache jq` (line 49). Untouched here as it is out of scope
for this PR, but it violates the same directive and is raised separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
review-bot left a comment
Member

🤖 Automated review — model CLI default (complexity: complex), commit 834ded5e9de496f23d923811019292503cc5c57a.

Advisory review by the review-bot pipeline. It never approves; a human merges. Push new commits to get a fresh review.


Summary

Adds a interview-watch sidecar to the zigbee2mqtt Deployment that tails Z2M's log for frame-receipt lines and force-triggers a re-interview for any device still PENDING/FAILED, plus the namespaced debug log level it depends on, a ConfigMap for the script, and a runbook update. The core idea (fire the interview while the device is provably awake) is sound and the PR body documents the operational verification well. However, the log-following mechanism has a design flaw that likely defeats the sidecar's stated goal of surviving a Z2M restart without kubelet backoff — which matters because the PR's own follow-up plan requires exactly one such restart.

🔴 Blocking

  • zigbee2mqtt-interview-watch.sh:95-133 — the outer "re-resolve on restart" loop likely never re-triggers, because tail -F follows a fixed path, not a directory. newest_log() (line 95-98) picks the newest $DATA/log/<dir>/log.log, and the PR body/script comments state Z2M creates a fresh log directory per start. The outer while :; do LOGFILE=$(newest_log); ...; tail -F "$LOGFILE" | while read ...; done loop (lines 110-180) is only supposed to re-run newest_log() after the inner pipeline ends — but tail --follow=name --retry (-F) tracks one specific pathname: it re-opens that same path if it's rotated/truncated/recreated in place, and otherwise just blocks waiting for more bytes. It has no mechanism to notice that Z2M has started writing to a different directory. When Z2M restarts into a new log/<new-timestamp>/log.log, the sidecar keeps tail -F-ing the old, now-frozen file — log "following $LOGFILE" was already printed and never updates, the container looks healthy, and no new interview frames are ever seen again until the sidecar pod itself is restarted. This isn't a rare edge case: the PR explicitly requires "one Z2M restart after a batch of interviews" as follow-up work, which is precisely the moment this breaks. There's also no liveness/readiness probe on this container (unlike the main zigbee2mqtt container), so nothing will catch or recover from this automatically.
    I could not test this against the live cluster, so I can't rule out that BusyBox's tail -F in eclipse-mosquitto:2.0.22's Alpine base behaves differently (e.g., exits when the followed file is deleted, which would let the outer loop recover) — but the documented --follow=name --retry semantics this comment itself invokes do not do that. The concrete check that would settle it: restart the zigbee2mqtt deployment while the sidecar is running, and confirm the log actually emits "log stream ended; re-resolving current log file" and starts "following" the new path. If it doesn't, a background poll comparing newest_log() to the currently-tailed path (killing/restarting tail on change) would be needed instead of relying on the pipe to end.

🟡 Suggestions

  • zigbee2mqtt-interview-watch.sh:139 (ieee_needing_interview) — this awk pass over the whole database.db runs for every Received payload: clusterID= line in the network, not just for devices that still need an interview (the namespaced debug log is global to zh:controller, so mains-powered/already-interviewed devices also match). Once most of the 16 devices are healed, this is still a fork of awk+date per frame from ~30 devices' worth of traffic for no benefit. Cheap short-circuit: track whether the device set to watch is empty (e.g. re-check the PENDING|FAILED count logged at startup periodically) and skip the awk call entirely when there's nothing left to interview — you already compute that count once at line ~102 for the startup log line.
  • zigbee2mqtt-interview-watch.sh:157 — mosquitto_pub has no explicit connect/publish timeout. If the broker is unreachable at the TCP level (vs. cleanly refusing), the call could block for longer than expected, stalling the single-threaded read loop and missing other devices' wake windows during that time. A -W <secs> (mosquitto_pub message timeout) or wrapping in timeout would bound this.
  • zigbee2mqtt-interview-watch.sh:186-190 — MQTT_HOST/MQTT_USER fall back to hardcoded defaults (mosquitto, zigbee2mqtt) that happen to match zigbee2mqtt-config.yaml, but unlike BASE_TOPIC (which the deployment sets explicitly with a "must track" comment), nothing wires these from the Deployment or flags that they need to stay in sync if the mosquitto Service name or MQTT username ever changes. Minor, since they're currently correct and stable.

🟢 Nits

  • zigbee2mqtt-deployment.yaml: the interview-watch container has no imagePullPolicy set (the main container explicitly sets IfNotPresent); harmless since the tag is pinned, just inconsistent with its sibling.

Tests

No automated coverage, and none seems to exist in this repo for Kubernetes shell-script sidecars (the only shell-script CI gate, ci-scripts-test.yml, targets scripts/ci/*, not kubernetes/apps/**). Given the restart-handling concern above, the one thing worth verifying live before/after merge is exactly that scenario: confirm the sidecar picks up a new log directory after a zigbee2mqtt pod restart, not just that a forced interview fires within a single continuous run (which the PR body already demonstrates well).

Recommendation

Request changes — 1 blocking issue (log-rotation-across-restart handling, high confidence but not live-verified).

🤖 **Automated review** — model `CLI default` (complexity: complex), commit `834ded5e9de496f23d923811019292503cc5c57a`. > Advisory review by the `review-bot` pipeline. It never approves; a human merges. Push new commits to get a fresh review. --- ## Summary Adds a `interview-watch` sidecar to the `zigbee2mqtt` Deployment that tails Z2M's log for frame-receipt lines and force-triggers a re-interview for any device still `PENDING`/`FAILED`, plus the namespaced debug log level it depends on, a ConfigMap for the script, and a runbook update. The core idea (fire the interview while the device is provably awake) is sound and the PR body documents the operational verification well. However, the log-following mechanism has a design flaw that likely defeats the sidecar's stated goal of surviving a Z2M restart without kubelet backoff — which matters because the PR's own follow-up plan requires exactly one such restart. ## 🔴 Blocking - **`zigbee2mqtt-interview-watch.sh:95-133` — the outer "re-resolve on restart" loop likely never re-triggers, because `tail -F` follows a fixed path, not a directory.** `newest_log()` (line 95-98) picks the newest `$DATA/log/<dir>/log.log`, and the PR body/script comments state Z2M creates a **fresh log directory per start**. The outer `while :; do LOGFILE=$(newest_log); ...; tail -F "$LOGFILE" | while read ...; done` loop (lines 110-180) is only supposed to re-run `newest_log()` after the inner pipeline ends — but `tail --follow=name --retry` (`-F`) tracks one specific pathname: it re-opens that same path if it's rotated/truncated/recreated in place, and otherwise just blocks waiting for more bytes. It has no mechanism to notice that Z2M has started writing to a *different* directory. When Z2M restarts into a new `log/<new-timestamp>/log.log`, the sidecar keeps `tail -F`-ing the old, now-frozen file — `log "following $LOGFILE"` was already printed and never updates, the container looks healthy, and no new interview frames are ever seen again until the sidecar pod itself is restarted. This isn't a rare edge case: the PR explicitly requires "one Z2M restart after a batch of interviews" as follow-up work, which is precisely the moment this breaks. There's also no liveness/readiness probe on this container (unlike the main `zigbee2mqtt` container), so nothing will catch or recover from this automatically. I could not test this against the live cluster, so I can't rule out that BusyBox's `tail -F` in `eclipse-mosquitto:2.0.22`'s Alpine base behaves differently (e.g., exits when the followed file is deleted, which would let the outer loop recover) — but the documented `--follow=name --retry` semantics this comment itself invokes do not do that. The concrete check that would settle it: restart the `zigbee2mqtt` deployment while the sidecar is running, and confirm the log actually emits `"log stream ended; re-resolving current log file"` and starts `"following"` the new path. If it doesn't, a background poll comparing `newest_log()` to the currently-tailed path (killing/restarting `tail` on change) would be needed instead of relying on the pipe to end. ## 🟡 Suggestions - **`zigbee2mqtt-interview-watch.sh:139` (`ieee_needing_interview`)** — this awk pass over the whole `database.db` runs for *every* `Received payload: clusterID=` line in the network, not just for devices that still need an interview (the namespaced debug log is global to `zh:controller`, so mains-powered/already-interviewed devices also match). Once most of the 16 devices are healed, this is still a fork of `awk`+`date` per frame from ~30 devices' worth of traffic for no benefit. Cheap short-circuit: track whether the device set to watch is empty (e.g. re-check the `PENDING|FAILED` count logged at startup periodically) and skip the awk call entirely when there's nothing left to interview — you already compute that count once at line ~102 for the startup log line. - **`zigbee2mqtt-interview-watch.sh:157` — `mosquitto_pub` has no explicit connect/publish timeout.** If the broker is unreachable at the TCP level (vs. cleanly refusing), the call could block for longer than expected, stalling the single-threaded read loop and missing other devices' wake windows during that time. A `-W <secs>` (mosquitto_pub message timeout) or wrapping in `timeout` would bound this. - **`zigbee2mqtt-interview-watch.sh:186-190`** — `MQTT_HOST`/`MQTT_USER` fall back to hardcoded defaults (`mosquitto`, `zigbee2mqtt`) that happen to match `zigbee2mqtt-config.yaml`, but unlike `BASE_TOPIC` (which the deployment sets explicitly with a "must track" comment), nothing wires these from the Deployment or flags that they need to stay in sync if the mosquitto Service name or MQTT username ever changes. Minor, since they're currently correct and stable. ## 🟢 Nits - `zigbee2mqtt-deployment.yaml`: the `interview-watch` container has no `imagePullPolicy` set (the main container explicitly sets `IfNotPresent`); harmless since the tag is pinned, just inconsistent with its sibling. ## Tests No automated coverage, and none seems to exist in this repo for Kubernetes shell-script sidecars (the only shell-script CI gate, `ci-scripts-test.yml`, targets `scripts/ci/*`, not `kubernetes/apps/**`). Given the restart-handling concern above, the one thing worth verifying live before/after merge is exactly that scenario: confirm the sidecar picks up a new log directory after a `zigbee2mqtt` pod restart, not just that a forced interview fires within a single continuous run (which the PR body already demonstrates well). ## Recommendation Request changes — 1 blocking issue (log-rotation-across-restart handling, high confidence but not live-verified). <!-- pr-review:sha=834ded5e9de496f23d923811019292503cc5c57a -->
claude-bot added 1 commit 2026-07-30 07:03:13 +00:00
fix(zigbee2mqtt): follow a stable log path across Z2M restarts
ci/woodpecker/pr/pr-review Pipeline was canceled
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ee15cdeb13
Addresses the blocking review point on #441, which was correct.

`tail -F` follows a PATHNAME: it re-opens that name when the inode behind
it changes, but it can never notice that Z2M has started writing to a
different directory. Z2M creates log/<start-timestamp>/log.log fresh on
every start, so tailing the newest directory breaks as soon as Z2M
restarts — the old file merely goes quiet, so the pipe never ends, the
outer loop never re-resolves, "following <file>" still shows the stale
path, and the sidecar looks healthy while never seeing another frame.

Worse than the review assumed: this is not limited to the planned
"interview a batch, then restart Z2M once" step. The Z2M *container* has
restarted 38 times on its own from DNS flaps, each time into a new log
directory, while the pod (and so the sidecar) kept running.

Fixed at the source rather than with a polling watchdog: set
advanced.log_symlink_current: true so Z2M maintains log/current -> the
active directory, and follow that stable path. Verified that BusyBox
`tail -F` in eclipse-mosquitto:2.0.22 does follow a symlink repointed to a
new target mid-follow.

No fallback to the newest directory, deliberately: falling back could
silently tail a frozen file, which is the failure being fixed. A missing
symlink is instead reported loudly and repeatedly while waiting.

Also from this round:

- Short-circuit while nothing needs interviewing. The namespaced debug log
  covers all of zh:controller, so every device on the network produces
  frames; without this the script forked awk over database.db for each of
  ~30 devices' traffic forever. Recounted at most once a minute, so a
  device regressing to PENDING is still picked up.
- Bound mosquitto_pub with `timeout`, so a hanging TCP connect cannot
  stall the single-threaded read loop.
- Wire MQTT_HOST and MQTT_USER explicitly from the Deployment, like
  BASE_TOPIC, instead of relying on the script's defaults.
- imagePullPolicy: IfNotPresent, matching the sibling container.

Two bugs found by testing this, not by inspection:

- `mosquitto_pub -W` does not exist (it is a mosquitto_sub flag). Adding
  it made every publish fail on a usage error. Removed; `timeout` alone
  bounds the call. Same class as the earlier `grep --line-buffered`
  mistake, and again only visible by running it.
- `grep -c` prints "0" AND exits 1 when nothing matches, so
  `grep -c ... || echo 0` emitted two lines ("0\n0") and every `-eq` test
  died with "bad number". This surfaced only once no devices needed an
  interview — the steady end state this feature exists to reach.

Verified in eclipse-mosquitto:2.0.22 as uid 1883, simulating Z2M's layout
and a restart (new directory + repointed symlink):

  frame before restart -> interview requested
  symlink repointed to a new directory
  frame after restart  -> interview requested   <- the reviewed failure case
  zero-pending fixture -> "no devices need an interview; idling", no errors

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Author
Member

Fixed in ee15cdeb. The blocking point was correct, and worse than the review could tell from the diff.

🔴 tail -F and the per-start log directory

Your analysis holds exactly: tail -F follows a pathname, re-opening it when the inode behind that name changes, but it cannot notice Z2M writing somewhere else entirely. The old file just goes quiet, so the pipe never ends, my outer loop never re-resolves, following <file> keeps showing the stale path, and the sidecar looks healthy while permanently deaf.

And it is not limited to the planned one-off restart you identified. The Z2M container has restarted 38 times on its own from DNS flaps, each time into a new log directory, while the pod — and therefore the sidecar — kept running. So this would have broken within hours of merge, repeatedly, not just during the follow-up step.

Fixed at the source rather than with the poll-and-restart-tail workaround you suggested: Z2M has advanced.log_symlink_current, which makes it maintain log/current -> <active dir>. Following that stable path means a restart is picked up with no watchdog at all.

Your caveat about BusyBox semantics was the right thing to flag, so I tested it rather than reasoning about it — repointing a symlink mid-follow, in this exact image:

GOT: appendA-1
--- repointing symlink to b ---
GOT: appendB-1
GOT: appendB-2

BusyBox tail -F does follow the repoint. Then the full scenario you asked for, with the real script:

following /app/data/log/current/log.log (1 device(s) still need an interview)
frame from 0xdeadbeefdeadbeef (nwk=65001 cluster=6) -> requesting interview
  interview requested for 0xdeadbeefdeadbeef
>>> SIMULATING Z2M RESTART: new dir run-B, symlink repointed
frame from 0xdeadbeefdeadbeef (nwk=65001 cluster=6) -> requesting interview   <-- the case you predicted would be missed
  interview requested for 0xdeadbeefdeadbeef

I deliberately did not add a fallback to the newest directory: falling back risks silently tailing a frozen file, which is the very failure being fixed. A missing symlink is instead reported loudly and repeatedly while waiting.

🟡 awk fork per frame

Fixed. Correct — the namespaced debug log covers all of zh:controller, so every device produces frames and the fork happened for ~30 devices' traffic indefinitely. Now short-circuited on a cached count, recounted at most once a minute so a device regressing to PENDING is still picked up.

🟡 Unbounded mosquitto_pub

Fixed, and this one bit me. I first added mosquitto_pub -W "$PUB_TIMEOUT" — that flag does not exist for mosquitto_pub (it is a mosquitto_sub option), so every publish failed on a usage error:

WARNING: mosquitto_pub failed for 0xdeadbeefdeadbeef; not starting cooldown so the next frame retries

Exactly the same class of mistake as the grep --line-buffered one you caught last round, and again invisible to inspection — only running it exposed it. Removed; timeout alone bounds the call, and it also covers a TCP connect that hangs rather than being refused, which -W would not have. Silver lining: the failure path you asked for last round demonstrably works — it warned and refused to start the cooldown, so the next frame retried.

🟡 MQTT_HOST / MQTT_USER not wired

Fixed. Both now set explicitly in the Deployment alongside BASE_TOPIC, with one comment covering all three and what they must track.

🟢 imagePullPolicy

Fixed — IfNotPresent, matching the sibling container.

One more bug, found by testing rather than review

While testing the new short-circuit I hit a latent defect worth recording: grep -c prints 0 and exits 1 when nothing matches, so grep -c ... || echo 0 emitted two lines ("0\n0") and every -eq test died with bad number. It surfaces only when zero devices need an interview — precisely the steady end state this feature exists to reach. Now normalised, with the trap documented so it does not get reintroduced.


On probes: with the moving-target gone the specific silent-deafness failure mode is addressed, so I have not added a liveness probe to the sidecar. If you would still like one, the honest check is "has the tailed path changed under us", which the symlink now makes a non-issue — so I would rather leave it out than add a probe that only tests that a shell is alive.

Fixed in `ee15cdeb`. **The blocking point was correct**, and worse than the review could tell from the diff. ### 🔴 `tail -F` and the per-start log directory Your analysis holds exactly: `tail -F` follows a *pathname*, re-opening it when the inode behind that name changes, but it cannot notice Z2M writing somewhere else entirely. The old file just goes quiet, so the pipe never ends, my outer loop never re-resolves, `following <file>` keeps showing the stale path, and the sidecar looks healthy while permanently deaf. And it is **not** limited to the planned one-off restart you identified. The Z2M *container* has restarted **38 times on its own** from DNS flaps, each time into a new log directory, while the pod — and therefore the sidecar — kept running. So this would have broken within hours of merge, repeatedly, not just during the follow-up step. **Fixed at the source rather than with the poll-and-restart-tail workaround you suggested:** Z2M has `advanced.log_symlink_current`, which makes it maintain `log/current -> <active dir>`. Following that stable path means a restart is picked up with no watchdog at all. Your caveat about BusyBox semantics was the right thing to flag, so I tested it rather than reasoning about it — repointing a symlink mid-follow, in this exact image: ``` GOT: appendA-1 --- repointing symlink to b --- GOT: appendB-1 GOT: appendB-2 ``` BusyBox `tail -F` does follow the repoint. Then the full scenario you asked for, with the real script: ``` following /app/data/log/current/log.log (1 device(s) still need an interview) frame from 0xdeadbeefdeadbeef (nwk=65001 cluster=6) -> requesting interview interview requested for 0xdeadbeefdeadbeef >>> SIMULATING Z2M RESTART: new dir run-B, symlink repointed frame from 0xdeadbeefdeadbeef (nwk=65001 cluster=6) -> requesting interview <-- the case you predicted would be missed interview requested for 0xdeadbeefdeadbeef ``` I deliberately did **not** add a fallback to the newest directory: falling back risks silently tailing a frozen file, which is the very failure being fixed. A missing symlink is instead reported loudly and repeatedly while waiting. ### 🟡 awk fork per frame **Fixed.** Correct — the namespaced debug log covers all of `zh:controller`, so every device produces frames and the fork happened for ~30 devices' traffic indefinitely. Now short-circuited on a cached count, recounted at most once a minute so a device regressing to `PENDING` is still picked up. ### 🟡 Unbounded `mosquitto_pub` **Fixed, and this one bit me.** I first added `mosquitto_pub -W "$PUB_TIMEOUT"` — **that flag does not exist for `mosquitto_pub`** (it is a `mosquitto_sub` option), so every publish failed on a usage error: ``` WARNING: mosquitto_pub failed for 0xdeadbeefdeadbeef; not starting cooldown so the next frame retries ``` Exactly the same class of mistake as the `grep --line-buffered` one you caught last round, and again invisible to inspection — only running it exposed it. Removed; `timeout` alone bounds the call, and it also covers a TCP connect that hangs rather than being refused, which `-W` would not have. Silver lining: the failure path you asked for last round demonstrably works — it warned and refused to start the cooldown, so the next frame retried. ### 🟡 `MQTT_HOST` / `MQTT_USER` not wired **Fixed.** Both now set explicitly in the Deployment alongside `BASE_TOPIC`, with one comment covering all three and what they must track. ### 🟢 `imagePullPolicy` **Fixed** — `IfNotPresent`, matching the sibling container. ### One more bug, found by testing rather than review While testing the new short-circuit I hit a latent defect worth recording: `grep -c` prints `0` **and exits 1** when nothing matches, so `grep -c ... || echo 0` emitted **two** lines (`"0\n0"`) and every `-eq` test died with `bad number`. It surfaces only when zero devices need an interview — precisely the steady end state this feature exists to reach. Now normalised, with the trap documented so it does not get reintroduced. --- On probes: with the moving-target gone the specific silent-deafness failure mode is addressed, so I have not added a liveness probe to the sidecar. If you would still like one, the honest check is "has the tailed path changed under us", which the symlink now makes a non-issue — so I would rather leave it out than add a probe that only tests that a shell is alive.
claude-bot added 1 commit 2026-07-30 07:07:34 +00:00
feat(zigbee2mqtt): add watchdog heartbeat and document seed-only log keys
ci/woodpecker/pr/pr-review Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
a75c482f7d
Addresses the round-3 review (commit 39616401), which I failed to reply to
before pushing the next change.

Add an unconditional heartbeat logging the outstanding PENDING/FAILED
count. The main loop is purely event-driven, so if the
"Received payload: clusterID=" format ever changes (herdsman upgrade, log
format change) it would match nothing forever while still logging
"following <file>" and looking healthy. The per-frame recount cannot
detect that either, because it only runs when a frame is matched. The
heartbeat runs off a timer instead, so a count that never falls while
devices are demonstrably in use is a visible signal that the matcher
broke.

Document, in the ConfigMap itself rather than only in the PR body, that
the two log_* keys are seed-only and do not reach a running Z2M whose PVC
is already seeded. Both were therefore also applied to the live
configuration.yaml via bridge/request/options; verified they persist there
(lines 29-31 of the on-PVC file) so they survive a restart. Also noted
that log_symlink_current only takes effect on a Z2M restart, since the
symlink is created when the log directory is opened — until then the
sidecar waits and says so.

Not changed: resources.limits.cpu is still unset on the sidecar. The
review flagged it as consistent with the sibling containers in this file
and asked only whether it was intentional — it is. A CPU limit on a
mostly-idle process whose value is reacting inside a device's short wake
window buys nothing and risks throttling at exactly the wrong moment; the
memory limit already bounds the real risk.

Verified in eclipse-mosquitto:2.0.22 as uid 1883 with HEARTBEAT=5: the
heartbeat interleaves with frame handling and the simulated Z2M restart
without disturbing either.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Author
Member

Belated reply to the round-3 review (commit 39616401) — I pushed the next change without answering it, which was careless of me. Addressed in a75c482f.

🟡 No signal if the matcher silently stops matching

Implemented. This was the sharpest point of that round, and it generalises beyond a log-format change: it is the same shape as the two real bugs that have since been caught here (grep --line-buffered, mosquitto_pub -W) — a sidecar that looks perfectly healthy while doing nothing.

There is now an unconditional heartbeat logging the outstanding PENDING/FAILED count on a timer. Worth noting why it has to be timer-based rather than reusing the per-frame recount: the recount only runs when a frame is matched, so in the exact failure being guarded against it would never fire either. Verified with HEARTBEAT=5:

interview-watch: heartbeat: 1 device(s) still need an interview
interview-watch: frame from 0xdeadbeefdeadbeef (nwk=65001 cluster=6) -> requesting interview
interview-watch:   interview requested for 0xdeadbeefdeadbeef
interview-watch: heartbeat: 1 device(s) still need an interview

So a count that never falls while devices are demonstrably being used is now a visible regression signal.

🟡 No resources.limits.cpu

Intentional, not an oversight — thanks for asking rather than assuming. A CPU limit on a mostly-idle process whose entire value is reacting inside a device's short wake window buys nothing and risks throttling at precisely the wrong moment. The memory limit already bounds the real failure mode. Left consistent with the sibling containers, and the reasoning is now recorded in the commit.

🟢 Seed-only ConfigMap caveat belongs in the YAML

Fixed. Correct that the PR body is the wrong place for it. The advanced: block now carries the caveat directly: these log_* keys do not reach a running Z2M whose PVC is already seeded, so both were also applied to the live configuration.yaml via bridge/request/options.

While writing that I checked whether they actually survive a restart, and the answer was worth confirming rather than assuming — my first check suggested they had not persisted, which would have meant the merged sidecar going permanently blind after the next restart. That was a bad extraction on my part (a sed range and a grep -A4 that both truncated). They are persisted, at lines 29–31 of the on-PVC file:

  log_namespaced_levels:
    zh:controller: debug
  log_symlink_current: true

Also now documented: log_symlink_current only takes effect on a Z2M restart, because the symlink is created when the log directory is opened. Until then the sidecar has no stable path and says so loudly rather than falling back to a file that may be frozen.

Also in this push

The round-4 blocking issue and its 🟡s are answered separately in the previous comment (ee15cdeb) — stable-symlink following, awk short-circuit, timeout-bounded publish, MQTT_HOST/MQTT_USER wired from the Deployment, imagePullPolicy.

Belated reply to the **round-3 review** (commit `39616401`) — I pushed the next change without answering it, which was careless of me. Addressed in `a75c482f`. ### 🟡 No signal if the matcher silently stops matching **Implemented.** This was the sharpest point of that round, and it generalises beyond a log-format change: it is the same shape as the two real bugs that have since been caught here (`grep --line-buffered`, `mosquitto_pub -W`) — a sidecar that looks perfectly healthy while doing nothing. There is now an unconditional heartbeat logging the outstanding `PENDING`/`FAILED` count on a timer. Worth noting *why* it has to be timer-based rather than reusing the per-frame recount: the recount only runs when a frame is **matched**, so in the exact failure being guarded against it would never fire either. Verified with `HEARTBEAT=5`: ``` interview-watch: heartbeat: 1 device(s) still need an interview interview-watch: frame from 0xdeadbeefdeadbeef (nwk=65001 cluster=6) -> requesting interview interview-watch: interview requested for 0xdeadbeefdeadbeef interview-watch: heartbeat: 1 device(s) still need an interview ``` So a count that never falls while devices are demonstrably being used is now a visible regression signal. ### 🟡 No `resources.limits.cpu` **Intentional, not an oversight** — thanks for asking rather than assuming. A CPU limit on a mostly-idle process whose entire value is reacting inside a device's short wake window buys nothing and risks throttling at precisely the wrong moment. The memory limit already bounds the real failure mode. Left consistent with the sibling containers, and the reasoning is now recorded in the commit. ### 🟢 Seed-only ConfigMap caveat belongs in the YAML **Fixed.** Correct that the PR body is the wrong place for it. The `advanced:` block now carries the caveat directly: these `log_*` keys do **not** reach a running Z2M whose PVC is already seeded, so both were also applied to the live `configuration.yaml` via `bridge/request/options`. While writing that I checked whether they actually survive a restart, and the answer was worth confirming rather than assuming — my first check suggested they had **not** persisted, which would have meant the merged sidecar going permanently blind after the next restart. That was a bad extraction on my part (a `sed` range and a `grep -A4` that both truncated). They are persisted, at lines 29–31 of the on-PVC file: ```yaml log_namespaced_levels: zh:controller: debug log_symlink_current: true ``` Also now documented: `log_symlink_current` only takes effect on a Z2M **restart**, because the symlink is created when the log directory is opened. Until then the sidecar has no stable path and says so loudly rather than falling back to a file that may be frozen. ### Also in this push The round-4 blocking issue and its 🟡s are answered separately in the previous comment (`ee15cdeb`) — stable-symlink following, awk short-circuit, `timeout`-bounded publish, `MQTT_HOST`/`MQTT_USER` wired from the Deployment, `imagePullPolicy`.
k.renner merged commit 4ab099f4c0 into master 2026-07-30 07:11:25 +00:00
review-bot left a comment
Member

🤖 Automated review — model CLI default (complexity: complex), commit a75c482f7dc1e42aa4ca1a261e3d0b37eeca753c.

Advisory review by the review-bot pipeline. It never approves; a human merges. Push new commits to get a fresh review.


Summary

Adds an interview-watch sidecar to the Zigbee2MQTT deployment that tails Z2M's debug log for zh:controller frame-receipt lines and fires a forced re-interview (via mosquitto_pub) for any device still PENDING/FAILED in database.db. Also enables the two advanced.log_* config keys the sidecar depends on, wires a new ConfigMap for the script, and documents the change in the migration runbook. The script is unusually well-reasoned for a shell one-off — it explicitly documents and guards against several real BusyBox/Alpine gotchas (no --line-buffered in BusyBox grep, grep -c's dual-output-line trap on no-match, mosquitto_pub -W not existing, the tail -F-on-newest-dir trap around Z2M restarts) — and I could not find a concrete defect. No blocking issues; a few points below are worth a look before/soon after merge.

🟡 Suggestions

  • kubernetes/apps/home-assistant/zigbee2mqtt-interview-watch.sh:153-158 — the "verified present in this exact image" utility list (sh, tail (incl. -F), awk, cat, date, mkdir, mosquitto_pub) omits grep (used in count_needing_interview, line ~255) and timeout (wraps every mosquitto_pub call, line ~388). Both are near-certainly present in eclipse-mosquitto:2.0.22's BusyBox, and a missing timeout would fail loud (the script's own "WARNING: mosquitto_pub failed" branch, not silently), so this is low risk — but worth a quick confirmation given how carefully every other utility in this script was checked and called out.
  • zigbee2mqtt-interview-watch.sh:332-400 — the frame-read loop is single-threaded and blocks on mosquitto_pub for up to PUB_TIMEOUT (10s default) once a trigger fires. Frames from other devices that arrive during that window queue in the tail -F pipe and aren't processed until the current publish returns/times out. Given the whole premise here is racing a short wake window (11–34s observed), a slow/hanging broker connection could cost a concurrently-waking device its window. Probably an acceptable tradeoff for a temporary watchdog, but worth being aware of if two sensors trip near-simultaneously (e.g. a leak event).
  • No trap ... TERM around the main loop / heartbeat background job (zigbee2mqtt-interview-watch.sh:305-310). On pod termination the script (PID 1 of this container) has no handler, so cleanup of the backgrounded heartbeat and any in-flight tail/awk relies on the container being torn down at the end of terminationGracePeriodSeconds rather than exiting promptly on SIGTERM. Minor given this is a lightweight, explicitly temporary sidecar.

🟢 Nits

  • Log lines identify devices only by IEEE address (zigbee2mqtt-interview-watch.sh:376, :396), not friendly_name — unlike the PR description's example log line, which shows a human-readable name. database.db has the friendly name available if it'd be useful for on-call readability, though it's not essential for a script meant to be temporary.

Tests

No automated tests exist for this (expected in this GitOps repo — no shellcheck/kustomize-build CI step covers kubernetes/apps/**, confirmed via .woodpecker/ci-scripts-test.yml which only exercises scripts/ci/). The PR body documents live verification against the real cluster (13→17 SUCCESSFUL devices, 11–34s interview completion), which is reasonable evidence for this kind of infra change. The script itself fails loud rather than silently on its two riskiest assumptions (log line format via the digit-only guard on nwk, and grep -c's no-match quirk), which meaningfully lowers the risk of an undetected regression.

Recommendation

Looks reasonable — no blocking issues; comments above are optional.

🤖 **Automated review** — model `CLI default` (complexity: complex), commit `a75c482f7dc1e42aa4ca1a261e3d0b37eeca753c`. > Advisory review by the `review-bot` pipeline. It never approves; a human merges. Push new commits to get a fresh review. --- ## Summary Adds an `interview-watch` sidecar to the Zigbee2MQTT deployment that tails Z2M's debug log for `zh:controller` frame-receipt lines and fires a forced re-interview (via `mosquitto_pub`) for any device still `PENDING`/`FAILED` in `database.db`. Also enables the two `advanced.log_*` config keys the sidecar depends on, wires a new ConfigMap for the script, and documents the change in the migration runbook. The script is unusually well-reasoned for a shell one-off — it explicitly documents and guards against several real BusyBox/Alpine gotchas (no `--line-buffered` in BusyBox grep, `grep -c`'s dual-output-line trap on no-match, `mosquitto_pub -W` not existing, the `tail -F`-on-newest-dir trap around Z2M restarts) — and I could not find a concrete defect. No blocking issues; a few points below are worth a look before/soon after merge. ## 🟡 Suggestions - `kubernetes/apps/home-assistant/zigbee2mqtt-interview-watch.sh:153-158` — the "verified present in this exact image" utility list (`sh, tail (incl. -F), awk, cat, date, mkdir, mosquitto_pub`) omits `grep` (used in `count_needing_interview`, line ~255) and `timeout` (wraps every `mosquitto_pub` call, line ~388). Both are near-certainly present in `eclipse-mosquitto:2.0.22`'s BusyBox, and a missing `timeout` would fail loud (the script's own "WARNING: mosquitto_pub failed" branch, not silently), so this is low risk — but worth a quick confirmation given how carefully every other utility in this script was checked and called out. - `zigbee2mqtt-interview-watch.sh:332-400` — the frame-read loop is single-threaded and blocks on `mosquitto_pub` for up to `PUB_TIMEOUT` (10s default) once a trigger fires. Frames from *other* devices that arrive during that window queue in the `tail -F` pipe and aren't processed until the current publish returns/times out. Given the whole premise here is racing a short wake window (11–34s observed), a slow/hanging broker connection could cost a concurrently-waking device its window. Probably an acceptable tradeoff for a temporary watchdog, but worth being aware of if two sensors trip near-simultaneously (e.g. a leak event). - No `trap ... TERM` around the main loop / heartbeat background job (`zigbee2mqtt-interview-watch.sh:305-310`). On pod termination the script (PID 1 of this container) has no handler, so cleanup of the backgrounded heartbeat and any in-flight `tail`/`awk` relies on the container being torn down at the end of `terminationGracePeriodSeconds` rather than exiting promptly on SIGTERM. Minor given this is a lightweight, explicitly temporary sidecar. ## 🟢 Nits - Log lines identify devices only by IEEE address (`zigbee2mqtt-interview-watch.sh:376`, `:396`), not `friendly_name` — unlike the PR description's example log line, which shows a human-readable name. `database.db` has the friendly name available if it'd be useful for on-call readability, though it's not essential for a script meant to be temporary. ## Tests No automated tests exist for this (expected in this GitOps repo — no shellcheck/kustomize-build CI step covers `kubernetes/apps/**`, confirmed via `.woodpecker/ci-scripts-test.yml` which only exercises `scripts/ci/`). The PR body documents live verification against the real cluster (13→17 `SUCCESSFUL` devices, 11–34s interview completion), which is reasonable evidence for this kind of infra change. The script itself fails loud rather than silently on its two riskiest assumptions (log line format via the digit-only guard on `nwk`, and `grep -c`'s no-match quirk), which meaningfully lowers the risk of an undetected regression. ## Recommendation Looks reasonable — no blocking issues; comments above are optional. <!-- pr-review:sha=a75c482f7dc1e42aa4ca1a261e3d0b37eeca753c -->
Sign in to join this conversation.