Skip to content

Commit 074992e

Browse files
authored
Fixes (#53)
* update samba, improve gpu auto for nvida vs nouveau * Add jellyfin workload
1 parent 6a56450 commit 074992e

7 files changed

Lines changed: 309 additions & 6 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# jellyfin
2+
3+
Free Software media server with hardware-accelerated video transcoding.
4+
5+
Unlike most workloads under `containers/`, this one **builds no image** — it
6+
runs the upstream `docker.io/jellyfin/jellyfin` image directly. This directory
7+
exists only to ship the host setup script.
8+
9+
## Files
10+
11+
- `setup.sh` — host prerequisite script run by `workloadctl enable/disable`.
12+
Turns on the `container_use_devices` SELinux boolean so the container can
13+
open the GPU render node `/dev/dri/renderD128`.
14+
15+
## Hardware transcoding
16+
17+
The workload passes `/dev/dri/renderD128` into the container and adds the
18+
`render` group. In the Jellyfin dashboard set:
19+
20+
- Hardware acceleration: **VAAPI**
21+
- VA-API device: **/dev/dri/renderD128**
22+
23+
Works with AMD (`amdgpu`) and Intel (`i915`) GPUs. AMD Navi 10 / RX 5000-series
24+
supports H.264 and HEVC encode/decode but not AV1 encode.
25+
26+
For NVIDIA, switch `[devices]` in `jellyfin.toml` to `gpu = "nvidia"` and pick
27+
NVENC in the UI instead.
28+
29+
## Media
30+
31+
By default jellyfin is a **fully independent workload**: the library is its
32+
own directory, `/var/lib/workloads/jellyfin/media`, mounted read-only at
33+
`/media` in the container.
34+
35+
For deployment you can point it elsewhere by editing the last `[storage]`
36+
volume in `jellyfin.toml` — e.g. at the `smb-server` share
37+
(`/var/lib/workloads/smb-server/exports/media`) so media can be added over
38+
SMB. The workload user only needs read access to whatever path you choose.
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
#!/bin/bash
2+
# Host setup for the jellyfin workload.
3+
#
4+
# Usage:
5+
# setup.sh enable — configure host prerequisites
6+
# setup.sh disable — remove host prerequisites
7+
#
8+
# Idempotent in both directions. Called by workloadctl enable/disable.
9+
set -euo pipefail
10+
11+
SEBOOL="container_use_devices"
12+
13+
enable() {
14+
# Allow confined containers (container_t) to open device nodes such as the
15+
# GPU render node /dev/dri/renderD128. Without this, SELinux denies the
16+
# VAAPI device open and hardware transcoding silently falls back to CPU.
17+
if command -v getsebool >/dev/null 2>&1; then
18+
if getsebool "$SEBOOL" 2>/dev/null | grep -q ' off$'; then
19+
echo " [host] Enabling SELinux boolean ${SEBOOL}..."
20+
setsebool -P "$SEBOOL" on
21+
else
22+
echo " [host] SELinux boolean ${SEBOOL} already on (or SELinux disabled)"
23+
fi
24+
else
25+
echo " [host] SELinux tooling not present — skipping ${SEBOOL}"
26+
fi
27+
echo " [host] Setup complete"
28+
}
29+
30+
disable() {
31+
# Intentionally leave container_use_devices on: it is a system-wide boolean
32+
# and other GPU workloads (game streaming, desktops) may depend on it.
33+
# Flipping it off here could break them.
34+
echo " [host] Leaving SELinux boolean ${SEBOOL} unchanged (shared by other GPU workloads)"
35+
}
36+
37+
case "${1:-}" in
38+
enable) enable ;;
39+
disable) disable ;;
40+
*)
41+
echo "Usage: $0 {enable|disable}" >&2
42+
exit 1
43+
;;
44+
esac

workloadctl/generators/workload-generate

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -345,13 +345,27 @@ def generate_setup_service(config, user_name):
345345

346346

347347
def resolve_auto_gpu():
348-
"""Detect the primary GPU vendor from sysfs. Returns 'nvidia', 'amd', 'intel', or 'none'."""
348+
"""Detect the primary GPU from sysfs.
349+
350+
Returns 'nvidia', 'nouveau', 'amd', 'intel', or 'none'. For NVIDIA cards
351+
the bound driver is checked: the proprietary driver uses the CDI path,
352+
but nouveau has no NVIDIA Container Toolkit support and must use the plain
353+
DRM render node instead — so it is reported separately.
354+
"""
349355
vendor_map = {"0x10de": "nvidia", "0x1002": "amd", "0x8086": "intel"}
350356
try:
351357
for vendor_path in sorted(Path("/sys/class/drm").glob("card*/device/vendor")):
352358
vendor = vendor_path.read_text().strip().lower()
353-
if vendor in vendor_map:
354-
return vendor_map[vendor]
359+
if vendor not in vendor_map:
360+
continue
361+
result = vendor_map[vendor]
362+
if result == "nvidia":
363+
try:
364+
if (vendor_path.parent / "driver").resolve().name == "nouveau":
365+
return "nouveau"
366+
except OSError:
367+
pass
368+
return result
355369
except OSError:
356370
pass
357371
return "none"
@@ -555,7 +569,9 @@ def generate_system_service(config, user_name, uid):
555569
"--device=nvidia.com/gpu=all",
556570
"--device /dev/dri",
557571
])
558-
elif gpu_vendor == "intel":
572+
elif gpu_vendor in ("intel", "nouveau"):
573+
# nouveau (and Intel) expose the GPU through the standard DRM render
574+
# node — no CDI / Container Toolkit involved.
559575
podman_args.append("--device /dev/dri")
560576

561577
# Generic device passthrough

workloadctl/tests/test_generator.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,32 @@
55
No root required — all paths are overridden via env vars and argv.
66
"""
77

8+
import importlib.machinery
9+
import importlib.util
810
import os
911
import subprocess
1012
import sys
1113
import tempfile
1214
import textwrap
1315
import unittest
1416
from pathlib import Path
17+
from unittest import mock
1518

1619
GENERATOR = os.path.join(os.path.dirname(__file__), '..', 'generators', 'workload-generate')
1720
LIB_DIR = os.path.join(os.path.dirname(__file__), '..', 'lib')
1821

1922

23+
def _load_generator_module():
24+
"""Import workload-generate as a module (it has a __main__ guard)."""
25+
if LIB_DIR not in sys.path:
26+
sys.path.insert(0, LIB_DIR)
27+
loader = importlib.machinery.SourceFileLoader("workload_generate", GENERATOR)
28+
spec = importlib.util.spec_from_loader("workload_generate", loader)
29+
module = importlib.util.module_from_spec(spec)
30+
loader.exec_module(module)
31+
return module
32+
33+
2034
def run_generator(config_dir, services_dir, sysusers_dir):
2135
"""Run the generator and return the CompletedProcess."""
2236
env = os.environ.copy()
@@ -698,5 +712,74 @@ def test_invalid_toml(self):
698712
self.assertEqual(result.returncode, 0)
699713

700714

715+
class TestResolveAutoGpu(unittest.TestCase):
716+
"""Unit tests for resolve_auto_gpu() — vendor + NVIDIA driver detection."""
717+
718+
@classmethod
719+
def setUpClass(cls):
720+
cls.wg = _load_generator_module()
721+
722+
def setUp(self):
723+
self.root = tempfile.mkdtemp()
724+
self.drm = Path(self.root) / "sys" / "class" / "drm"
725+
self.drm.mkdir(parents=True)
726+
727+
def tearDown(self):
728+
import shutil
729+
shutil.rmtree(self.root)
730+
731+
def _add_card(self, name, vendor_id, driver=None):
732+
"""Create a fake /sys/class/drm/<name> with a vendor file and
733+
optionally a 'driver' symlink whose basename is the driver name."""
734+
device = self.drm / name / "device"
735+
device.mkdir(parents=True)
736+
(device / "vendor").write_text(vendor_id + "\n")
737+
if driver is not None:
738+
target = Path(self.root) / "_drivers" / driver
739+
target.mkdir(parents=True, exist_ok=True)
740+
(device / "driver").symlink_to(target)
741+
742+
def _resolve(self):
743+
"""Run resolve_auto_gpu() with /sys/class/drm redirected to the fake tree."""
744+
real_path = self.wg.Path
745+
drm = self.drm
746+
747+
def fake_path(arg):
748+
if str(arg) == "/sys/class/drm":
749+
return real_path(drm)
750+
return real_path(arg)
751+
752+
with mock.patch.object(self.wg, "Path", side_effect=fake_path):
753+
return self.wg.resolve_auto_gpu()
754+
755+
def test_amd(self):
756+
self._add_card("card0", "0x1002")
757+
self.assertEqual(self._resolve(), "amd")
758+
759+
def test_intel(self):
760+
self._add_card("card0", "0x8086")
761+
self.assertEqual(self._resolve(), "intel")
762+
763+
def test_nvidia_proprietary(self):
764+
self._add_card("card0", "0x10de", driver="nvidia")
765+
self.assertEqual(self._resolve(), "nvidia")
766+
767+
def test_nvidia_nouveau(self):
768+
self._add_card("card0", "0x10de", driver="nouveau")
769+
self.assertEqual(self._resolve(), "nouveau")
770+
771+
def test_nvidia_no_driver_symlink_falls_back_to_nvidia(self):
772+
# No driver bound (e.g. modeset/driver not yet attached) → vendor only.
773+
self._add_card("card0", "0x10de")
774+
self.assertEqual(self._resolve(), "nvidia")
775+
776+
def test_no_gpu(self):
777+
self.assertEqual(self._resolve(), "none")
778+
779+
def test_unknown_vendor_skipped(self):
780+
self._add_card("card0", "0xbeef")
781+
self.assertEqual(self._resolve(), "none")
782+
783+
701784
if __name__ == "__main__":
702785
unittest.main()
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
# Jellyfin - Free Software Media System (stream & transcode video)
2+
# Web interface: http://<host-ip>:8096
3+
#
4+
# Runs the official jellyfin/jellyfin image rootless. Video transcoding is
5+
# hardware-accelerated through VAAPI on the host GPU render node
6+
# (/dev/dri/renderD128) — works with AMD (amdgpu) and Intel (i915) GPUs.
7+
# For NVIDIA, use the `gpu = "nvidia"` convenience flag under [devices]
8+
# instead and select NVENC in the Jellyfin UI.
9+
#
10+
# Note on `gpu = "auto"` and NVIDIA: auto detects both the PCI vendor and the
11+
# bound driver. With the proprietary driver it uses the NVIDIA Container
12+
# Toolkit (CDI) path for NVENC; with nouveau it uses plain /dev/dri VAAPI.
13+
# Both work — but nouveau's hardware encode support is weak, so a proprietary
14+
# driver with `gpu = "nvidia"` is recommended for real transcoding on NVIDIA.
15+
#
16+
# Setup:
17+
# 1. Enable the workload. This pulls the image, creates the volume
18+
# directories, and runs the host setup script (setup.sh) which turns on
19+
# the `container_use_devices` SELinux boolean so the container may open
20+
# the GPU render node:
21+
# sudo workloadctl enable jellyfin
22+
#
23+
# 2. Add media. By default the library is the workload's own directory,
24+
# mounted read-only at /media inside the container:
25+
# /var/lib/workloads/jellyfin/media
26+
# Drop video files there (created automatically on enable). To serve
27+
# media from elsewhere instead — e.g. the smb-server share so files can
28+
# be added over SMB — see the [storage] section below.
29+
#
30+
# 3. Configure the firewall (LAN-only — recommended):
31+
# sudo firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="192.168.0.0/24" port port="8096" protocol="tcp" accept'
32+
# sudo firewall-cmd --reload
33+
#
34+
# 4. Open http://<host-ip>:8096 and complete first-run setup. Add a library
35+
# pointing at /media. Then under Dashboard > Playback > Transcoding:
36+
# - Hardware acceleration: Video Acceleration API (VAAPI)
37+
# - VA-API device: /dev/dri/renderD128
38+
# - Enable the HEVC / H.264 hardware decoders & encoders you want.
39+
# (AMD Navi 10 / RX 5000-series does H.264 and HEVC but not AV1 encode.)
40+
#
41+
# Notes:
42+
# - The official image runs as root inside the container. userns
43+
# keep-id:uid=0,gid=0 maps that to the unprivileged _wl-jellyfin host
44+
# user, so /config and /cache stay writable without granting real root.
45+
# - /media is mounted read-only — Jellyfin never modifies your library.
46+
# - To serve from the local registry instead of Docker Hub, retag the image
47+
# into zot (e.g. zamd:5050/jellyfin/jellyfin) and adjust [container].
48+
#
49+
# After editing this file, apply changes with:
50+
# sudo workloadctl recreate jellyfin
51+
52+
[workload]
53+
name = "jellyfin"
54+
enabled = false
55+
56+
[container]
57+
image = "docker.io/jellyfin/jellyfin:latest"
58+
pull = "missing"
59+
60+
[container.health]
61+
# The official image is Ubuntu-based (bash present) but ships no curl, so
62+
# probe the HTTP listener socket directly rather than hitting /health.
63+
cmd = "bash -c 'exec 3<>/dev/tcp/127.0.0.1/8096'"
64+
interval = "30s"
65+
start_period = "30s"
66+
on_failure = "kill"
67+
68+
[container.environment]
69+
# Optional: the absolute URL clients should use, shown in the dashboard.
70+
# JELLYFIN_PublishedServerUrl = "http://192.168.0.100:8096"
71+
72+
[storage]
73+
# By default the library lives in the workload's own home, so jellyfin is a
74+
# fully independent workload with no dependency on any other.
75+
# Default media path: /var/lib/workloads/jellyfin/media
76+
#
77+
# Deployment tweak — to serve media from another location, replace the last
78+
# entry with an absolute host path, e.g. to share the smb-server library:
79+
# "/var/lib/workloads/smb-server/exports/media:/media:ro"
80+
# (the workload user only needs read access to whatever path you choose).
81+
volumes = [
82+
"./config:/config",
83+
"./cache:/cache",
84+
"./media:/media:ro",
85+
]
86+
87+
[security]
88+
# keep-id:uid=0,gid=0 — the image runs as container root; map that to the
89+
# unprivileged _wl-jellyfin host user so volumes are writable without real
90+
# root. Not userns=host: no other host UID maps to the workload user.
91+
userns = "keep-id:uid=0,gid=0"
92+
# render — access the GPU VAAPI render node /dev/dri/renderD128.
93+
extra_groups = ["render"]
94+
95+
[devices]
96+
# Just the render node — enough for VAAPI transcoding. Avoids pulling in
97+
# /dev/kfd and the card* node that gpu = "amd" would add.
98+
devices = ["/dev/dri/renderD128"]
99+
100+
[network]
101+
mode = "pasta"
102+
ports = ["8096:8096"]
103+
104+
[host]
105+
# Turns on the container_use_devices SELinux boolean so the container can
106+
# open the GPU render node. Idempotent; left in place on `disable` since
107+
# other GPU workloads may rely on it.
108+
setup = "setup.sh"
109+
110+
[resources]
111+
shm_size = "256m"
112+
memory_high = "2G"
113+
memory_max = "4G"

workloadctl/workloads.d/schema-reference.toml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,13 +257,20 @@ devices = ["/dev/ttyUSB0", "/dev/video0"]
257257

258258
# Optional: GPU type for hardware acceleration
259259
# Type: string
260-
# Options: "amd", "nvidia", "none"
260+
# Options: "amd", "nvidia", "intel", "auto", "none"
261261
# Default: "none"
262262
# "amd" - Expands to: --device /dev/kfd --device /dev/dri
263263
# Requires: extra_groups = ["video", "render"]
264264
# "nvidia" - Expands to: --device=nvidia.com/gpu=all --device /dev/dri
265265
# Requires: extra_groups = ["video"]
266+
# "intel" - Expands to: --device /dev/dri
267+
# "auto" - Detect the GPU vendor (and, for NVIDIA, the bound driver) from
268+
# sysfs and use one of the above. Proprietary NVIDIA -> "nvidia"
269+
# (CDI path); nouveau -> the /dev/dri path. Both work, but nouveau
270+
# hardware encode is weak — for real transcoding on NVIDIA prefer
271+
# the proprietary driver with an explicit gpu = "nvidia".
266272
# "none" - No GPU access
273+
#
267274
# Advanced: For specific NVIDIA GPU selection, use generic devices instead:
268275
# devices = ["nvidia.com/gpu=0", "/dev/dri"]
269276
gpu = "amd"

workloadctl/workloads.d/smb-server.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,10 @@ required_files = [
6363
image = "localhost/smb-server:latest"
6464
pull = "never"
6565

66+
# The smb-server image ships only the `samba` package (server side); smbclient
67+
# is not available in-container. Probe the listening socket instead.
6668
[container.health]
67-
cmd = "smbclient -N -L 127.0.0.1 > /dev/null 2>&1 || exit 1"
69+
cmd = "bash -c 'exec 3<>/dev/tcp/127.0.0.1/445'"
6870
interval = "30s"
6971
start_period = "10s"
7072
on_failure = "kill"

0 commit comments

Comments
 (0)