diff --git a/cookbooks/zwave-js-ui/README.md b/cookbooks/zwave-js-ui/README.md index 763c72a..8781bbf 100644 --- a/cookbooks/zwave-js-ui/README.md +++ b/cookbooks/zwave-js-ui/README.md @@ -27,8 +27,9 @@ This cookbook: - Supports automatic container image updates via Podman auto-update. The image is `ghcr.io/zwave-js/zwave-js-ui:11`, a multi-architecture manifest (amd64 + -arm64, verified 2026-09-26, version 11.24.1). It is named in the container unit rather than in -a `.image` unit — see the deviations below, and the reason is boot resilience, not taste. +arm64, verified 2026-09-26, version 11.24.1). It is pulled by `zwave-js-ui.image`, which the +container **wants** rather than requires — see the deviations below, and the reason is boot +resilience, not taste. ## What this cookbook does NOT do @@ -199,17 +200,41 @@ a restart loop fixes. artifact. What the external file buys is not secrecy: it is that the authoritative copy lives outside the application, is restorable from a vault, and cannot be edited from the web interface. -- **No `.image` unit: the image is named in the container.** A `.image` unit runs - `podman image pull`, which contacts the registry even when the image is already in local - storage. A machine that boots before its network does — a power cut, a switch slower than the - machine, a cable plugged in after power-on — fails that pull with - `Temporary failure in name resolution`, and the `Requires=` that quadlet generates on the - `.image` unit takes the container down with it. **A failed dependency is never retried**: - `Restart=always` only ever applies to a unit that started at least once, so the service stays - down until someone notices. Naming the image in the container instead lets podman resolve it - at start with its default `--pull=missing`: the local image starts with no registry involved, - and a genuinely missing image fails the container, which `Restart=` then retries until the - network is there. Auto-update is unaffected — it reads the container's label. +- **The container wants the `.image` unit, it does not require it — and it names the image + reference, not the unit.** This is the deviation, and it is the one this cookbook has already + paid for. + + A `.image` unit runs `podman image pull`, which contacts the registry even when the image is + already in local storage. A machine that boots before its network does — a PoE port whose + switch takes a minute to forward, an uplink that comes back after the machine — fails that + pull with `Temporary failure in name resolution`. Naming the unit in `Image=` is what makes + quadlet generate a `Requires=` on it, and **a failed dependency is never retried**: + `Restart=` only ever applies to a unit that started at least once, so the container stays down + until a human notices. On the radio head that was fifty-five minutes, with the image sitting + in local storage the whole time. + + What does *not* fix it, measured rather than assumed: `Restart=on-failure` on the `.image` + unit. The pull converges, and the container stays `inactive` for good, because its job died + at the first failure. (`Upholds=` on the pull unit does pick the container up, at the price of + a container that can no longer be stopped on its own — and it still leaves the container down + for as long as the registry is unreachable, even though the image is right there.) + + So the pull keeps its own unit — it can be long, and its duration has no business inside the + container's start time — the container keeps `After=` on it so the pull still runs first when + it can, and the dependency is `Wants=`. Podman then resolves the image at container start with + its default `--pull=missing`: the local image starts with no registry involved, and a genuinely + missing image fails the container, which `Restart=` retries. Auto-update is unaffected, it + reads the container's label. + + The pull unit also carries `Restart=on-failure` with `StartLimitIntervalSec=0`, so a machine + that boots without a network still ends up with the image rather than with a stale one, and + `PartOf=zwave-js-ui.target`, so stopping the target leaves nothing retrying behind. + + **The image reference appears in both files on purpose**, and the two must be changed together. + A divergence is not silent — the pull unit fetches one tag and the container runs the other, + which both journals say out loud — but it is a divergence. The alternative, a drop-in that + resets the generated `Requires=`, has to restate the other requirements of the container, and + silently drops any that a later edit adds. That trap is worse than this one. - **Only `external-settings.json` is mounted into the container, never the configuration directory.** `:Z` relabels what it mounts to `container_file_t`, recursively and on disk, and `init.sh` lives in that directory. Mounting the directory therefore leaves a script systemd diff --git a/cookbooks/zwave-js-ui/zwave-js-ui.container b/cookbooks/zwave-js-ui/zwave-js-ui.container index 5879a0e..cf3a063 100644 --- a/cookbooks/zwave-js-ui/zwave-js-ui.container +++ b/cookbooks/zwave-js-ui/zwave-js-ui.container @@ -4,6 +4,21 @@ Documentation=https://zwave-js.github.io/zwave-js-ui/ After=network.target var-lib-virtiofs-data.mount zwave-js-ui-init.service Requires=var-lib-virtiofs-data.mount zwave-js-ui-init.service +# The pull runs in its own unit -- it can be long, and its duration has no +# business inside this container's start time -- but it is Wants=, NOT Requires=. +# A pull needs the registry; starting this container does not, as long as the +# image is in local storage. Requires= is what took the radio head down for +# fifty-five minutes on 2026-09-26: no name resolution twenty-six seconds into +# the boot, pull failed, dependency failed, and a failed dependency is never +# retried -- Restart= only ever applies to a unit that started at least once. +# Measured, not assumed: a Restart=on-failure on the pull unit does make the pull +# converge, and leaves this container inactive for good. +# +# After= still orders the pull before this container, so nothing is lost on a +# boot where the network is there. +Wants=zwave-js-ui-image.service +After=zwave-js-ui-image.service + # The Z-Wave controller, under the stable name a udev rule gives it. Without it # there is nothing to talk to: the driver would retry forever and every entity # of the household would sit unavailable, which is noise, not a service. @@ -21,20 +36,21 @@ PartOf=zwave-js-ui.target [Container] ContainerName=zwave-js-ui -# The image reference, and NOT a zwave-js-ui.image unit -- the one deviation from -# the convention of this repository, for a reason this cookbook has already paid -# for. A .image unit runs `podman image pull`, which contacts the registry even -# when the image is already in local storage. A radio head that boots before its -# network does -- a power cut, a switch that is slower than it is, a cable -# plugged in after the machine -- therefore fails that pull, and the generated -# `Requires=` on it takes the container down with it. A failed dependency is -# never retried: Restart=always only ever applies to a unit that started once. +# The image REFERENCE, and not the name of the zwave-js-ui.image unit, although +# that unit exists and is what pulls this image. Naming the unit here is what +# makes quadlet generate a Requires= on it, and that Requires= is the fault +# described in [Unit] above. +# +# Named as a reference, podman resolves it at container start with its default +# --pull=missing: the local image starts with no registry involved, and a +# genuinely missing image fails the container, which Restart= below retries until +# the pull unit has done its job. Auto-update is unaffected, it reads the label +# of the container. # -# Named here instead, podman resolves the image at container start with its -# default --pull=missing: the local image starts with no registry involved, and a -# genuinely missing image fails the container, which Restart= below then retries -# until the network is there. Auto-update still works -- it reads the label of -# the container, not the .image unit. +# THIS REFERENCE IS ALSO IN zwave-js-ui.image, on purpose, and the two must be +# changed together. A divergence is not silent -- the pull unit fetches one tag +# and this container runs the other, which the journal of both says out loud -- +# but it is still a divergence. Image=ghcr.io/zwave-js/zwave-js-ui:11 AutoUpdate=registry diff --git a/cookbooks/zwave-js-ui/zwave-js-ui.image b/cookbooks/zwave-js-ui/zwave-js-ui.image new file mode 100644 index 0000000..0ed89ce --- /dev/null +++ b/cookbooks/zwave-js-ui/zwave-js-ui.image @@ -0,0 +1,34 @@ +[Unit] +Description=podman pull ghcr.io/zwave-js/zwave-js-ui +Documentation=https://zwave-js.github.io/zwave-js-ui/ + +# Keep trying. A machine whose network arrives after it does -- a PoE port whose +# switch takes a minute to forward, an uplink that comes back after the machine -- +# fails this pull once, twenty-six seconds into the boot, and a oneshot that +# failed once never runs again. Measured on the radio head: network-online.target +# was reached at boot+26s and the DHCP lease only landed at boot+72s. +# +# StartLimitIntervalSec=0 is what makes the retry unlimited: the default start +# limit (5 attempts in 10 s) turns the unit "failed" for good after 15 seconds of +# a network that needs a minute. +StartLimitIntervalSec=0 + +# Stopping the service target stops the pull with it, so `systemctl stop +# zwave-js-ui.target` leaves nothing retrying behind. +PartOf=zwave-js-ui.target + +[Image] +# Multi-architecture manifest (amd64 + arm64, verified 2026-09-26 on tag 11 -> +# 11.24.1), so the cookbook installs on an aarch64 host as well as on the x86_64 +# radio head. The tag is the major version: Z-Wave JS UI ships the zwave-js +# driver and its Z-Wave JS Server, whose schema version is what Home Assistant +# negotiates. A major bump is the one that can break that handshake, so it is +# the one an operator has to opt into. +# +# THIS REFERENCE IS ALSO IN zwave-js-ui.container, on purpose, and the two must +# be changed together. The reason is in the container file and in the README. +Image=ghcr.io/zwave-js/zwave-js-ui:11 + +[Service] +Restart=on-failure +RestartSec=60