From 826042f3eac2858406a82f742dd43ef53cef946c Mon Sep 17 00:00:00 2001 From: hermes-bot Date: Wed, 4 Nov 2026 20:46:03 +0000 Subject: [PATCH] fix(zwave-gateway): zwave-js-ui 11.25.2, Renovate for edge images Home Assistant 2026.11.0 requires Z-Wave JS Server schema 51. zwave-js-ui 11.21.1 ships @zwave-js/server 3.10 (schema 50), so the zwave_js entry is stuck in setup_retry. 11.24.2 is the first release on @zwave-js/server ^3.11.0 (schema 51); bump to the current 11.25.2, pinned by digest. 11.25.2 is built on Alpine 3.23 (was 3.22). The role now reads the Alpine release from the image and names the musl libeatmydata.so after it, instead of a hand-synced edge_zwave_alpine_base: Renovate bumps the image from now on, and the old `creates:` never refreshed the .so. Builds for other Alpine releases are removed. Renovate never read ansible/roles/*/defaults/main.yml: enabledManagers had no custom.regex and there were no customManagers. Add one for edge__image (docker datasource and versioning), and give ansible/** updates their own PR, since the play runs from a workstation after the merge (no Ansible pipeline since 0c1fb0a2). --- ansible/docs/edge-host.md | 20 ++++++----- ansible/roles/zwave-gateway/defaults/main.yml | 8 ++--- ansible/roles/zwave-gateway/tasks/main.yml | 35 +++++++++++++++++-- .../templates/docker-compose.yml.j2 | 2 +- renovate.json | 31 +++++++++++++++- 5 files changed, 76 insertions(+), 20 deletions(-) diff --git a/ansible/docs/edge-host.md b/ansible/docs/edge-host.md index f2bd6bbb..81dcaf9a 100644 --- a/ansible/docs/edge-host.md +++ b/ansible/docs/edge-host.md @@ -134,11 +134,13 @@ Afterwards: ## Day-2 notes -- Image bumps: edit `edge_zwavejs_image` / `edge_sml2mqtt_image` in - defaults and re-run the play (no flux image-automation out here — - deliberate for sml2mqtt, acceptable for zwave-js). If a zwave-js bump - changes its Alpine major, also update `edge_zwave_alpine_base` and refresh - the preloaded `.so` (see "Logging & eMMC" → zwave-js). +- Image bumps: Renovate opens a PR for `edge_*_image` in + `roles/*/defaults/main.yml` (tag + digest, `customManagers` in + `renovate.json`). No pipeline deploys to edge-1, so after the merge run + `ansible-playbook plays/install-zwave-gateway.yml` from `ansible/` on a + mesh-connected workstation. The play reads the image's Alpine release + itself and rebuilds the preloaded `.so` when it changes (see + "Logging & eMMC" → zwave-js). - open3e / E3onCAN updates: both are pinned to commits in `roles/heating-can/defaults/main.yml`, so re-running the play does NOT pick up upstream changes -- that is deliberate, these drive the heat pump. The @@ -190,7 +192,7 @@ journal commit). - **Fix:** the `zwave-js` container preloads **`libeatmydata`** (`LD_PRELOAD=/usr/lib/libeatmydata.so`, mounted read-only from - `zwave-js/libeatmydata.so`). This makes `fsync`/`fdatasync` no-ops *for that + `zwave-js/libeatmydata-alpine.so`). This makes `fsync`/`fdatasync` no-ops *for that one process*, so the kernel batches the writes via normal writeback (~30 s) instead of forcing each one. The WS/MQTT value stream to Home Assistant and the on-disk cache are **unchanged** — full data resolution, no re-interview @@ -199,9 +201,9 @@ journal commit). batch it); the only mount-level alternative is putting the cache on tmpfs, which makes the *whole* store volatile → re-interview on every restart. - **The `.so` is musl** (the image is Alpine-based), extracted from - `edge_zwave_alpine_base` — keep that var in sync with the image's Alpine - version; to refresh after a major bump, `rm zwave-js/libeatmydata.so` and - re-run. + `alpine:`, where `X.Y` is read from the image's `/etc/alpine-release` + on every run. A new Alpine release gives a new file name, so the play + extracts it, recreates the container and deletes the old file. - **Durability guard:** because `fsync` is neutralized, a hard power-loss during the (rare) settings write could truncate `settings.json`, which holds the **Z-Wave network keys**. `edge-zwave-settings-backup.timer` keeps a diff --git a/ansible/roles/zwave-gateway/defaults/main.yml b/ansible/roles/zwave-gateway/defaults/main.yml index 79ea0dad..1bb16fe1 100644 --- a/ansible/roles/zwave-gateway/defaults/main.yml +++ b/ansible/roles/zwave-gateway/defaults/main.yml @@ -13,12 +13,8 @@ zwave_base_dir: "{{ edge_base_dir }}/zwave-js" # survives moving the stick between machines. edge_zwave_device: /dev/serial/by-id/usb-Nabu_Casa_ZWA-2_80B54EE15720-if00 -edge_zwavejs_image: ghcr.io/zwave-js/zwave-js-ui:11.21.1 - -# Alpine version the zwave-js-ui image is built on -- the source for the musl -# libeatmydata.so preloaded into that container. Keep in sync if the image -# bumps its Alpine major (check: docker exec zwave-js cat /etc/os-release). -edge_zwave_alpine_base: alpine:3.22 +# Renovate-tracked (customManagers in renovate.json). +edge_zwavejs_image: ghcr.io/zwave-js/zwave-js-ui:11.25.2@sha256:ac4874a3cbea61c0c60002a8fd8ae05104cc8eb2eae91aa05bd184972aa06853 # Secret from inventory/group_vars/edge_host/vault.yml: # edge_zwavejs_session_secret diff --git a/ansible/roles/zwave-gateway/tasks/main.yml b/ansible/roles/zwave-gateway/tasks/main.yml index a87c14f2..7da041c5 100644 --- a/ansible/roles/zwave-gateway/tasks/main.yml +++ b/ansible/roles/zwave-gateway/tasks/main.yml @@ -15,17 +15,31 @@ - "{{ zwave_base_dir }}" - "{{ zwave_base_dir }}/store" +# The preloaded .so is musl and must come from the image's own Alpine release. +- name: Read the Alpine release of the zwave-js-ui image + ansible.builtin.command: + cmd: docker run --rm --entrypoint cat {{ edge_zwavejs_image }} /etc/alpine-release + register: zwave_alpine_release + changed_when: false + check_mode: false + +- name: Name libeatmydata.so after that Alpine release + ansible.builtin.set_fact: + zwave_alpine_minor: "{{ zwave_alpine_release.stdout.split('.')[:2] | join('.') }}" + zwave_libeatmydata: "libeatmydata-alpine{{ zwave_alpine_release.stdout.split('.')[:2] | join('.') }}.so" + - name: Extract musl libeatmydata.so (matches the zwave-js-ui Alpine base) ansible.builtin.shell: cmd: | set -euo pipefail tmp="$(mktemp)" trap 'rm -f "$tmp"' EXIT - docker run --rm --network=host {{ edge_zwave_alpine_base }} \ + docker run --rm --network=host alpine:{{ zwave_alpine_minor }} \ sh -c 'apk add --no-cache libeatmydata >/dev/null 2>&1 && cat /usr/lib/libeatmydata.so' > "$tmp" test "$(stat -c%s "$tmp")" -ge 8000 # sanity: real .so is ~14 KB, guards against a failed/empty pull - install -m 0644 "$tmp" {{ zwave_base_dir }}/libeatmydata.so - creates: "{{ zwave_base_dir }}/libeatmydata.so" + install -m 0644 "$tmp" {{ zwave_base_dir }}/{{ zwave_libeatmydata }} + creates: "{{ zwave_base_dir }}/{{ zwave_libeatmydata }}" + executable: /bin/bash notify: Restart zwave-js # Because fsync is neutralized, a hard power-loss during the (rare) settings @@ -93,3 +107,18 @@ zwave-js could not start -- the ZWA-2 radio is not present: {{ zwave_compose_up.stderr_lines | select('search', 'device') | list }} when: zwave_compose_up.rc != 0 + +- name: Find libeatmydata.so builds of other Alpine releases + ansible.builtin.find: + paths: "{{ zwave_base_dir }}" + patterns: "libeatmydata*.so" + excludes: "{{ zwave_libeatmydata }}" + register: zwave_stale_libeatmydata + +- name: Remove libeatmydata.so builds of other Alpine releases + ansible.builtin.file: + path: "{{ item.path }}" + state: absent + loop: "{{ zwave_stale_libeatmydata.files }}" + loop_control: + label: "{{ item.path }}" diff --git a/ansible/roles/zwave-gateway/templates/docker-compose.yml.j2 b/ansible/roles/zwave-gateway/templates/docker-compose.yml.j2 index d01dbd96..7c241fea 100644 --- a/ansible/roles/zwave-gateway/templates/docker-compose.yml.j2 +++ b/ansible/roles/zwave-gateway/templates/docker-compose.yml.j2 @@ -31,7 +31,7 @@ services: - "{{ edge_zwave_device }}:/dev/zwave" volumes: # musl libeatmydata.so extracted from the image's Alpine base (ro) - - ./libeatmydata.so:/usr/lib/libeatmydata.so:ro + - ./{{ zwave_libeatmydata }}:/usr/lib/libeatmydata.so:ro # Persistent store: settings.json, device DB, network keys. Path is # unchanged from the previous shared project, so nothing is migrated. - ./store:/usr/src/app/store diff --git a/renovate.json b/renovate.json index 4f3069f4..0d5b2441 100644 --- a/renovate.json +++ b/renovate.json @@ -7,7 +7,8 @@ "flux", "kubernetes", "woodpecker", - "dockerfile" + "dockerfile", + "custom.regex" ], "ignorePaths": [ "kubernetes/clusters/homelab/flux-system/**", @@ -23,6 +24,20 @@ "/^kubernetes/.+\\.ya?ml$/" ] }, + "customManagers": [ + { + "customType": "regex", + "description": "Container images of the Ansible roles: `edge__image: :@` in ansible/roles/*/defaults/main.yml. No other manager reads these files.", + "managerFilePatterns": [ + "/^ansible/roles/[^/]+/defaults/main\\.ya?ml$/" + ], + "matchStrings": [ + "(?:^|\\n)edge_[a-z0-9_]+_image:[ \\t]*[\"']?(?[^\\s:@\"']+):(?[^\\s@\"']+)(?:@(?sha256:[a-f0-9]{64}))?" + ], + "datasourceTemplate": "docker", + "versioningTemplate": "docker" + } + ], "packageRules": [ { "description": "Flux image automation (kubernetes/infrastructure/image-automation) already bumps these images. Renovate must not open a second, competing update. Keep this list equal to the ImageRepository images there.", @@ -134,6 +149,20 @@ "groupName": "CI non-major", "groupSlug": "ci-minor-patch" }, + { + "description": "Edge-host images (ansible/): no pipeline deploys them, the play runs from a workstation after the merge. One PR of its own, so the merge and the play run go together.", + "matchFileNames": [ + "ansible/**" + ], + "matchUpdateTypes": [ + "patch", + "minor", + "digest", + "pin" + ], + "groupName": "edge hosts (ansible)", + "groupSlug": "edge-hosts-ansible" + }, { "description": "Stateful apps and the CNI: one PR each, never in a group (REN-315). A git revert does not undo a DB migration or a CNI change, so each needs its own review and, where it can disrupt the cluster, a window.", "matchUpdateTypes": [ -- 2.54.0