diet: заведён плейбук, периметр и бэкапы

- playbook-diet.yml и files/diet: образ через app_image, uid=gid=1111,
  каталоги config/data/cache, backup-targets со строкой data/
- блок сайта в Caddy разделён: /api/* идёт мимо Authelia под собственную
  аутентификацию, клиентские Remote-* срезаются на уровне сайта
- правило Authelia one_factor без subject: пользователи шире группы admins
This commit is contained in:
av
2026-09-06 11:50:40 +03:00
parent 41284ed33f
commit 8e1d5f8112
8 changed files with 238 additions and 0 deletions
+1
View File
@@ -77,6 +77,7 @@ uv run ansible-galaxy install --role-file requirements.yml
- `playbook-transcriber.yml` — транскрибация (образ собирается локально ролью `app_image`). - `playbook-transcriber.yml` — транскрибация (образ собирается локально ролью `app_image`).
- `playbook-wanderer.yml` — пешие маршруты. - `playbook-wanderer.yml` — пешие маршруты.
- `playbook-remembos.yml` — интервальное повторение. - `playbook-remembos.yml` — интервальное повторение.
- `playbook-diet.yml` — дневник питания (Go + SQLite, образ собирается локально ролью `app_image`; сайт разделён — `/api/*` мимо Authelia под собственную аутентификацию, разбор еды моделями через Bifrost).
- `playbook-tuwunel.yml` — Matrix-сервер (Tuwunel) с federation-делегацией на apex-домен. - `playbook-tuwunel.yml` — Matrix-сервер (Tuwunel) с federation-делегацией на apex-домен.
- `playbook-tududi.yml` — планировщик задач (SQLite, OIDC через Authelia, AI-фичи через Bifrost). - `playbook-tududi.yml` — планировщик задач (SQLite, OIDC через Authelia, AI-фичи через Bifrost).
- `playbook-bifrost.yml` — LLM-шлюз (OpenAI-совместимый) для AI-фич других приложений, провайдер DeepSeek. - `playbook-bifrost.yml` — LLM-шлюз (OpenAI-совместимый) для AI-фич других приложений, провайдер DeepSeek.
@@ -747,6 +747,14 @@ access_control:
subject: "group:admins" subject: "group:admins"
policy: "two_factor" policy: "two_factor"
# diet — дневник питания. Без subject и одним фактором намеренно: круг
# пользователей шире админов (семья), а второй фактор кого-то отпугнёт.
# Кого периметр пропустил, тому приложение заводит запись при первом входе:
# своего списка разрешённых у него нет. Правило касается только клиента и
# POST /auth/session — /api/* до Authelia не доходит вовсе.
- domain: "diet.vakhrushev.me"
policy: "one_factor"
- domain: "rssbridge.vakhrushev.me" - domain: "rssbridge.vakhrushev.me"
subject: "group:admins" subject: "group:admins"
policy: "one_factor" policy: "one_factor"
+32
View File
@@ -225,6 +225,38 @@ remembos.vakhrushev.me {
} }
# diet — дневник питания. Сайт разделён, в отличие от соседей: у ассистента и # diet — дневник питания. Сайт разделён, в отличие от соседей: у ассистента и
# скриптов своя аутентификация (личный токен), через Authelia они пройти не могут.
diet.vakhrushev.me {
tls anwinged@ya.ru
import common
# Заголовки личности ставит только Authelia. Клиентские срезаются на всех
# маршрутах разом, до всякой маршрутизации: приложение источник запроса не
# проверяет, поэтому подделанный заголовок — это подделанная личность.
# copy_headers ниже живёт внутри forward_auth и /api/* не покрывает.
request_header -Remote-User
request_header -Remote-Groups
request_header -Remote-Email
request_header -Remote-Name
# API: своя аутентификация (сессионная кука или личный токен), периметр
# в неё не вмешивается.
handle /api/* {
reverse_proxy diet_app:8080
}
# Всё остальное — клиент и вход — за Authelia.
handle {
forward_auth authelia_app:9091 {
uri /api/authz/forward-auth
copy_headers Remote-User Remote-Groups Remote-Email Remote-Name
}
reverse_proxy diet_app:8080
}
}
calibre.vakhrushev.me {
tls anwinged@ya.ru tls anwinged@ya.ru
import common import common
@@ -63,6 +63,11 @@
description: Интервальное повторение description: Интервальное повторение
icon: mdi-brain icon: mdi-brain
siteMonitor: http://remembos_app:8080 siteMonitor: http://remembos_app:8080
- Diet:
href: https://diet.vakhrushev.me
description: Дневник питания
icon: mdi-food-apple
siteMonitor: http://diet_app:8080
- Matrix: - Matrix:
href: https://matrix.vakhrushev.me href: https://matrix.vakhrushev.me
description: Мессенджер (Tuwunel) description: Мессенджер (Tuwunel)
+60
View File
@@ -0,0 +1,60 @@
# Diet — конфигурация. Рендерится плейбуком playbook-diet.yml.
#
# Здесь только обязательные поля и те умолчания, что меняются осознанно:
# полный справочник полей с их умолчаниями — config.example.toml в репозитории
# приложения. Разбор строгий: неизвестный ключ в этом файле роняет старт.
[general]
# Часовой пояс новых пользователей: по нему режутся сутки в итогах дня.
# Хранение времени и журнал всегда в UTC, его это не трогает.
timezone = "Europe/Moscow"
[http]
# Порт наружу не публикуется: в сеть смотрит только Caddy, а внутрь
# web_proxy_network приложение видно по имени контейнера diet_app.
listen = "0.0.0.0:8080"
[storage]
# data/ приложения смонтирован на /var/lib/diet — единственное, что уезжает
# в бэкап (backup-targets).
db_path = "/var/lib/diet/diet.db"
[storage.files]
# Снимки тарелок и этикеток: каталог на диске этой же машины.
type = "local"
dir = "/var/lib/diet/files"
[cache.images]
# cache/ смонтирован на /var/cache/diet: содержимое восстановимо целиком,
# в бэкап не идёт и сносится в любой момент.
type = "local"
dir = "/var/cache/diet/images"
[llm]
# Шлюз в общей сети; версию API ("/v1/chat/completions") приложение
# дописывает само, поэтому адрес указывается корнем.
base_url = "http://bifrost_app:8080/openai"
# Bifrost в закрытой сети ключа не спрашивает: governance у него не включён
# и виртуальных ключей нет. Пустая строка — приложение ходит без авторизации.
# Появится ключ — значение приедет из vault, а файл станет секретным:
# тогда задаче рендера нужны no_log и права 0600.
api_key = ""
[llm.recognition]
# Снимки читает vision-модель через bothub — кандидат по умолчанию из замера
# на живых фотографиях (docs/research/2026-08-31-vision-recognition.md
# в репозитории приложения): точность не хуже моделей в девяносто раз дороже.
type = "openai-compat"
model = "bothub/gemini-3.1-flash-lite"
[llm.composition]
# Сведение позиций. Модель медленнее соседки по шлюзу, зато честно говорит
# «не знаю» о незнакомом продукте вместо выдуманного КБЖУ. Разбор фоновый,
# человек ответа у экрана не ждёт, поэтому ожидание здесь дешевле выдумки.
type = "openai-compat"
model = "bothub/deepseek-v4-flash"
# Замер давал этой модели 19–70 секунд на ответ, то есть умолчание в минуту
# обрывало бы её самые долгие ответы, засчитывая их временной неудачей.
timeout = "2m"
+28
View File
@@ -0,0 +1,28 @@
# Образ собирается локально и доставляется на сервер ролью app_image
# (см. playbook-diet.yml): тег — случайный BUILD_ID каждого прогона,
# в реестре образа нет, поэтому pull_policy: never.
#
# В образе нет оболочки (distroless): зайти внутрь работающего контейнера
# нечем, разбор идёт по журналу и GET /healthz.
services:
diet_app:
image: "{{ app_name }}:{{ app_image_tag }}"
container_name: diet_app
restart: unless-stopped
pull_policy: never
user: "{{ owner_create_result.uid }}:{{ owner_create_result.group }}"
networks:
- "web_proxy_network"
volumes:
- "{{ config_dir }}:/config:ro"
- "{{ data_dir }}:/var/lib/diet"
- "{{ cache_dir }}:/var/cache/diet"
command:
- "--config"
- "/config/config.toml"
networks:
web_proxy_network:
external: true
+3
View File
@@ -37,6 +37,9 @@
- name: "Configure remembos" - name: "Configure remembos"
ansible.builtin.import_playbook: playbook-remembos.yml ansible.builtin.import_playbook: playbook-remembos.yml
- name: "Configure diet"
ansible.builtin.import_playbook: playbook-diet.yml
- name: "Configure apprise" - name: "Configure apprise"
ansible.builtin.import_playbook: playbook-apprise.yml ansible.builtin.import_playbook: playbook-apprise.yml
+101
View File
@@ -0,0 +1,101 @@
---
- name: "Configure diet application"
hosts: all
vars:
app_name: "diet"
app_user: "{{ app_name }}"
app_owner_uid: 1111
app_owner_gid: 1111
base_dir: "{{ (application_dir, app_name) | path_join }}"
config_dir: "{{ (base_dir, 'config') | path_join }}"
data_dir: "{{ (base_dir, 'data') | path_join }}"
cache_dir: "{{ (base_dir, 'cache') | path_join }}"
config_file: "{{ (config_dir, 'config.toml') | path_join }}"
tasks:
- name: "Create user and environment"
ansible.builtin.import_role:
name: owner
vars:
owner_name: "{{ app_user }}"
owner_uid: "{{ app_owner_uid }}"
owner_gid: "{{ app_owner_gid }}"
# Подкаталоги files и images заводятся здесь, а не приложением: оно
# проверяет их на запись при старте и без них не поднимается вовсе.
# Всё, что появится внутри них, приложение создаёт само.
- name: "Create application internal directories"
ansible.builtin.file:
path: "{{ item }}"
state: "directory"
owner: "{{ app_user }}"
group: "{{ app_user }}"
mode: "0750"
loop:
- "{{ base_dir }}"
- "{{ config_dir }}"
- "{{ data_dir }}"
- "{{ (data_dir, 'files') | path_join }}"
- "{{ cache_dir }}"
- "{{ (cache_dir, 'images') | path_join }}"
# Секретов в файле пока нет: ключ к шлюзу пустой, а больше приложению
# ничего секретного не нужно. Поэтому без no_log — правку конфига видно
# в --diff. Появится ключ из vault — вернуть no_log и права 0600.
- name: "Copy config"
ansible.builtin.template:
src: "./files/{{ app_name }}/config.template.toml"
dest: "{{ config_file }}"
owner: "{{ app_user }}"
group: "{{ app_user }}"
mode: "0640"
notify: "Restart application"
# Данные — только data/: config/ восстанавливает этот плейбук, cache/
# приложение пересчитывает само.
- name: "Create backup targets file"
ansible.builtin.copy:
dest: "{{ base_dir }}/backup-targets"
content: |
{{ data_dir }}
owner: "{{ app_user }}"
group: "{{ app_user }}"
mode: "0640"
# Образ собирается на control-хосте самим репозиторием diet (контракт —
# `task image`) и едет на сервер через docker save/load, без реестра.
# Роль выставляет факт app_image_tag — его подхватывает docker-compose.
- name: "Build and deliver application image"
ansible.builtin.import_role:
name: "app_image"
vars:
app_image_name: "{{ app_name }}"
app_image_src_dir: '{{ (playbook_dir, "..", "diet") | path_join }}'
- name: "Copy docker compose file"
ansible.builtin.template:
src: "./files/{{ app_name }}/docker-compose.template.yml"
dest: "{{ base_dir }}/docker-compose.yml"
owner: "{{ app_user }}"
group: "{{ app_user }}"
mode: "0640"
- name: "Run application with docker compose"
community.docker.docker_compose_v2:
project_src: "{{ base_dir }}"
state: "present"
remove_orphans: true
tags:
- run-app
handlers:
# Приложение читает конфиг только при старте, а `state: present` не
# пересоздаёт контейнер, если docker-compose.yml не изменился, — поэтому
# правку конфига применяет только явный рестарт.
- name: "Restart application"
community.docker.docker_compose_v2:
project_src: "{{ base_dir }}"
state: "restarted"
tags:
- run-app