comfyui: replace the stale compose container with a Quadlet unit

ComfyUI had failed 9,146 times and had been down ~7 hours when this was found:
its container was created 2026-09-15 14:12, BEFORE the GPU declarations were
added to the compose service, and `restart: unless-stopped` kept restarting
that stale container — no /dev/kfd, no /dev/dri, no keep-groups — so it died
every time on `RuntimeError: No HIP GPUs are available`. Its "unit" was a
hand-written ~/.config/systemd/user/comfyui.service that shelled out to
`podman compose up -d`, which is not repo-tracked state and collides by name
with the Quadlet the generator produces.

  - dankclaw/comfyui/quadlet/comfyui.container: image comfyui-rocm:gfx1151
    (built from Containerfile.comfyui), /dev/kfd + /dev/dri, keep-groups,
    the four comfyui-data volumes, loopback-only publish (an unguarded service
    is internal-only here; the homepage card probes 127.0.0.1:8188), Restart=
    always, WantedBy=default.target.
  - the compose service is deleted — one owner per container name.
  - deploy_comfyui builds the image, installs the unit, retires the compose-era
    unit into ~/.AGENTTRASH, removes the stale container so the unit's own can
    take the name, and waits on :8188.
  - the healthcheck is a file, because the podman 5.x v1 generator splits
    HealthCmd on spaces and an inline `python3 -c "..."` becomes a quoting
    puzzle that fails silently.

The comfyui seam now reads the unit instead of compose for devices, command and
groups (and resolves the Containerfile from the deployer's declaration, not
from a directory listing).
This commit is contained in:
Frank
2026-09-16 07:13:27 -07:00
parent d8835e69c6
commit 03d44d3308
5 changed files with 180 additions and 104 deletions
+54 -64
View File
@@ -9,93 +9,83 @@
# named-group membership, because systemd user-unit spawns carry NO
# supplementary groups at spawn time
#
# STATUS (2026-08-31): the GPU migration is applied. The compose builds
# comfyui-rocm:gfx1151 from Containerfile.comfyui (AMD's gfx1151 torch base),
# passes /dev/kfd + /dev/dri, and relies on the udev rule at
# dankclaw/udev/99-comfyui-gpu-access.rules (MODE 0666 on kfd + renderD*) for
# compute-node access; group_add is keep-groups only. The four compose-wiring
# tests below are active (un-skipped). See
# STATUS (2026-09-16): ComfyUI is a Quadlet unit, not a compose service. The
# build still comes from Containerfile.comfyui (AMD's gfx1151 torch base) via
# the deployer's build_compose_image, and the wiring now lives in
# dankclaw/comfyui/quadlet/comfyui.container: /dev/kfd + /dev/dri, keep-groups
# only, and the udev rule at dankclaw/udev/99-comfyui-gpu-access.rules (MODE
# 0666 on kfd + renderD*) for compute-node access, because systemd user-unit
# spawns carry no supplementary groups. The move happened after the compose
# container went stale — created before the GPU declarations existed, then
# restarted 9,146 times without them. See
# serverAppConfigs-docs/Research/ComfyUI ROCm and Vulkan.md.
load helpers
@test "comfyui builds from the gfx1151 ROCm Containerfile" {
local compose="$REPO_ROOT/dankclaw/compose.yaml"
python3 - "$compose" <<'PYEOF'
import sys, yaml
with open(sys.argv[1]) as fh:
data = yaml.safe_load(fh)
svc = data["services"]["comfyui"]
assert svc.get("build"), "comfyui must have a build: section (Containerfile)"
assert svc.get("image", "").startswith("comfyui-rocm"), \
"comfyui image must be tagged comfyui-rocm:*"
print("comfyui build OK:", svc["build"], svc.get("image"))
PYEOF
# The image is built by the deployer (build_compose_image comfyui, which
# resolves dankclaw/Containerfile.comfyui) and referenced by the unit.
grep -q "comfyui)" "$REPO_ROOT/scripts/deploy-remote.sh"
grep -q 'image=comfyui-rocm:gfx1151' "$REPO_ROOT/scripts/deploy-remote.sh"
grep -q 'containerfile=dankclaw/Containerfile.comfyui' "$REPO_ROOT/scripts/deploy-remote.sh"
local unit="$REPO_ROOT/dankclaw/comfyui/quadlet/comfyui.container"
grep -q '^Image=localhost/comfyui-rocm:gfx1151$' "$unit" \
|| { echo "unit must run the locally built image"; return 1; }
# One owner per container name: a surviving compose service would fight the
# unit for the container, which is how the stale one stayed alive.
! grep -qE '^\s+comfyui:' "$REPO_ROOT/dankclaw/compose.yaml"
}
@test "comfyui Containerfile is based on the gfx1151 torch image" {
# Resolve the Containerfile the way the build does — build.context joined
# with build.dockerfile — rather than asking the filesystem. A
# `find … | head -1` here returned whichever Containerfile the directory
# listing happened to put first (dankclaw/alloy/Containerfile), so the test
# failed for sessions while the real ComfyUI Containerfile was correct — and
# it would equally have passed on a wrong file.
local cf
cf=$(python3 - "$REPO_ROOT/dankclaw/compose.yaml" "$REPO_ROOT/dankclaw" <<'PYEOF'
import os, sys, yaml
with open(sys.argv[1]) as fh:
svc = yaml.safe_load(fh)["services"]["comfyui"]
build = svc.get("build") or {}
# Compose resolves dockerfile relative to context, context relative to the
# compose file's own directory.
print(os.path.normpath(os.path.join(
sys.argv[2], build.get("context", "."), build.get("dockerfile", "Containerfile"))))
PYEOF
)
[ -n "$cf" ] || { echo "could not resolve the comfyui Containerfile"; return 1; }
[ -f "$cf" ] || { echo "$cf (from compose build) does not exist"; return 1; }
# Resolve the Containerfile from the deployer's declaration — the file the
# build actually uses — not from the filesystem. A `find … | head -1` here
# once returned whichever Containerfile the directory listing happened to
# put first (dankclaw/alloy/Containerfile), so the test failed for sessions
# while the real ComfyUI Containerfile was correct, and it would equally
# have passed on a wrong file. Compose no longer names it: ComfyUI is a
# Quadlet unit and the image is built by deploy_comfyui.
local cf="$REPO_ROOT/dankclaw/Containerfile.comfyui"
grep -q 'containerfile=dankclaw/Containerfile.comfyui' "$REPO_ROOT/scripts/deploy-remote.sh" \
|| { echo "deployer no longer builds that Containerfile"; return 1; }
[ -f "$cf" ] || { echo "$cf does not exist"; return 1; }
grep -q "gfx1151" "$cf" || { echo "$cf does not reference gfx1151"; return 1; }
grep -q "FROM" "$cf" || { echo "$cf has no FROM"; return 1; }
}
@test "comfyui passes /dev/kfd and /dev/dri to the container" {
python3 - "$REPO_ROOT/dankclaw/compose.yaml" <<'PYEOF'
import sys, yaml
with open(sys.argv[1]) as fh:
data = yaml.safe_load(fh)
devices = data["services"]["comfyui"].get("devices", [])
joined = "\n".join(devices)
for need in ("/dev/kfd", "/dev/dri"):
assert need in joined, f"comfyui devices must include {need}; got: {devices}"
print("device passthrough OK:", devices)
PYEOF
# Read the Quadlet unit, not compose: ComfyUI left compose on 2026-09-16
# after a stale, device-less container was restarted 9,146 times by
# `restart: unless-stopped` (see comfyui/quadlet/comfyui.container).
local unit="$REPO_ROOT/dankclaw/comfyui/quadlet/comfyui.container"
[ -f "$unit" ]
grep -q '^AddDevice=/dev/kfd$' "$unit" || { echo "no /dev/kfd"; return 1; }
grep -q '^AddDevice=/dev/dri$' "$unit" || { echo "no /dev/dri"; return 1; }
}
@test "comfyui does not run with --cpu" {
python3 - "$REPO_ROOT/dankclaw/compose.yaml" <<'PYEOF'
import sys, yaml
with open(sys.argv[1]) as fh:
data = yaml.safe_load(fh)
cmd = data["services"]["comfyui"].get("command", [])
joined = " ".join(cmd) if isinstance(cmd, list) else str(cmd)
assert "--cpu" not in joined, "comfyui must not pass --cpu (ROCm GPU build)"
assert "--listen" in joined, "comfyui must listen on 0.0.0.0"
print("command OK:", cmd)
PYEOF
local unit="$REPO_ROOT/dankclaw/comfyui/quadlet/comfyui.container"
local exec_line
exec_line=$(grep -E '^Exec=' "$unit")
case "$exec_line" in
*--cpu*) echo "comfyui must not pass --cpu (ROCm GPU build)"; return 1 ;;
esac
case "$exec_line" in
*--listen*) ;;
*) echo "comfyui must listen on 0.0.0.0"; return 1 ;;
esac
}
@test "comfyui GPU access uses keep-groups + udev MODE 0666, not named groups" {
python3 - "$REPO_ROOT/dankclaw/compose.yaml" "$REPO_ROOT/dankclaw/udev/99-comfyui-gpu-access.rules" <<'PYEOF'
import sys, yaml
with open(sys.argv[1]) as fh:
data = yaml.safe_load(fh)
groups = data["services"]["comfyui"].get("group_add", [])
python3 - "$REPO_ROOT/dankclaw/comfyui/quadlet/comfyui.container" "$REPO_ROOT/dankclaw/udev/99-comfyui-gpu-access.rules" <<'PYEOF'
import sys, re
unit = open(sys.argv[1]).read()
groups = re.findall(r"^GroupAdd=(.*)$", unit, re.M)
# User-unit spawns carry NO supplementary groups, so named group_add entries
# (video, render) can never be honored at spawn time. The deployed contract is
# keep-groups plus world-writable compute nodes via the udev rule; a future
# edit that drops either half must fail here.
assert list(groups) == ["keep-groups"], \
f"comfyui group_add must be exactly ['keep-groups']; got: {groups}"
assert groups == ["keep-groups"], \
f"comfyui GroupAdd must be exactly ['keep-groups']; got: {groups}"
rule = open(sys.argv[2]).read()
assert 'KERNEL=="kfd", MODE="0666"' in rule, \
"udev rule must grant MODE 0666 on kfd with NO SUBSYSTEM guard (misc-class devices have no subsystem to match)"