#!/usr/bin/env bats # Seam: ComfyUI ROCm GPU configuration on Dankclaw. # The gfx1151-enabled PyTorch build is the only verified GPU path on Strix # Halo. The compose must: # - build from a Containerfile based on the gfx1151 ROCm torch image # - pass /dev/kfd and /dev/dri to the container # - NOT pass --cpu # - grant GPU access via world-writable compute nodes (udev MODE 0666), not # named-group membership, because systemd user-unit spawns carry NO # supplementary groups at spawn time # # 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" { # 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. The file # itself is retired (observability_quadlet.bats asserts that), so here the # regression to catch is a re-added entry — the check must not go hollow # just because the file is absent. local cf="$REPO_ROOT/dankclaw/compose.yaml" [ ! -f "$cf" ] || ! grep -qE '^\s+comfyui:' "$cf" } @test "comfyui Containerfile is based on the gfx1151 torch image" { # 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" { # 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" { local unit="$REPO_ROOT/dankclaw/comfyui/quadlet/comfyui.container" local wrapper="$REPO_ROOT/dankclaw/comfyui-start.sh" local exec_line exec_line=$(grep -E '^Exec=' "$unit") # The exec line points at the startup wrapper; the actual flags live there. local target case "$exec_line" in *comfyui-start.sh*) target="$wrapper" ;; *) target="$unit" ;; esac case "$(grep -E 'main\.py|--cpu' "$target")" in *--cpu*) echo "comfyui must not pass --cpu (ROCm GPU build)"; return 1 ;; esac case "$(grep -E 'main\.py|--listen' "$target")" 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/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 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)" assert 'KERNEL=="renderD*", SUBSYSTEM=="drm", MODE="0666"' in rule, \ "udev rule must grant MODE 0666 on renderD* (SUBSYSTEM drm guard)" print("GPU access contract OK") PYEOF }