Browse Source

zwave-js-ui: keep the .image unit, want it instead of requiring it

Reverts the shape of the previous commit without reverting its finding. The pull
keeps its own unit -- it can be long, and its duration has no business inside the
container's start time -- and the container keeps After= on it, so the pull still
runs first on a boot where the network is there. What changes is the strength of
the dependency.

Measured on the radio head rather than assumed, because the obvious fix does not
work: Restart=on-failure on the .image unit makes the PULL converge and leaves
the container inactive for good. A failed dependency is never retried -- the
dependent job dies at the first failure, and the later successful restart of the
required unit does not re-trigger it. 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: Image= names the reference, which is what keeps quadlet from generating the
Requires=, and the container declares Wants= + After= on the pull unit itself.
The pull unit gains Restart=on-failure with StartLimitIntervalSec=0, so a machine
that boots without a network ends up with the image instead of a stale one, and
PartOf=zwave-js-ui.target so stopping the target leaves nothing retrying.

Verified on the target, booted with ghcr.io pointed at 127.0.0.1: the container
starts from local storage and reports healthy while the pull unit retries, then
the pull converges on its own after two restarts once resolution is restored, and
nothing is left in systemctl --failed.

The image reference now appears in both files, on purpose. The alternative -- a
drop-in resetting the generated Requires= -- has to restate the container's other
requirements and silently drops any a later edit adds. That trap is worse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pull/11/head
Nicolas Massé 1 month ago
parent
commit
18b7ebaaf3
  1. 51
      cookbooks/zwave-js-ui/README.md
  2. 42
      cookbooks/zwave-js-ui/zwave-js-ui.container
  3. 34
      cookbooks/zwave-js-ui/zwave-js-ui.image

51
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

42
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

34
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
Loading…
Cancel
Save