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": [