/
/
/
Ansible role that deployes services on my runner machine
1---
2# Runner Services - D&D Session Agent (The Thaumaturgeon)
3#
4# One compose project covering speech-to-text, the live agent and the
5# post-session processor, so `dnd_enabled: false` removes the whole system.
6#
7# The three services have different lifecycles and are handled accordingly:
8# dnd-stt runs continuously, dnd-agent is started per session under the
9# "session" profile, and dnd-post runs to completion under "post".
10
11- name: Create D&D configuration directory structure
12 file:
13 path: "{{ item }}"
14 state: directory
15 owner: "{{ runner_user }}"
16 group: "{{ runner_group }}"
17 mode: '2775'
18 loop:
19 - "{{ dnd_config_dir }}"
20 - "{{ dnd_stt_model_dir }}"
21 # huggingface_hub.scan_cache_dir() raises CacheNotFound if hub/ is
22 # missing, which makes Speaches' /v1/models return 500 on a fresh deploy.
23 - "{{ dnd_stt_model_dir }}/hub"
24 - "{{ dnd_model_dir }}"
25 # The camera-to-projector homography lives here, outside the container:
26 # dnd-camera is recreated per session and would otherwise lose a
27 # calibration that is meant to hold for months.
28 - "{{ dnd_config_dir }}/camera"
29
30# The tracker reads the frame tap. Enabling one without the other gives a
31# detection loop that can never see anything -- it runs, reports healthy, and
32# silently never publishes a token.
33- name: Assert the frame tap is on when mini tracking is
34 ansible.builtin.assert:
35 that: dnd_camera_frames_enabled | bool
36 fail_msg: >-
37 dnd_camera_track_minis is true but dnd_camera_frames_enabled is false.
38 The tracker reads the camera's frame tap; without it the detection loop
39 runs and never sees a frame. Set dnd_camera_frames_enabled: true.
40 success_msg: "Mini tracking has the frame tap it needs"
41 when: dnd_camera_track_minis | bool
42 tags: [dnd, camera]
43
44# Only when the feeder is enabled. This lives on the NFS share rather than
45# beside the config, so it is created here only to save a manual mkdir before
46# the first episode is copied in -- the Chamberlain treats a missing directory
47# as "no episodes" and hides the panel, which is the right behaviour for a
48# deployment that has no test material.
49- name: Create the D&D test episode directory
50 file:
51 path: "{{ dnd_testfeed_host_dir }}"
52 state: directory
53 owner: "{{ dnd_uid }}"
54 group: "{{ dnd_gid }}"
55 mode: '2775'
56 when: dnd_testfeed_enabled | default(false)
57
58- name: Create D&D environment files
59 template:
60 src: "{{ item.src }}"
61 dest: "{{ dnd_config_dir }}/{{ item.dest }}"
62 owner: "{{ runner_user }}"
63 group: "{{ runner_group }}"
64 mode: '0660'
65 loop:
66 - { src: "dnd-stt-env.j2", dest: "stt.env" }
67 - { src: "dnd-agent.env.j2", dest: "agent.env" }
68 - { src: "dnd-post.env.j2", dest: "post.env" }
69 # The Chamberlain's env belongs here rather than in chamberlain.yml even
70 # though that is where the service is started: the compose file below
71 # references it, so *any* compose command in this file fails if it is
72 # missing. Writing it later would make this file's own tasks depend on a
73 # file a later import has not created yet.
74 - { src: "dnd-chamberlain.env.j2", dest: "chamberlain.env" }
75 - { src: "dnd-camera.env.j2", dest: "camera.env" }
76 # The test feeder. Written unconditionally, even when the feeder is
77 # disabled: the file is inert without the compose service, whereas a
78 # compose file that references a missing env_file fails *every* command in
79 # the project, including the ones that start a real session. Cheap
80 # insurance against enabling the feeder and forgetting to re-run this.
81 - { src: "dnd-testfeed.env.j2", dest: "testfeed.env" }
82 loop_control:
83 label: "{{ item.dest }}"
84 # Both handlers: `restart dnd` covers dnd-stt, and the Chamberlain has its own
85 # so a change to one service's environment does not bounce the other. Ansible
86 # runs a handler once regardless of how many tasks notify it.
87 notify:
88 - restart dnd
89 - restart dnd chamberlain
90
91- name: Create D&D Docker Compose file
92 template:
93 src: dnd-compose.yml.j2
94 dest: "{{ dnd_config_dir }}/docker-compose.yml"
95 owner: "{{ runner_user }}"
96 group: "{{ runner_group }}"
97 mode: '0664'
98 notify: restart dnd
99
100# --- Images ------------------------------------------------------------------
101# Everything is pulled from Harbor, and each image is built by the CI of the
102# repository it comes from.
103#
104# dnd-agent and dnd-post come from two repositories -- dnd_thaumaturgeon_live
105# and dnd_thaumaturgeon_post -- which were one until the packaging made that
106# expensive: both Dockerfiles copied a single package wholesale, so editing the
107# live agent rebuilt torch and the CUDA runtime that had not changed. The
108# *image* names are deliberately unchanged by that split, because they appear
109# here, in the compose template and in the Chamberlain's service table.
110#
111# The table server image is also deployed to the Pi, so one build serves both
112# hosts.
113
114# --- Tunables on the share ----------------------------------------------------
115# `cues.yml`, `atmosphere.yml` and `lights.yml` hold values that get *tuned*:
116# no threshold in them has a knowable-right setting until several evenings have
117# been watched, and editing a file on the NFS share between sessions is far
118# easier than an Ansible run.
119#
120# **All three are now seeded from this role with `force: no`.** A deploy that
121# overwrote a tuned file would silently undo an evening's work, and the symptom
122# (the room behaving as it did last month) would not point at Ansible. So the
123# repo holds the starting point and the share holds the current truth.
124#
125# Set `dnd_config_force: true` to push the repo's copies over the share's --
126# which is what to do while a value is actively being changed, as with the
127# judge model during the 2026-09 evaluation runs. It is `false` by default
128# precisely so that a routine deploy cannot reach in and undo tuning.
129#
130# > â ï¸ **Until 2026-09-01, only `lights.yml` was seeded at all.** The other two
131# > existed *only* on the share: `atmosphere.yml` carried which model the whole
132# > live half runs on, and `cues.yml` 151 KB of curated triggers, neither in
133# > any repository nor any backup. The tell was `ambience_model: qwen3:14b`,
134# > which sat in `atmosphere.yml` for months being parsed and never used by any
135# > code, because nothing reviews a file that no diff ever touches.
136
137# A copy of whatever was there before an overwrite, because `force: true` is
138# the one setting here that can destroy tuning. Only taken when it would
139# actually replace something, and only when forcing.
140- name: Keep a dated copy before overwriting a tuned file
141 copy:
142 src: "{{ dnd_media_dir }}/{{ item }}"
143 dest: "{{ dnd_media_dir }}/{{ item }}.bak-{{ ansible_date_time.epoch }}"
144 remote_src: yes
145 owner: "{{ runner_user }}"
146 group: "{{ runner_group }}"
147 mode: '0664'
148 loop:
149 - cues.yml
150 - atmosphere.yml
151 - lights.yml
152 when: dnd_config_force | bool
153 failed_when: false
154
155- name: Seed the lighting configuration if it is not already on the share
156 copy:
157 src: lights.yml
158 dest: "{{ dnd_media_dir }}/lights.yml"
159 owner: "{{ runner_user }}"
160 group: "{{ runner_group }}"
161 mode: '0664'
162 force: "{{ dnd_config_force }}"
163 when: dnd_lights_enabled
164
165# The cue library and the atmosphere thresholds, on the same terms. `cues.yml`
166# is 151 KB of curated trigger words and bed metadata -- the output of the
167# sanitising pass and a great deal of hand work -- and it existed in exactly
168# one place until this task was written.
169- name: Seed the cue library and atmosphere configuration on the share
170 copy:
171 src: "{{ item }}"
172 dest: "{{ dnd_media_dir }}/{{ item }}"
173 owner: "{{ runner_user }}"
174 group: "{{ runner_group }}"
175 mode: '0664'
176 force: "{{ dnd_config_force }}"
177 loop:
178 - cues.yml
179 - atmosphere.yml
180
181
182- name: Create the table media directory
183 file:
184 path: "{{ dnd_table_media_dir }}"
185 state: directory
186 owner: "{{ runner_user }}"
187 group: "{{ runner_group }}"
188 mode: '2775'
189 when: dnd_table_enabled
190
191- name: Create the table media subdirectories
192 file:
193 path: "{{ dnd_table_media_dir }}/{{ item }}"
194 state: directory
195 owner: "{{ runner_user }}"
196 group: "{{ runner_group }}"
197 mode: '2775'
198 loop: [maps, handouts]
199 when: dnd_table_enabled
200
201# Creature tokens, the DM's uploads, and saved encounters.
202#
203# Only the directories are created. **The creature set is deliberately NOT
204# shipped by Ansible**: it is over a thousand files that never change, and
205# copying them on every deploy would make a routine `--tags dnd` run take
206# minutes and hammer the NAS for no benefit. It is seeded once, out of band:
207#
208# rsync -a ~/personal/FA_Tokens_Webp/ \
209# [email protected]:{{ dnd_token_dir }}/library/
210#
211# and its catalog.json is generated alongside (see live/creatures.md).
212#
213# A deployment without it runs and offers only the DM's own uploads, which is
214# what a missing optional asset should do -- the same way a node with no
215# lights.yml runs the whole lighting layer and lights nothing.
216- name: Create the creature token directories
217 file:
218 path: "{{ dnd_token_dir }}/{{ item }}"
219 state: directory
220 owner: "{{ runner_user }}"
221 group: "{{ runner_group }}"
222 mode: '2775'
223 loop: ['', library, custom, encounters]
224 when: dnd_table_enabled
225
226# Reported rather than asserted: the token set is optional, and a deploy must
227# not fail because an asset the DM has not installed yet is absent.
228- name: Check whether the creature token catalogue is present
229 stat:
230 path: "{{ dnd_token_dir }}/catalog.json"
231 register: dnd_token_catalog
232 when: dnd_table_enabled
233
234- name: Report a missing creature token catalogue
235 debug:
236 msg: >-
237 No catalog.json under {{ dnd_token_dir }}. The creature browser will
238 offer only custom uploads until the token set is seeded -- see the
239 rsync command in roles/runner/tasks/dnd.yml.
240 when: dnd_table_enabled and not dnd_token_catalog.stat.exists
241
242# The runner already trusts the Harbor CA -- it hosts Harbor -- but Ansible
243# runs as root, which has its own docker credential store.
244- name: Log in to Harbor
245 community.docker.docker_login:
246 registry_url: "{{ dnd_registry }}"
247 username: "{{ dnd_registry_username }}"
248 password: "{{ dnd_registry_password }}"
249 reauthorize: true
250 no_log: true
251
252- name: Pull the agent and post-processing images
253 community.docker.docker_image:
254 name: "{{ item }}"
255 source: pull
256 force_source: true
257 loop:
258 - "{{ dnd_agent_image }}"
259 - "{{ dnd_post_image }}"
260
261- name: Pull the table server image
262 community.docker.docker_image:
263 name: "{{ dnd_table_runner_image }}"
264 source: pull
265 force_source: true
266 when: dnd_table_enabled
267
268# Pulled but not started: it belongs to a session, so the image is made present
269# and the Chamberlain brings it up when a session starts.
270- name: Pull the camera receiver image
271 community.docker.docker_image:
272 name: "{{ dnd_camera_image }}"
273 source: pull
274 force_source: true
275 when: dnd_camera_enabled
276
277# --- the Almanac -------------------------------------------------------------
278# Long-lived, like the Chamberlain: players reach it between sessions, which is
279# most of the time. Unlike the Chamberlain it gets no Docker socket -- see the
280# compose template.
281
282- name: Create the Almanac data directory
283 ansible.builtin.file:
284 path: "{{ dnd_almanac_data_dir }}"
285 state: directory
286 owner: "{{ dnd_uid }}"
287 group: "{{ dnd_gid }}"
288 # 2775: setgid so the SQLite file lands in the shared group, the same
289 # reasoning as the campaign directories.
290 mode: '2775'
291 when: dnd_almanac_enabled | bool
292
293# The SRD corpus from 5e-bits/5e-database (MIT) powers the editor's pickers.
294# NOT shipped by this role: it is several megabytes of JSON, it changes rarely,
295# and the service degrades to free-text fields without it -- which is what a
296# homebrew character gets anyway. Seed it once:
297#
298# git clone --depth 1 https://github.com/5e-bits/5e-database /tmp/5e
299# rsync -a /tmp/5e/src/2014/en/ [email protected]:{{ dnd_almanac_data_dir }}/srd/
300- name: Create the SRD directory
301 ansible.builtin.file:
302 path: "{{ dnd_almanac_data_dir }}/srd"
303 state: directory
304 owner: "{{ dnd_uid }}"
305 group: "{{ dnd_gid }}"
306 mode: '2775'
307 when: dnd_almanac_enabled | bool
308
309- name: Note when the SRD corpus is absent
310 ansible.builtin.find:
311 paths: "{{ dnd_almanac_data_dir }}/srd"
312 patterns: "*.json"
313 register: dnd_almanac_srd_files
314 when: dnd_almanac_enabled | bool
315
316# Fetched rather than left to a manual step: without it the spell picker is
317# empty, and an empty picker looks like a broken feature rather than an absent
318# corpus. 3.8 MB, MIT, and it changes rarely -- so this runs only when the
319# directory is empty and never re-fetches.
320- name: Fetch the SRD corpus
321 ansible.builtin.git:
322 repo: "{{ dnd_almanac_srd_repo }}"
323 dest: /tmp/5e-database
324 depth: 1
325 version: main
326 force: true
327 when:
328 - dnd_almanac_enabled | bool
329 - dnd_almanac_srd_fetch | bool
330 - dnd_almanac_srd_files.matched | default(0) == 0
331 register: dnd_almanac_srd_clone
332 # A runner without a route to GitHub is a degraded editor, not a failed
333 # deploy: the fields fall back to free text, which is what a homebrew
334 # character gets anyway.
335 failed_when: false
336
337- name: Install the SRD corpus
338 ansible.builtin.copy:
339 src: "/tmp/5e-database/src/2014/en/"
340 dest: "{{ dnd_almanac_data_dir }}/srd/"
341 remote_src: true
342 owner: "{{ dnd_uid }}"
343 group: "{{ dnd_gid }}"
344 mode: '0664'
345 when:
346 - dnd_almanac_enabled | bool
347 - dnd_almanac_srd_fetch | bool
348 - dnd_almanac_srd_clone is defined
349 - not (dnd_almanac_srd_clone.failed | default(false))
350 - dnd_almanac_srd_files.matched | default(0) == 0
351
352- name: Report that the editor will fall back to free text
353 ansible.builtin.debug:
354 msg: >-
355 No SRD JSON in {{ dnd_almanac_data_dir }}/srd, and it could not be
356 fetched. The character editor's spell and class pickers will be empty and
357 every field falls back to free text -- a degraded editor, not a broken
358 one. Seed it by hand from {{ dnd_almanac_srd_repo }} (src/2014/en).
359 when:
360 - dnd_almanac_enabled | bool
361 - dnd_almanac_srd_files.matched | default(0) == 0
362 - dnd_almanac_srd_clone is not defined
363 or (dnd_almanac_srd_clone.failed | default(false))
364
365- name: Pull the Almanac image
366 community.docker.docker_image:
367 name: "{{ dnd_almanac_image }}"
368 source: pull
369 force_source: true
370 when: dnd_almanac_enabled | bool
371
372- name: Start the Almanac
373 community.docker.docker_compose_v2:
374 project_src: "{{ dnd_config_dir }}"
375 services: [dnd-almanac]
376 state: present
377 when: dnd_almanac_enabled | bool
378
379# A crossing assertion. `changed=1` from the compose task says a container was
380# created, not that the application inside it works -- the distinction this
381# project has been bitten by three times (pitfalls #31, #47, #50). This asks
382# the running service.
383- name: Wait for the Almanac to answer
384 ansible.builtin.uri:
385 url: "http://127.0.0.1:{{ dnd_almanac_port }}/health"
386 status_code: 200
387 register: dnd_almanac_health
388 retries: 10
389 delay: 3
390 until: dnd_almanac_health is succeeded
391 when: dnd_almanac_enabled | bool
392
393- name: Require the Almanac to enforce authentication
394 ansible.builtin.uri:
395 url: "http://127.0.0.1:{{ dnd_almanac_port }}/api/me"
396 status_code: 401
397 # An unauthenticated 200 here would mean the service players reach is open,
398 # which is the one failure this whole separate-service design exists to
399 # prevent. Worth asserting on every deploy rather than assuming.
400 when: dnd_almanac_enabled | bool
401
402# Only dnd-stt comes up here. The agent belongs to a session and the post
403# processor to a finished one, so both sit behind compose profiles.
404- name: Start D&D speech-to-text service
405 community.docker.docker_compose_v2:
406 project_src: "{{ dnd_config_dir }}"
407 services: [dnd-stt]
408 state: present
409 register: dnd_start_result
410 check_mode: no
411
412# The table server is deliberately NOT started here. It belongs to a session,
413# and the Chamberlain starts and stops it along with the agent and the camera
414# recorder -- so an Ansible deploy places its configuration and leaves it down.
415#
416# Starting it here would also fight the Chamberlain: a deploy mid-session would
417# recreate the container with the *deploy-time* environment, losing the session
418# scoping the Chamberlain injected and sending the table's uploads to the wrong
419# campaign directory.
420
421- name: Wait for dnd-stt to be healthy
422 uri:
423 url: "http://localhost:{{ dnd_stt_host_port }}/health"
424 method: GET
425 status_code: 200
426 register: dnd_stt_health
427 until: dnd_stt_health.status == 200
428 retries: 60
429 delay: 10
430 when: dnd_start_result is changed
431 check_mode: no
432
433# PRELOAD_MODELS is accepted by Speaches but does not fetch the weights in
434# 0.8.3, so the first transcription would otherwise pay a multi-minute
435# download mid-session.
436- name: Check whether the Whisper model is cached
437 ansible.builtin.stat:
438 path: "{{ dnd_stt_model_dir }}/hub/models--{{ dnd_stt_model | replace('/', '--') }}"
439 register: dnd_stt_model_cached
440
441- name: Download the Whisper model
442 ansible.builtin.uri:
443 url: "http://localhost:{{ dnd_stt_host_port }}/v1/models/{{ dnd_stt_model }}"
444 method: POST
445 status_code: [200, 201]
446 timeout: "{{ dnd_stt_model_download_timeout }}"
447 register: dnd_stt_model_pull
448 when: not dnd_stt_model_cached.stat.exists
449 # The endpoint streams progress and can outlive the HTTP timeout while the
450 # download continues server-side; the wait below decides success.
451 failed_when: false
452 check_mode: no
453
454- name: Wait for the Whisper model to finish downloading
455 ansible.builtin.stat:
456 path: "{{ dnd_stt_model_dir }}/hub/models--{{ dnd_stt_model | replace('/', '--') }}"
457 register: dnd_stt_model_ready
458 until: dnd_stt_model_ready.stat.exists
459 retries: 60
460 delay: 15
461 when: not dnd_stt_model_cached.stat.exists
462
463# The post chain transcribes with the *full* model, not the distilled one the
464# live path uses, and it was never provisioned -- the task above pulls only
465# `dnd_stt_model`. It worked anyway because the first post run downloaded it on
466# demand, which is precisely the mid-session cost the block above exists to
467# avoid, arriving by a route nobody had extended it to.
468- name: Check whether the post-side Whisper model is cached
469 ansible.builtin.stat:
470 path: "{{ dnd_stt_model_dir }}/hub/models--{{ dnd_post_stt_model | replace('/', '--') }}"
471 register: dnd_post_stt_model_cached
472
473- name: Download the post-side Whisper model
474 ansible.builtin.uri:
475 url: "http://localhost:{{ dnd_stt_host_port }}/v1/models/{{ dnd_post_stt_model }}"
476 method: POST
477 status_code: [200, 201]
478 timeout: "{{ dnd_stt_model_download_timeout }}"
479 when: not dnd_post_stt_model_cached.stat.exists
480 # As above: the endpoint streams progress and can outlive the HTTP timeout
481 # while the download continues server-side. The wait below decides success.
482 failed_when: false
483 check_mode: no
484
485- name: Wait for the post-side Whisper model to finish downloading
486 ansible.builtin.stat:
487 path: "{{ dnd_stt_model_dir }}/hub/models--{{ dnd_post_stt_model | replace('/', '--') }}"
488 register: dnd_post_stt_model_ready
489 until: dnd_post_stt_model_ready.stat.exists
490 retries: 60
491 delay: 15
492 when: not dnd_post_stt_model_cached.stat.exists
493
494# The artwork model, for the same reason and at a much larger cost. Measured on
495# a real run: the artwork step took 54 minutes against a ~6 minute render,
496# because a cold cache downloads ~13 GB *inside* the step -- where it reports
497# itself to the UI as "generating" and shows no GPU activity at all, which
498# looks exactly like a hung render.
499#
500# Run in the post image rather than fetched by URL: the repository is many
501# files and the cache layout the loader expects (blobs, snapshots, refs) is not
502# something to reproduce by hand. The image already carries huggingface-cli and
503# already knows this mount as /models.
504- name: Check whether the artwork model is cached
505 ansible.builtin.stat:
506 path: "{{ dnd_model_dir }}/hub/models--{{ dnd_artwork_model | replace('/', '--') }}/snapshots"
507 register: dnd_artwork_model_cached
508
509- name: Download the artwork model
510 ansible.builtin.command:
511 argv:
512 - docker
513 - run
514 - --rm
515 - --name=dnd-artwork-pull
516 - "--env=HF_HOME=/models"
517 - "--env=HF_TOKEN={{ vault_runner.huggingface_token }}"
518 - "--volume={{ dnd_model_dir }}:/models"
519 - --entrypoint=huggingface-cli
520 - "{{ dnd_post_image }}"
521 - download
522 - "{{ dnd_artwork_model }}"
523 register: dnd_artwork_model_pull
524 when: not dnd_artwork_model_cached.stat.exists
525 timeout: "{{ dnd_artwork_model_download_timeout }}"
526 changed_when: dnd_artwork_model_pull.rc == 0
527 check_mode: no
528
529- name: Display D&D deployment summary
530 debug:
531 msg: |
532 D&D Session Agent (The Thaumaturgeon):
533 - Status: {{ 'Started' if dnd_start_result is changed else 'Already running' }}
534 - Config: {{ dnd_config_dir }}
535 - Media (NFS): {{ dnd_media_dir }}
536 - Images: {{ dnd_registry }}/{{ dnd_registry_project }} @ {{ dnd_image_tag }}
537
538 Speech to text (always on):
539 - Endpoint: http://{{ ansible_default_ipv4.address }}:{{ dnd_stt_host_port }}/v1
540 - Model: {{ dnd_stt_model }} ({{ dnd_stt_compute_type }})
541
542 Character curation (the Almanac), if enabled:
543 http://{{ ansible_default_ipv4.address }}:{{ dnd_almanac_port }}
544 Players sign up with a per-campaign invite code. It holds NO Docker
545 socket -- that separation is why it is a separate service.
546
547 Sessions are managed by the Chamberlain, not by hand:
548 http://{{ ansible_default_ipv4.address }}:{{ chamberlain_http_port | default(8090) }}
549
550 It creates the campaign and session, opens the LiveKit room, and starts
551 and stops dnd-table-runner, dnd-agent and the camera recorder around the
552 evening. Only dnd-stt is started by this deploy; everything else is
553 per-session and comes up behind a compose profile.
554
555 To drive it by hand anyway (a session the Chamberlain did not create will
556 not be scoped, so prefer the UI):
557 cd {{ dnd_config_dir }} && docker compose --profile session up -d dnd-agent
558 cd {{ dnd_config_dir }} && docker compose logs -f dnd-agent
559
560 Post-processing is a Chamberlain button. By hand, against a session
561 directory:
562 cd {{ dnd_config_dir }} && docker compose run --rm dnd-post \
563 dnd-refine /media/dnd/campaigns/<campaign>/raw/<session>/transcript.jsonl \
564 --roster /media/dnd/campaigns/<campaign>/roster.yml \
565 --table-audio /media/dnd/campaigns/<campaign>/raw/<session>/audio/TV.flac
566