From 8e1d5f8112761c179c5ca3243ad01c60eb7b5c6a Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Sun, 6 Sep 2026 11:50:40 +0300 Subject: [PATCH] =?UTF-8?q?diet:=20=D0=B7=D0=B0=D0=B2=D0=B5=D0=B4=D1=91?= =?UTF-8?q?=D0=BD=20=D0=BF=D0=BB=D0=B5=D0=B9=D0=B1=D1=83=D0=BA,=20=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D0=B8=D0=BC=D0=B5=D1=82=D1=80=20=D0=B8=20=D0=B1?= =?UTF-8?q?=D1=8D=D0=BA=D0=B0=D0=BF=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- AGENTS.md | 1 + files/authelia/configuration.template.yml | 8 ++ files/caddyproxy/Caddyfile.template | 32 ++++++ files/dashboard/config/services.template.yaml | 5 + files/diet/config.template.toml | 60 +++++++++++ files/diet/docker-compose.template.yml | 28 +++++ playbook-all-applications.yml | 3 + playbook-diet.yml | 101 ++++++++++++++++++ 8 files changed, 238 insertions(+) create mode 100644 files/diet/config.template.toml create mode 100644 files/diet/docker-compose.template.yml create mode 100644 playbook-diet.yml diff --git a/AGENTS.md b/AGENTS.md index ea691f3..b4c14e8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,6 +77,7 @@ uv run ansible-galaxy install --role-file requirements.yml - `playbook-transcriber.yml` — транскрибация (образ собирается локально ролью `app_image`). - `playbook-wanderer.yml` — пешие маршруты. - `playbook-remembos.yml` — интервальное повторение. +- `playbook-diet.yml` — дневник питания (Go + SQLite, образ собирается локально ролью `app_image`; сайт разделён — `/api/*` мимо Authelia под собственную аутентификацию, разбор еды моделями через Bifrost). - `playbook-tuwunel.yml` — Matrix-сервер (Tuwunel) с federation-делегацией на apex-домен. - `playbook-tududi.yml` — планировщик задач (SQLite, OIDC через Authelia, AI-фичи через Bifrost). - `playbook-bifrost.yml` — LLM-шлюз (OpenAI-совместимый) для AI-фич других приложений, провайдер DeepSeek. diff --git a/files/authelia/configuration.template.yml b/files/authelia/configuration.template.yml index 699f73e..d406100 100644 --- a/files/authelia/configuration.template.yml +++ b/files/authelia/configuration.template.yml @@ -747,6 +747,14 @@ access_control: subject: "group:admins" policy: "two_factor" + # diet — дневник питания. Без subject и одним фактором намеренно: круг + # пользователей шире админов (семья), а второй фактор кого-то отпугнёт. + # Кого периметр пропустил, тому приложение заводит запись при первом входе: + # своего списка разрешённых у него нет. Правило касается только клиента и + # POST /auth/session — /api/* до Authelia не доходит вовсе. + - domain: "diet.vakhrushev.me" + policy: "one_factor" + - domain: "rssbridge.vakhrushev.me" subject: "group:admins" policy: "one_factor" diff --git a/files/caddyproxy/Caddyfile.template b/files/caddyproxy/Caddyfile.template index 27dad7c..c1877d1 100644 --- a/files/caddyproxy/Caddyfile.template +++ b/files/caddyproxy/Caddyfile.template @@ -225,6 +225,38 @@ remembos.vakhrushev.me { } } +# 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 import common diff --git a/files/dashboard/config/services.template.yaml b/files/dashboard/config/services.template.yaml index f3e057c..93f552f 100644 --- a/files/dashboard/config/services.template.yaml +++ b/files/dashboard/config/services.template.yaml @@ -63,6 +63,11 @@ description: Интервальное повторение icon: mdi-brain siteMonitor: http://remembos_app:8080 + - Diet: + href: https://diet.vakhrushev.me + description: Дневник питания + icon: mdi-food-apple + siteMonitor: http://diet_app:8080 - Matrix: href: https://matrix.vakhrushev.me description: Мессенджер (Tuwunel) diff --git a/files/diet/config.template.toml b/files/diet/config.template.toml new file mode 100644 index 0000000..8a412dc --- /dev/null +++ b/files/diet/config.template.toml @@ -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" diff --git a/files/diet/docker-compose.template.yml b/files/diet/docker-compose.template.yml new file mode 100644 index 0000000..ef93a8b --- /dev/null +++ b/files/diet/docker-compose.template.yml @@ -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 diff --git a/playbook-all-applications.yml b/playbook-all-applications.yml index 0205680..0d40fde 100644 --- a/playbook-all-applications.yml +++ b/playbook-all-applications.yml @@ -37,6 +37,9 @@ - name: "Configure remembos" ansible.builtin.import_playbook: playbook-remembos.yml +- name: "Configure diet" + ansible.builtin.import_playbook: playbook-diet.yml + - name: "Configure apprise" ansible.builtin.import_playbook: playbook-apprise.yml diff --git a/playbook-diet.yml b/playbook-diet.yml new file mode 100644 index 0000000..b5fc572 --- /dev/null +++ b/playbook-diet.yml @@ -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