fix(zigbee2mqtt): drop BusyBox-incompatible grep from watchdog
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>
This commit is contained in:
@@ -164,6 +164,17 @@ spec:
|
||||
secretKeyRef:
|
||||
name: mosquitto-zigbee2mqtt-credentials
|
||||
key: password
|
||||
# Must track mqtt.base_topic in zigbee2mqtt-config.yaml.
|
||||
- name: BASE_TOPIC
|
||||
value: zigbee2mqtt
|
||||
# No runAsNonRoot: `apk add` needs root, and Z2M writes /app/data as
|
||||
# root (database.db and the log dir are root-owned 0644/0755), so a
|
||||
# non-root uid could not read either input. Dropping privilege
|
||||
# escalation and all capabilities is still worth having.
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop: [ALL]
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /app/data
|
||||
|
||||
@@ -54,6 +54,11 @@ DATA=/app/data
|
||||
DB="$DATA/database.db"
|
||||
MQTT_HOST=${MQTT_HOST:-mosquitto}
|
||||
MQTT_USER=${MQTT_USER:-zigbee2mqtt}
|
||||
# Must track mqtt.base_topic in zigbee2mqtt-config.yaml. Overridable via env so
|
||||
# the two can be kept in step from the Deployment rather than by editing this
|
||||
# script; the MQTT ACL for this user is scoped to `zigbee2mqtt/#`, so a change
|
||||
# here needs a matching ACL change too.
|
||||
BASE_TOPIC=${BASE_TOPIC:-zigbee2mqtt}
|
||||
COOLDOWN=${COOLDOWN:-300} # min seconds between interview attempts per device
|
||||
STATEDIR=/tmp/interview-cooldown
|
||||
|
||||
@@ -115,12 +120,33 @@ while :; do
|
||||
|
||||
# tail -F (--follow=name --retry) rides out an in-place rotation; the outer
|
||||
# loop handles Z2M starting a whole new log directory.
|
||||
#
|
||||
# Matching happens in the read loop rather than via a `grep` stage on purpose.
|
||||
# This runs on alpine:3.21, whose `grep` is the BusyBox applet, and BusyBox
|
||||
# grep does NOT support --line-buffered:
|
||||
# grep: unrecognized option: line-buffered (exit 2)
|
||||
# so a `tail -F | grep --line-buffered | while read` pipe yields ZERO lines
|
||||
# forever while the sidecar still logs "following <file>" and looks healthy —
|
||||
# a silent no-op of the whole feature. Dropping the stage avoids both that and
|
||||
# any block-buffering question, since `tail` writes per line and `read`
|
||||
# consumes per line, with no extra package to install.
|
||||
tail -F "$LOGFILE" 2>/dev/null |
|
||||
grep --line-buffered -oE 'Received payload: clusterID=[0-9]+, address=[0-9]+' |
|
||||
while IFS= read -r line; do
|
||||
nwk=${line##*address=}
|
||||
cluster=${line#*clusterID=}
|
||||
cluster=${cluster%%,*}
|
||||
case "$line" in
|
||||
*"Received payload: clusterID="*) ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
|
||||
# ... clusterID=6, address=12468, groupID=0, endpoint=1, ...
|
||||
rest=${line#*"Received payload: clusterID="}
|
||||
cluster=${rest%%,*}
|
||||
rest=${rest#*address=}
|
||||
nwk=${rest%%,*}
|
||||
|
||||
# Guard against a format change silently yielding garbage.
|
||||
case "$nwk" in
|
||||
''|*[!0-9]*) continue ;;
|
||||
esac
|
||||
|
||||
ieee=$(ieee_needing_interview "$nwk")
|
||||
[ -z "${ieee:-}" ] && continue # already interviewed, or not a device we track
|
||||
@@ -133,7 +159,6 @@ while :; do
|
||||
# this on every attribute read it performs.
|
||||
[ $((now - last)) -lt "$COOLDOWN" ] && continue
|
||||
fi
|
||||
printf '%s' "$now" > "$stamp"
|
||||
|
||||
log "frame from $ieee (nwk=$nwk cluster=$cluster) while awake -> requesting interview"
|
||||
# NOTE: mosquitto_pub can only take a password via argv (-P, or an -L URL);
|
||||
@@ -141,11 +166,16 @@ while :; do
|
||||
# client. Not a cross-container exposure: the pod does not set
|
||||
# shareProcessNamespace, so /proc is not shared with the Z2M container.
|
||||
if mosquitto_pub -h "$MQTT_HOST" -u "$MQTT_USER" -P "$MQTT_PASSWORD" \
|
||||
-t 'zigbee2mqtt/bridge/request/device/interview' \
|
||||
-t "$BASE_TOPIC/bridge/request/device/interview" \
|
||||
-m "{\"id\":\"$ieee\"}" 2>/dev/null; then
|
||||
# Stamp only on a SUCCESSFUL publish. Stamping unconditionally would burn
|
||||
# the device's wake window on a failed publish (broker hiccup, DNS blip)
|
||||
# and then refuse to retry for the full cooldown, despite no interview
|
||||
# ever having been requested.
|
||||
printf '%s' "$now" > "$stamp"
|
||||
log " interview requested for $ieee"
|
||||
else
|
||||
log " WARNING: mosquitto_pub failed for $ieee"
|
||||
log " WARNING: mosquitto_pub failed for $ieee; not starting cooldown so the next frame retries"
|
||||
fi
|
||||
done
|
||||
|
||||
|
||||
@@ -144,7 +144,8 @@ empty, so each device must be re-interviewed before it reports fully. Order:
|
||||
Keep `permit_join` **off** (it is, by config) — this is re-interviewing, not
|
||||
re-pairing; nothing leaves and rejoins the network.
|
||||
|
||||
> **Step 2 is now automated — do not chase these by hand.** The
|
||||
> **Item 2 above (battery/sleepy devices) is now automated — do not chase
|
||||
> those by hand.** The
|
||||
> `interview-watch` sidecar (`zigbee2mqtt-interview-watch.sh`) fires a forced
|
||||
> interview whenever a still-`PENDING`/`FAILED` device transmits, which is the
|
||||
> only moment a sleepy device can complete one. Devices heal as they are used or
|
||||
|
||||
Reference in New Issue
Block a user