/
/
/
Ansible role that deployes services on my runner machine
1# D&D session agent stack.
2#
3# One compose project so the whole system has a single on/off switch:
4# dnd_enabled: false -> every part of it goes away.
5#
6# Three services with deliberately different lifecycles:
7#
8# dnd-stt always up. A generic OpenAI-compatible speech endpoint that
9# anything on the runner can use, not just this stack.
10# dnd-agent started per session and stopped afterwards. It only makes sense
11# while people are in the room, and leaving it idling would keep a
12# participant in the conference all week.
13# dnd-post runs to completion and exits. Under a compose profile so a plain
14# `up` never starts it.
15
16services:
17 dnd-stt:
18 container_name: dnd-stt
19 image: "{{ container_images.dnd_stt }}"
20 restart: unless-stopped
21
22 ports:
23 - "{{ dnd_stt_host_port }}:8000"
24
25 env_file:
26 - stt.env
27
28 volumes:
29 - /etc/localtime:/etc/localtime:ro
30 # Whisper weights are several hundred MB and fetched on first use, so
31 # this must persist or every restart re-downloads them.
32 - {{ dnd_stt_model_dir }}:/home/ubuntu/.cache/huggingface
33
34{% if gpu_enabled %}
35 runtime: nvidia
36 deploy:
37 resources:
38 reservations:
39 devices:
40 - driver: nvidia
41 count: 1
42 capabilities: [gpu]
43{% endif %}
44
45 healthcheck:
46 test: ["CMD", "curl", "-fsS", "http://localhost:8000/health"]
47 interval: 30s
48 timeout: 10s
49 start_period: {{ dnd_stt_start_period }}
50 retries: 3
51
52 networks:
53 {{ runner_docker_network }}:
54 aliases:
55 - dnd-stt
56
57 dnd-agent:
58 container_name: dnd-agent
59 build:
60 context: {{ dnd_config_dir }}/src
61 dockerfile: Dockerfile.agent
62 args:
63 UID: "{{ dnd_uid }}"
64 GID: "{{ dnd_gid }}"
65 image: dnd-agent:{{ dnd_agent_version }}
66
67 # Never restarted automatically. A session ends when people leave, and a
68 # restarting agent would silently rejoin an empty room and record silence.
69 restart: "no"
70 profiles: ["session"]
71
72 env_file:
73 - agent.env
74
75 depends_on:
76 dnd-stt:
77 condition: service_healthy
78
79 volumes:
80 - /etc/localtime:/etc/localtime:ro
81 - {{ dnd_media_dir }}:/media/dnd
82
83 networks:
84 {{ runner_docker_network }}:
85 aliases:
86 - dnd-agent
87
88 dnd-post:
89 container_name: dnd-post
90 build:
91 context: {{ dnd_config_dir }}/src
92 dockerfile: Dockerfile.post
93 args:
94 UID: "{{ dnd_uid }}"
95 GID: "{{ dnd_gid }}"
96 image: dnd-post:{{ dnd_agent_version }}
97
98 restart: "no"
99 # Only started explicitly, so `docker compose up -d` brings up the STT
100 # service alone and never kicks off an hour of summarisation.
101 profiles: ["post"]
102
103 env_file:
104 - post.env
105
106 volumes:
107 - /etc/localtime:/etc/localtime:ro
108 - {{ dnd_media_dir }}:/media/dnd
109 # pyannote weights, kept out of the image so a rebuild does not refetch
110 # them and so the gated download happens exactly once.
111 - {{ dnd_model_dir }}:/models
112
113{% if gpu_enabled %}
114 runtime: nvidia
115 deploy:
116 resources:
117 reservations:
118 devices:
119 - driver: nvidia
120 count: 1
121 capabilities: [gpu]
122{% endif %}
123
124 networks:
125 {{ runner_docker_network }}:
126 aliases:
127 - dnd-post
128{% if dnd_table_enabled %}
129
130 # The table server: renders the kiosk page with headless Chromium and
131 # streams it to the Pi, which only hardware-decodes it. Always up, because
132 # bringing a video pipeline from cold at the start of a session costs a
133 # visible moment of mode negotiation -- the projector sleeps over CEC
134 # instead, which is instant.
135 dnd-table-runner:
136 container_name: dnd-table-runner
137 image: "{{ dnd_table_runner_image }}"
138 restart: unless-stopped
139
140 # Match the media directory's owner so uploaded handouts and rendered
141 # snapshots are readable from the NAS side. Root-owned files on a shared
142 # mount are a nuisance to clean up later.
143 user: "{{ dnd_uid }}:{{ dnd_gid }}"
144
145 environment:
146 TABLE_WIDTH: "{{ dnd_table_width }}"
147 TABLE_HEIGHT: "{{ dnd_table_height }}"
148 TABLE_FPS: "{{ dnd_table_fps }}"
149 TABLE_BITRATE_KBPS: "{{ dnd_table_bitrate_kbps }}"
150 TABLE_PI_HOST: "{{ dnd_table_pi_host }}"
151 TABLE_RTP_PORT: "{{ dnd_table_rtp_port }}"
152 TABLE_HTTP_PORT: "{{ dnd_table_http_port }}"
153 TABLE_MEDIA_ROOT: "/media"
154 TABLE_MQTT_HOST: "{{ mqtt_host }}"
155 TABLE_MQTT_PORT: "{{ mqtt_port }}"
156 TABLE_MQTT_USERNAME: "{{ mqtt_username }}"
157 TABLE_MQTT_PASSWORD: "{{ mqtt_password }}"
158 TABLE_BASE_TOPIC: "dnd"
159 # Chromium, Xvfb and lgpio all want a writable HOME. As a non-root user
160 # the image's /root is unreachable, and the failure is an opaque crash
161 # rather than a permission message.
162 HOME: "/tmp"
163 XDG_RUNTIME_DIR: "/tmp"
164 XDG_CACHE_HOME: "/tmp/.cache"
165 XDG_CONFIG_HOME: "/tmp/.config"
166
167 ports:
168 - "{{ dnd_table_http_port }}:{{ dnd_table_http_port }}"
169
170 volumes:
171 # Writable: the DM uploads handouts through the web UI mid-session, and
172 # rendered URL snapshots are written here too. Read-only was the right
173 # call when the library could only be filled from the NAS side.
174 - "{{ dnd_table_media_dir }}:/media"
175
176 # Chromium's renderer wants more than the 64MB default, and it fails in
177 # confusing ways rather than cleanly when it runs out.
178 shm_size: "512m"
179
180 healthcheck:
181 test: ["CMD", "python3", "-c",
182 "import urllib.request; urllib.request.urlopen('http://localhost:{{ dnd_table_http_port }}/api/state', timeout=5)"]
183 interval: "{{ health_check_interval }}"
184 timeout: "{{ health_check_timeout }}"
185 retries: {{ health_check_retries }}
186 start_period: "60s"
187
188 networks:
189 {{ runner_docker_network }}:
190 aliases:
191 - dnd-table-runner
192{% endif %}
193
194networks:
195 {{ runner_docker_network }}:
196 external: true
197