/
/
/
Ansible role that deployes services on my runner machine
1---
2# Runner Services - Mealie
3#
4# Recipe manager, running alongside Tandoor for now. Upstream image pinned to
5# mealie_version; PostgreSQL; nightly backup of database and images to the
6# share; AI features wired to the local LLM stack through LiteLLM.
7#
8# Mealie keeps its first admin and its AI providers in the database rather than
9# in the environment, so the second half of this file configures them through
10# the API: that is what makes a fresh deploy come up usable, and a re-run
11# converge instead of duplicate.
12
13- name: Create Mealie directories
14 file:
15 path: "{{ item.path }}"
16 state: directory
17 owner: "{{ item.owner }}"
18 group: "{{ runner_group }}"
19 mode: '2775'
20 loop:
21 - { path: "{{ mealie_config_dir }}", owner: "{{ runner_user }}" }
22 # Written by Mealie as PUID inside the container.
23 - { path: "{{ mealie_data_dir }}", owner: "{{ mealie_puid }}" }
24 - { path: "{{ mealie_backup_dir }}", owner: "{{ runner_user }}" }
25 # Speaches runs as UID 1000. hub/ must exist, or its model listing fails
26 # on a fresh deploy (the same trap as dnd-stt).
27 - { path: "{{ mealie_stt_model_dir }}", owner: "1000" }
28 - { path: "{{ mealie_stt_model_dir }}/hub", owner: "1000" }
29 loop_control:
30 label: "{{ item.path }}"
31
32# Created once and never chowned again: the postgres image takes it over as
33# UID 70 on first start, and an `owner:` here would take it back.
34- name: Check the Mealie database directory
35 stat:
36 path: "{{ mealie_db_dir }}"
37 register: mealie_db_dir_stat
38
39- name: Create the Mealie database directory
40 file:
41 path: "{{ mealie_db_dir }}"
42 state: directory
43 mode: '0700'
44 when: not mealie_db_dir_stat.stat.exists
45
46- name: Create Mealie environment file
47 template:
48 src: mealie.env.j2
49 dest: "{{ mealie_config_dir }}/mealie.env"
50 owner: "{{ runner_user }}"
51 group: "{{ runner_group }}"
52 # Holds the database password.
53 mode: '0640'
54 notify: restart mealie
55
56- name: Create Mealie Docker Compose file
57 template:
58 src: mealie-compose.yml.j2
59 dest: "{{ mealie_config_dir }}/docker-compose.yml"
60 owner: "{{ runner_user }}"
61 group: "{{ runner_group }}"
62 mode: '0640'
63 notify: restart mealie
64
65- name: Pull the Mealie image
66 community.docker.docker_image:
67 name: "{{ mealie_image }}"
68 source: pull
69
70- name: Start Mealie
71 community.docker.docker_compose_v2:
72 project_src: "{{ mealie_config_dir }}"
73 state: present
74 register: mealie_start_result
75 check_mode: no
76
77# Migrations run on start and take a while on a fresh database, so this waits
78# on the API rather than on the container.
79- name: Wait for Mealie to answer
80 uri:
81 url: "http://127.0.0.1:{{ mealie_port }}/api/app/about"
82 status_code: 200
83 register: mealie_about
84 retries: 40
85 delay: 5
86 until: mealie_about is succeeded
87 check_mode: no
88
89- name: Require Mealie to run the pinned version
90 assert:
91 that: mealie_about.json.version == mealie_version
92 fail_msg: "Mealie reports {{ mealie_about.json.version }}, expected {{ mealie_version }}"
93 quiet: true
94
95- name: Require sign-up to be closed
96 assert:
97 that: not mealie_about.json.allowSignup
98 fail_msg: "Mealie allows sign-up; set mealie_allow_signup: false"
99 quiet: true
100 when: not mealie_allow_signup
101
102# --- speech-to-text model ------------------------------------------------------------
103- name: Speech-to-text for video imports
104 when: mealie_stt_enabled | bool
105 block:
106 - name: Wait for the speech-to-text service
107 uri:
108 url: "http://127.0.0.1:{{ mealie_stt_port }}/health"
109 status_code: 200
110 register: mealie_stt_health
111 retries: 30
112 delay: 5
113 until: mealie_stt_health is succeeded
114 check_mode: no
115
116 - name: Check whether the Whisper model is cached
117 stat:
118 path: "{{ mealie_stt_model_dir }}/hub/models--{{ mealie_stt_model | replace('/', '--') }}"
119 register: mealie_stt_model_cached
120
121 # Speaches downloads a model on request, not at start; the first reel
122 # would otherwise wait for it.
123 - name: Download the Whisper model
124 uri:
125 url: "http://127.0.0.1:{{ mealie_stt_port }}/v1/models/{{ mealie_stt_model }}"
126 method: POST
127 status_code: [200, 201]
128 timeout: 600
129 when: not mealie_stt_model_cached.stat.exists
130 failed_when: false
131 check_mode: no
132
133 - name: Wait for the Whisper model to be on disk
134 stat:
135 path: "{{ mealie_stt_model_dir }}/hub/models--{{ mealie_stt_model | replace('/', '--') }}"
136 register: mealie_stt_model_ready
137 until: mealie_stt_model_ready.stat.exists
138 retries: 40
139 delay: 15
140 when: not mealie_stt_model_cached.stat.exists
141
142# --- the admin account ---------------------------------------------------------
143# A fresh database has exactly one user, [email protected] / MyPassword. The
144# first deploy logs in with that, moves it to the vault's email, name and
145# password, and from then on only the vault credentials work.
146
147- name: Log in to Mealie as the configured admin
148 uri:
149 url: "http://127.0.0.1:{{ mealie_port }}/api/auth/token"
150 method: POST
151 body_format: form-urlencoded
152 body:
153 username: "{{ mealie_admin_email }}"
154 password: "{{ mealie_admin_password }}"
155 status_code: [200, 401]
156 register: mealie_login
157 no_log: true
158 check_mode: no
159
160- name: Take over the default Mealie admin
161 when: mealie_login.status == 401
162 block:
163 - name: Log in with the fresh-install default admin
164 uri:
165 url: "http://127.0.0.1:{{ mealie_port }}/api/auth/token"
166 method: POST
167 body_format: form-urlencoded
168 body:
169 username: [email protected]
170 password: MyPassword
171 status_code: [200, 401]
172 register: mealie_default_login
173 no_log: true
174
175 # Neither works: the admin was changed by hand, or the vault is wrong.
176 # Stop rather than guess -- nothing below can run without a token.
177 - name: Require a working Mealie admin login
178 fail:
179 msg: >-
180 Mealie accepted neither the vault admin ({{ mealie_admin_email }}) nor
181 the fresh-install default. If the admin was changed in the UI, put its
182 email and password in vault_mealie.admin_email / admin_password.
183 when: mealie_default_login.status != 200
184
185 - name: Read the default admin
186 uri:
187 url: "http://127.0.0.1:{{ mealie_port }}/api/users/self"
188 headers:
189 Authorization: "Bearer {{ mealie_default_login.json.access_token }}"
190 register: mealie_default_self
191 no_log: true
192
193 - name: Move the admin to the configured email and name
194 uri:
195 url: "http://127.0.0.1:{{ mealie_port }}/api/users/{{ mealie_default_self.json.id }}"
196 method: PUT
197 headers:
198 Authorization: "Bearer {{ mealie_default_login.json.access_token }}"
199 body_format: json
200 body: >-
201 {{ mealie_default_self.json | combine({
202 'email': mealie_admin_email,
203 'username': mealie_admin_username,
204 'fullName': mealie_admin_full_name}) }}
205 no_log: true
206 changed_when: true
207
208 # Changing the password invalidates the token, so this goes last.
209 - name: Set the configured admin password
210 uri:
211 url: "http://127.0.0.1:{{ mealie_port }}/api/users/password"
212 method: PUT
213 headers:
214 Authorization: "Bearer {{ mealie_default_login.json.access_token }}"
215 body_format: json
216 body:
217 currentPassword: MyPassword
218 newPassword: "{{ mealie_admin_password }}"
219 no_log: true
220 changed_when: true
221
222 - name: Log in as the configured admin
223 uri:
224 url: "http://127.0.0.1:{{ mealie_port }}/api/auth/token"
225 method: POST
226 body_format: form-urlencoded
227 body:
228 username: "{{ mealie_admin_email }}"
229 password: "{{ mealie_admin_password }}"
230 register: mealie_login_after_takeover
231 no_log: true
232
233- name: Keep the Mealie API token
234 set_fact:
235 mealie_token: >-
236 {{ (mealie_login_after_takeover.json.access_token
237 if mealie_login.status == 401
238 else mealie_login.json.access_token) }}
239 no_log: true
240
241- name: Require the default admin to be gone
242 uri:
243 url: "http://127.0.0.1:{{ mealie_port }}/api/auth/token"
244 method: POST
245 body_format: form-urlencoded
246 body:
247 username: [email protected]
248 password: MyPassword
249 status_code: 401
250 no_log: true
251 check_mode: no
252
253# --- units ------------------------------------------------------------------------
254# Only units come from Mealie's seed. Its food and label seed is one US-derived
255# list (2,687 foods, same keys in every locale; nl-NL is a translation with
256# poblano peppers and hash browns, and no plain honey). Foods and labels are
257# instead pushed by the Macro Checker, from NEVO and the products you confirm,
258# each linked back to its Macro Checker food.
259
260# Every deploy: the seeder only adds units whose name is missing, so this is
261# a no-op once they exist and restores any that were deleted. Metric first,
262# but cups, ounces, pounds, pints and quarts are there for American recipes.
263- name: Seed Mealie units
264 uri:
265 url: "http://127.0.0.1:{{ mealie_port }}/api/groups/seeders/units"
266 method: POST
267 headers:
268 Authorization: "Bearer {{ mealie_token }}"
269 body_format: json
270 body:
271 locale: "{{ mealie_seed_locale }}"
272 when: mealie_seed_locale | length > 0
273 changed_when: false
274
275- name: Read Mealie units
276 uri:
277 url: "http://127.0.0.1:{{ mealie_port }}/api/units?perPage=200"
278 headers:
279 Authorization: "Bearer {{ mealie_token }}"
280 register: mealie_units
281 check_mode: no
282
283- name: Add units Mealie's seed lacks
284 uri:
285 url: "http://127.0.0.1:{{ mealie_port }}/api/units"
286 method: POST
287 headers:
288 Authorization: "Bearer {{ mealie_token }}"
289 body_format: json
290 body: "{{ item | combine({'fraction': true, 'useAbbreviation': false}) }}"
291 status_code: [200, 201]
292 loop: "{{ mealie_extra_units }}"
293 loop_control:
294 label: "{{ item.name }}"
295 when: item.name not in (mealie_units.json['items'] | map(attribute='name') | list)
296 changed_when: true
297
298# "550 g", not "550 gram"; and metric amounts as decimals, never "½ gram".
299- name: Show metric units abbreviated
300 uri:
301 url: "http://127.0.0.1:{{ mealie_port }}/api/units/{{ item.id }}"
302 method: PUT
303 headers:
304 Authorization: "Bearer {{ mealie_token }}"
305 body_format: json
306 body: >-
307 {{ item | combine({'useAbbreviation': true, 'fraction': false,
308 'abbreviation': item.abbreviation or mealie_abbreviated_units[item.name]}) }}
309 loop: "{{ mealie_units.json['items'] | selectattr('name', 'in', mealie_abbreviated_units.keys() | list) | list }}"
310 loop_control:
311 label: "{{ item.name }}"
312 when: not item.useAbbreviation or item.fraction or not item.abbreviation
313 changed_when: true
314
315# --- AI providers ----------------------------------------------------------------
316# Fixed names per role, so changing a model updates the provider rather than
317# adding another one.
318
319- name: Configure Mealie AI providers
320 when: mealie_ai_enabled | bool
321 block:
322 - name: Decide the wanted AI providers
323 set_fact:
324 mealie_ai_wanted: >-
325 {{ [{'role': 'default', 'name': 'Local LLM (text)', 'model': mealie_ai_default_model,
326 'base_url': mealie_ai_base_url, 'api_key': mealie_ai_api_key}]
327 + ([{'role': 'image', 'name': 'Local LLM (vision)', 'model': mealie_ai_image_model,
328 'base_url': mealie_ai_base_url, 'api_key': mealie_ai_api_key}]
329 if mealie_ai_image_model | length > 0 else [])
330 + ([{'role': 'audio', 'name': 'Local STT (audio)', 'model': mealie_stt_model,
331 'base_url': 'http://mealie-stt:8000/v1', 'api_key': 'speaches'}]
332 if mealie_stt_enabled | bool else []) }}
333
334 - name: Read the current AI provider settings
335 uri:
336 url: "http://127.0.0.1:{{ mealie_port }}/api/groups/ai-providers/settings"
337 headers:
338 Authorization: "Bearer {{ mealie_token }}"
339 register: mealie_ai_settings_before
340
341 - name: Map existing AI providers by name
342 set_fact:
343 mealie_ai_existing: >-
344 {{ dict(mealie_ai_settings_before.json.providers
345 | map(attribute='name')
346 | zip(mealie_ai_settings_before.json.providers | map(attribute='id'))) }}
347
348 - name: Create missing AI providers
349 uri:
350 url: "http://127.0.0.1:{{ mealie_port }}/api/groups/ai-providers/providers"
351 method: POST
352 headers:
353 Authorization: "Bearer {{ mealie_token }}"
354 body_format: json
355 body:
356 name: "{{ item.name }}"
357 baseUrl: "{{ item.base_url }}"
358 apiKey: "{{ item.api_key }}"
359 model: "{{ item.model }}"
360 timeout: "{{ mealie_ai_timeout | int }}"
361 status_code: [200, 201]
362 loop: "{{ mealie_ai_wanted | rejectattr('name', 'in', mealie_ai_existing.keys() | list) | list }}"
363 loop_control:
364 label: "{{ item.name }} -> {{ item.model }}"
365 no_log: true
366 changed_when: true
367
368 - name: Read existing AI providers
369 uri:
370 url: "http://127.0.0.1:{{ mealie_port }}/api/groups/ai-providers/providers/{{ mealie_ai_existing[item.name] }}"
371 headers:
372 Authorization: "Bearer {{ mealie_token }}"
373 loop: "{{ mealie_ai_wanted | selectattr('name', 'in', mealie_ai_existing.keys() | list) | list }}"
374 loop_control:
375 label: "{{ item.name }}"
376 register: mealie_ai_current
377
378 # Mealie never returns the key, so a key change cannot be seen here; that
379 # is what mealie_ai_sync_keys is for.
380 - name: Update AI providers that drifted
381 uri:
382 url: "http://127.0.0.1:{{ mealie_port }}/api/groups/ai-providers/providers/{{ item.json.id }}"
383 method: PUT
384 headers:
385 Authorization: "Bearer {{ mealie_token }}"
386 body_format: json
387 body:
388 name: "{{ item.item.name }}"
389 baseUrl: "{{ item.item.base_url }}"
390 apiKey: "{{ item.item.api_key }}"
391 model: "{{ item.item.model }}"
392 timeout: "{{ mealie_ai_timeout | int }}"
393 loop: "{{ mealie_ai_current.results | default([]) }}"
394 loop_control:
395 label: "{{ item.item.name }} -> {{ item.item.model }}"
396 when: >-
397 mealie_ai_sync_keys | bool
398 or item.json.baseUrl != item.item.base_url
399 or item.json.model != item.item.model
400 or (item.json.timeout | int) != (mealie_ai_timeout | int)
401 no_log: true
402 changed_when: true
403
404 - name: Read the AI providers after changes
405 uri:
406 url: "http://127.0.0.1:{{ mealie_port }}/api/groups/ai-providers/settings"
407 headers:
408 Authorization: "Bearer {{ mealie_token }}"
409 register: mealie_ai_settings_after
410
411 - name: Map AI providers by name after changes
412 set_fact:
413 mealie_ai_ids: >-
414 {{ dict(mealie_ai_settings_after.json.providers
415 | map(attribute='name')
416 | zip(mealie_ai_settings_after.json.providers | map(attribute='id'))) }}
417
418 - name: Assign the default, image and audio AI providers
419 uri:
420 url: "http://127.0.0.1:{{ mealie_port }}/api/groups/ai-providers/settings"
421 method: PUT
422 headers:
423 Authorization: "Bearer {{ mealie_token }}"
424 body_format: json
425 # One expression, so an unset provider is sent as JSON null rather
426 # than the string "None".
427 body: >-
428 {{ {'defaultProviderId': mealie_ai_ids['Local LLM (text)'],
429 'imageProviderId': (mealie_ai_ids['Local LLM (vision)']
430 if mealie_ai_image_model | length > 0 else None),
431 'audioProviderId': (mealie_ai_ids['Local STT (audio)']
432 if mealie_stt_enabled | bool else None)} }}
433 when: >-
434 mealie_ai_settings_after.json.defaultProviderId != mealie_ai_ids['Local LLM (text)']
435 or (mealie_ai_image_model | length > 0
436 and mealie_ai_settings_after.json.imageProviderId != mealie_ai_ids['Local LLM (vision)'])
437 or (mealie_stt_enabled | bool
438 and mealie_ai_settings_after.json.audioProviderId != mealie_ai_ids['Local STT (audio)'])
439 changed_when: true
440
441 # A real chat round-trip through LiteLLM, and for the vision provider an
442 # image read. `changed` from the tasks above only says the rows exist.
443 # Not the audio provider: Mealie's test is a chat message, which a
444 # speech-to-text server cannot answer; the transcription check below is
445 # its test instead.
446 - name: Test the AI providers end to end
447 uri:
448 url: "http://127.0.0.1:{{ mealie_port }}/api/groups/ai-providers/providers/{{ mealie_ai_ids[item.name] }}/test"
449 method: POST
450 headers:
451 Authorization: "Bearer {{ mealie_token }}"
452 timeout: "{{ mealie_ai_timeout | int + 30 }}"
453 loop: "{{ mealie_ai_wanted | rejectattr('role', 'equalto', 'audio') | list }}"
454 loop_control:
455 label: "{{ item.name }} -> {{ item.model }}"
456 register: mealie_ai_tests
457 check_mode: no
458
459 - name: Require every AI provider to answer
460 assert:
461 that: item.json.success
462 fail_msg: >-
463 Mealie could not use {{ item.item.model }} through {{ mealie_ai_base_url }}:
464 {{ item.json.message }}. See `docker logs mealie` and `docker logs litellm`.
465 quiet: true
466 loop: "{{ mealie_ai_tests.results }}"
467 loop_control:
468 label: "{{ item.item.name }}"
469
470 # Reported rather than failed: text features work without it.
471 - name: Report a vision provider that cannot read images
472 debug:
473 msg: >-
474 {{ item.item.model }} answered but could not read the test image, so
475 recipe-from-photo imports will fail. Check that the model uses
476 provider ollama_chat in llm_stack_models and the LiteLLM config is
477 deployed (--tags llm-stack).
478 loop: "{{ mealie_ai_tests.results }}"
479 loop_control:
480 label: "{{ item.item.name }}"
481 when: item.item.role == 'image' and not (item.json.supportsImages | default(false))
482
483# The audio provider's real test: transcribe a second of silence through the
484# same endpoint Mealie uses, from inside the Mealie container (so the name
485# mealie-stt and the network are what is tested, too).
486- name: Test speech-to-text from Mealie
487 command: >-
488 docker exec mealie python -c
489 "import io, wave, openai;
490 b = io.BytesIO(); w = wave.open(b, 'wb'); w.setnchannels(1); w.setsampwidth(2);
491 w.setframerate(16000); w.writeframes(bytes(32000)); w.close(); b.name = 'silence.wav'; b.seek(0);
492 c = openai.OpenAI(base_url='http://mealie-stt:8000/v1', api_key='speaches', timeout=300);
493 r = c.audio.transcriptions.create(model='{{ mealie_stt_model }}', file=b);
494 print('ok', repr(r.text))"
495 register: mealie_stt_test
496 changed_when: false
497 when: mealie_enabled and mealie_stt_enabled | bool and mealie_ai_enabled | bool
498 check_mode: no
499
500- name: Display Mealie deployment summary
501 debug:
502 msg: |
503 Mealie {{ mealie_about.json.version }}:
504 - Web UI: http://{{ ansible_default_ipv4.address }}:{{ mealie_port }} (BASE_URL {{ mealie_base_url }})
505 - Admin: {{ mealie_admin_email }}
506 - Config: {{ mealie_config_dir }}
507 - Data: {{ mealie_data_dir }}, database {{ mealie_db_dir }}
508 - Backups: {{ mealie_backup_dir }} (nightly, {{ mealie_backup_keep_days }} days)
509 {% if mealie_ai_enabled %}
510 - AI: {{ mealie_ai_default_model }} (text){% if mealie_ai_image_model %}, {{ mealie_ai_image_model }} (vision){% endif %} via {{ mealie_ai_base_url }}
511 {% endif %}
512 Runs alongside Tandoor ({{ tandoor_config_dir }}) until that is retired.
513