/
/
/
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_config_dir }}/src"
21 - "{{ dnd_stt_model_dir }}"
22 # huggingface_hub.scan_cache_dir() raises CacheNotFound if hub/ is
23 # missing, which makes Speaches' /v1/models return 500 on a fresh deploy.
24 - "{{ dnd_stt_model_dir }}/hub"
25 - "{{ dnd_model_dir }}"
26
27# The application is built on the runner from a checkout rather than pulled
28# from a registry: it changes far more often than the infrastructure around
29# it, and this keeps deploys to a single playbook run.
30- name: Synchronise D&D agent source
31 ansible.posix.synchronize:
32 src: "{{ dnd_source_dir }}/"
33 dest: "{{ dnd_config_dir }}/src/"
34 delete: true
35 rsync_opts:
36 - "--exclude=.git"
37 - "--exclude=.venv"
38 - "--exclude=__pycache__"
39 - "--exclude=*.egg-info"
40 - "--exclude=.pytest_cache"
41 register: dnd_source_sync
42 when: dnd_build_from_source
43
44- name: Create D&D environment files
45 template:
46 src: "{{ item.src }}"
47 dest: "{{ dnd_config_dir }}/{{ item.dest }}"
48 owner: "{{ runner_user }}"
49 group: "{{ runner_group }}"
50 mode: '0660'
51 loop:
52 - { src: "dnd-stt-env.j2", dest: "stt.env" }
53 - { src: "dnd-agent.env.j2", dest: "agent.env" }
54 - { src: "dnd-post.env.j2", dest: "post.env" }
55 loop_control:
56 label: "{{ item.dest }}"
57 notify: restart dnd
58
59- name: Create D&D Docker Compose file
60 template:
61 src: dnd-compose.yml.j2
62 dest: "{{ dnd_config_dir }}/docker-compose.yml"
63 owner: "{{ runner_user }}"
64 group: "{{ runner_group }}"
65 mode: '0664'
66 notify: restart dnd
67
68# Only dnd-stt comes up here. The agent belongs to a session and the post
69# processor to a finished one, so both sit behind compose profiles.
70- name: Start D&D speech-to-text service
71 community.docker.docker_compose_v2:
72 project_src: "{{ dnd_config_dir }}"
73 services: [dnd-stt]
74 state: present
75 register: dnd_start_result
76 check_mode: no
77
78- name: Wait for dnd-stt to be healthy
79 uri:
80 url: "http://localhost:{{ dnd_stt_host_port }}/health"
81 method: GET
82 status_code: 200
83 register: dnd_stt_health
84 until: dnd_stt_health.status == 200
85 retries: 60
86 delay: 10
87 when: dnd_start_result is changed
88 check_mode: no
89
90# PRELOAD_MODELS is accepted by Speaches but does not fetch the weights in
91# 0.8.3, so the first transcription would otherwise pay a multi-minute
92# download mid-session.
93- name: Check whether the Whisper model is cached
94 ansible.builtin.stat:
95 path: "{{ dnd_stt_model_dir }}/hub/models--{{ dnd_stt_model | replace('/', '--') }}"
96 register: dnd_stt_model_cached
97
98- name: Download the Whisper model
99 ansible.builtin.uri:
100 url: "http://localhost:{{ dnd_stt_host_port }}/v1/models/{{ dnd_stt_model }}"
101 method: POST
102 status_code: [200, 201]
103 timeout: "{{ dnd_stt_model_download_timeout }}"
104 register: dnd_stt_model_pull
105 when: not dnd_stt_model_cached.stat.exists
106 # The endpoint streams progress and can outlive the HTTP timeout while the
107 # download continues server-side; the wait below decides success.
108 failed_when: false
109 check_mode: no
110
111- name: Wait for the Whisper model to finish downloading
112 ansible.builtin.stat:
113 path: "{{ dnd_stt_model_dir }}/hub/models--{{ dnd_stt_model | replace('/', '--') }}"
114 register: dnd_stt_model_ready
115 until: dnd_stt_model_ready.stat.exists
116 retries: 60
117 delay: 15
118 when: not dnd_stt_model_cached.stat.exists
119
120# Built ahead of time so the first session does not wait on an image build.
121# Uses the compose CLI directly because docker_compose_v2 has no way to build
122# profile-gated services without also starting them.
123- name: Build session and post-processing images
124 ansible.builtin.command:
125 cmd: "docker compose --profile session --profile post build"
126 chdir: "{{ dnd_config_dir }}"
127 register: dnd_build
128 changed_when: "'Building' in dnd_build.stdout or 'Building' in dnd_build.stderr"
129 when: dnd_build_images
130 check_mode: no
131
132- name: Display D&D deployment summary
133 debug:
134 msg: |
135 D&D Session Agent (The Thaumaturgeon):
136 - Status: {{ 'Started' if dnd_start_result is changed else 'Already running' }}
137 - Config: {{ dnd_config_dir }}
138 - Media (NFS): {{ dnd_media_dir }}
139
140 Speech to text (always on):
141 - Endpoint: http://{{ ansible_default_ipv4.address }}:{{ dnd_stt_host_port }}/v1
142 - Model: {{ dnd_stt_model }} ({{ dnd_stt_compute_type }})
143
144 Start a session:
145 cd {{ dnd_config_dir }} && docker compose --profile session up -d dnd-agent
146 cd {{ dnd_config_dir }} && docker compose logs -f dnd-agent
147
148 Stop the session (flushes transcript and compresses recordings):
149 cd {{ dnd_config_dir }} && docker compose stop dnd-agent
150
151 Process a finished session:
152 cd {{ dnd_config_dir }} && docker compose run --rm dnd-post \
153 dnd-refine /media/dnd/transcripts/<session>.jsonl \
154 --roster /media/dnd/campaign/roster.yml \
155 --table-audio /media/dnd/recordings/<session>/TV.flac
156 cd {{ dnd_config_dir }} && docker compose run --rm dnd-post \
157 dnd-chronicle /media/dnd/transcripts/<session>-refined.jsonl \
158 --campaign /media/dnd/campaign
159