Compare commits

..
99 Commits
Author SHA1 Message Date
av c5217607b1 owner: группа docker убрана у сервисных пользователей
Linting / YAML Lint (push) Canceled after 0s
Linting / Ansible Lint (push) Canceled after 0s
- 20 приложений её не получают: контейнерами рулит ansible от root, а
  членство в группе docker равносильно root на хосте
- у gitea, miniflux и outline группа осталась — их backup.sh дампит базу
  через docker compose exec из-под самого приложения
- задача service-users-docker-group закрыта, остаток вынесен в новую
  backup-dump-without-docker-group
2026-08-22 19:40:57 +03:00
av 856bf72560 owner: роль причёсана под конвенции eget и secrets
- валидация аргументов через assert вместо пары fail с одинаковым именем
- with_items/with_dict заменены на loop, имена задач без точки на конце
- добавлены meta/main.yml и README с таблицей переменных
- задача ansible-owner-role-cleanup удалена из беклога
2026-08-22 19:27:33 +03:00
av 70372ee028 caddyproxy: HSTS для всех сайтов и мелкий хардненинг
- сниппет access_log переименован в common, туда добавлен заголовок
  Strict-Transport-Security на год; без includeSubDomains — под
  vakhrushev.me есть имена не с этого сервера
- у edge-контейнера убрана capability NET_ADMIN, reverse-proxy её не требует
- убран leftover-листенер :29999 у status.vakhrushev.me
- задача caddy-hardening-nits удалена из беклога
2026-08-22 19:14:34 +03:00
av 16f0712a31 caddyproxy: Caddyfile применяется через caddy reload
- вместо рестарта всего прокси, через который идёт весь трафик; конфиг
  указан явно через --config, иначе caddy молча перезагружает не тот файл
- задача handlers-remaining-apps удалена из беклога
2026-08-22 19:08:08 +03:00
av 3bb85b85f6 handlers рестарта для apprise, dashboard, transcriber, miniflux
- правка bind-моунтнутого конфига раньше не подхватывалась до пересоздания
  контейнера; теперь template/secrets шлют notify на рестарт
- miniflux: убран recreate: always, вместо него точечный рестарт miniflux_app
  по изменению файлов секретов, postgres больше не дёргается
- goaccess: рестарт не нужен, build: always пересоздаёт контейнер по новому
  id образа — зафиксировано комментарием
2026-08-22 19:08:05 +03:00
av 7e64d76d02 ufw: правило для zabbix-агента и включение в all-setup
Linting / YAML Lint (push) Canceled after 0s
Linting / Ansible Lint (push) Canceled after 0s
- Порт 10050 (агент Timeweb) открыт только опросчикам хостера из Server= его
  конфига, а не всему интернету.
- playbook-ufw.yml добавлен в playbook-all-setup.yml перед docker: раньше он
  запускался только руками и после переезда на Timeweb не был прогнан вовсе —
  на сервере ufw до сих пор с ENABLED=no и пустым набором правил.
- В шапке плейбука зафиксировано, что ufw не закрывает порты, опубликованные
  докером: они идут мимо цепочки INPUT.
2026-08-22 18:00:24 +03:00
av 6bb0d61183 caddyproxy: admin API закрыт на loopback, метрики вынесены на :2020
- `admin :2019` слушал всю web_proxy_network: любой контейнер мог POST-ом в
  /config/ переписать маршрутизацию и снять forward_auth. Теперь
  `admin localhost:2019`.
- Метрики для netdata отдаёт отдельный site-блок `http://:2020 { metrics
  /metrics }`, netdata скрейпит новый порт.
- Порт для метрик именно 2020, а не 2019, как предлагала задача: сайт на :2019
  перехватывает IPv6-петлю (admin висит только на IPv4), из-за чего
  `caddy reload` внутри контейнера возвращает 0 и молча ничего не применяет —
  проверено на caddy:2.11.4.
2026-08-22 17:44:08 +03:00
av 304263e7b1 authelia: уровень логов понижен с debug до info
- debug-логи содержат детали аутентификации, имена пользователей и внутренности
  OIDC-потоков — в обычной эксплуатации не нужны, а через docker logs/dozzle это
  лишняя поверхность утечки.
- Задача убрана из беклога.
2026-08-22 17:38:56 +03:00
av 25886d1be0 miniflux: /metrics закрыт снаружи в Caddy
- METRICS_ALLOWED_NETWORKS не помогает: за прокси Miniflux видит IP Caddy,
  а netdata скрейпит по той же web_proxy_network — по IP их не различить
- задача убрана из беклога
2026-08-22 17:35:00 +03:00
av b9be642c5c gitea: ветка по умолчанию в новых репозиториях — master 2026-08-22 17:29:22 +03:00
av 9c75a52529 Dashboard: update to 2.1.2
Мажорное обновление homepage. Единственное ломающее изменение 2.0.0 —
встроенная аутентификация, она включается только через HOMEPAGE_AUTH_ENABLED,
так что дашборд остаётся за Authelia как прежде.
2026-08-22 13:39:01 +03:00
av a909442d8f образы приложений прописаны в docker-compose явно
- dashboard и wanderer брали имя образа и версию из переменных плейбука;
  теперь, как у остальных сервисов, они видны прямо в compose
- переменные homepage_image и wanderer_version удалены за ненадобностью
2026-08-22 13:38:36 +03:00
av 6ff11061d9 apprise: бандл server переименован в backups, telegram убран
Linting / YAML Lint (push) Canceled after 0s
Linting / Ansible Lint (push) Canceled after 0s
- имя отражает единственный источник уведомлений — скрипт бэкапа;
  плейбук удаляет с сервера прежний server.cfg, иначе он остался бы
  рабочим конфигом со старыми секретами
- канал tgram:// удалён, уведомления идут на почту и в matrix
2026-08-22 12:38:34 +03:00
av a9d9eb8d83 apprise: уведомления дублируются в matrix-комнату
Linting / YAML Lint (push) Canceled after 0s
Linting / Ansible Lint (push) Canceled after 0s
- в бандл server добавлен канал matrix; в homeserver ходим по внутренней
  docker-сети, для этого у tuwunel заведён алиас без подчёркивания
- плейбук проверяет формат id комнаты: значение без '!' или '#' apprise
  считает алиасом и молча не доставляет уведомление
2026-08-22 12:28:01 +03:00
av daa4379dbe backups: в нотификацию добавлены размеры приложений и свободное место
Linting / YAML Lint (push) Canceled after 0s
Linting / Ansible Lint (push) Canceled after 0s
- список приложений теперь со значком статуса (забекаплено, упал дамп,
  бекапить нечего) и занятым местом; размеры считает dust одним вызовом
- в конце уведомления — свободное место на дисках сервера, по одной строке
  на файловую систему
2026-08-22 11:50:07 +03:00
av b5b43a484e в ruff включён набор UP: современный синтаксис аннотаций
- typing.Dict/List заменены на dict/list, Optional[X] — на X | None;
  эти же места подсвечивал basedpyright в редакторе
- из smtp-convert-secret-key-to-password.py убрана проверка на Python 2
2026-08-22 11:37:51 +03:00
av bdc319df64 линтеры python перенастроены: mypy заменён на pyrefly
- набор правил ruff расширен по канону rp-local-env (ANN, PTH, C90, G и др.),
  в lefthook добавлен job ruff check --fix
- код приведён под новые правила: логирование через %s вместо f-строк,
  os.path заменён на pathlib, run_backup_process и initialize разбиты
  на функции по порогу цикломатической сложности
2026-08-22 11:32:03 +03:00
av 321f6e7b6b backups: в нотификацию попадают все забекапленные приложения
- список строится по backup-targets, а не только по успешным скриптам дампа,
  поэтому приложения с бекапом одних данных (calibre, goatcounter, tududi,
  tuwunel) больше не теряются
- отсутствие backup.sh при наличии backup-targets — штатный случай, теперь
  это info, а warning остался только когда бекапить нечего вообще
2026-08-22 11:25:06 +03:00
av 889c8a565e добавлен goatcounter для веб-аналитики
Linting / YAML Lint (push) Canceled after 0s
Linting / Ansible Lint (push) Canceled after 0s
- один инстанс на два сайта, gc-main и gc-photo: нужный выбирается по Host
- домены ведутся в goatcounter_sites (group_vars), оттуда же рендерятся имена в Caddyfile
- сайты и первый пользователь заводятся идемпотентно, креды из vault
2026-08-21 22:06:06 +03:00
av af14732652 Tududi: update to 1.4.1
Linting / YAML Lint (push) Canceled after 0s
Linting / Ansible Lint (push) Canceled after 0s
2026-08-21 17:50:49 +03:00
av 3fd82b71b2 wakapi: исправлена ссылка на релизы в шапке docker-compose
Linting / YAML Lint (push) Canceled after 0s
Linting / Ansible Lint (push) Canceled after 0s
- в комментарии со времён copy-paste стоял пакет grampsweb вместо muety/wakapi
2026-08-21 10:43:53 +03:00
av d4824bac6f Wakapi: update to 2.17.6 2026-08-21 10:43:51 +03:00
av cbdf2e7b2f Apprise: update to 1.5.2 2026-08-21 10:43:42 +03:00
av a9d0498c12 Gramps: update to 26.8.0 2026-08-21 10:43:40 +03:00
av 37774f4542 Bifrost: update to 1.6.11 2026-08-21 10:43:39 +03:00
av 4484c9d9c2 Dozzle: update to 10.7.3 2026-08-21 10:43:37 +03:00
av cf75db3482 tuwunel: удалена несуществующая опция TUWUNEL_ALLOW_CHECK_FOR_UPDATES
- наследство conduwuit: в tuwunel такого поля нет ни в конфиге, ни в
  исходниках, проверки обновлений тоже нет — переменная молча игнорировалась
2026-08-21 10:43:29 +03:00
av 07a82ca4b0 Tuwunel: update to 1.9.0 2026-08-21 10:43:27 +03:00
av b79b1269c4 Tududi: update to 1.4.0 2026-08-21 10:43:09 +03:00
av ef20e39315 Gitea: update to 1.27.2 2026-08-21 10:43:07 +03:00
av 626bac5303 Netdata: снижено потребление памяти и CPU
Linting / YAML Lint (push) Canceled after 0s
Linting / Ansible Lint (push) Canceled after 0s
- выключены ML (52 MB и тренировка k-means по 13.7k измерений каждые 3
  часа), network-viewer, debugfs и ioping; storage tiers 3 → 2, то есть
  максимум истории 3 месяца вместо 2 лет; apps.plugin обходит /proc раз в
  30s вместо 10s, метрики групп процессов при этом сохранены
- из /proc/net/dev отфильтрованы veth*, br-* и docker0 — 462 графика на 50
  интерфейсов, из которых 43 безымянные veth докера. Побочный эффект:
  проверка имени в proc_net_dev.c идёт до netdev_rename_this_device(),
  поэтому теряется единственный смапленный cgroup_caddyproxy.net_eth0
- postgres перестал собирать метрики индексов (205 индексов × 2 графика);
  max_db_indexes задан внутри джоб, потому что на верхнем уровне файла
  go.d разбирает только confgroup.Default и молча игнорирует остальное

Замер до: 13.7k метрик, 479 MiB, ~18% ядра. Ожидаемо после: ~11.8k
метрик и ~310 MiB.
2026-08-13 08:57:09 +03:00
av 50c975d49b Netdata: update to 2.11.0
- в 2.11 появился netflow-plugin, его стоковый netflow.yaml идёт с
  enabled: true и слушателями на UDP 2055/6343; при дефолтном
  `enable running new plugins = yes` он поднялся бы сам, поэтому отключён
  явно — экспортировать потоки нам некому, а память не бесконечная
- ломающих изменений для нас нет: убранный из prometheus-коллектора
  label_prefix и удалённый go-коллектор sensors мы не используем
2026-08-12 21:56:27 +03:00
av dd4a3b8456 Calibre-Web: update to 0.6.27 2026-08-10 08:53:27 +03:00
av aeddc1f168 tasks.py: версия authelia CLI берётся из docker-compose
Linting / YAML Lint (push) Canceled after 0s
Linting / Ansible Lint (push) Canceled after 0s
- константа AUTHELIA_DOCKER была прибита к 4.39.4, а сервис уже на 4.39.20 — конфиг валидировался чужим бинарником
- authelia-validate-config сам создаёт temp/: директория в .gitignore, на свежем клоне задача падала
2026-08-06 10:39:02 +03:00
av 045b2426b3 Authelia: увеличено время жизни сессии, Outline получает refresh token
- session cookies: inactivity 1 week, expiration 1 month, remember_me 3 months вместо дефолтных 5 минут / 1 часа / 1 месяца
- клиенту outline-app выданы scope offline_access и grant refresh_token с кастомным lifespan (refresh token 3 месяца): без них ValidateSSOAccessTask не мог продлить access token и ротацией jwtSecret убивал все сессии Outline
- consent_mode переведён в pre-configured, чтобы при повторном входе не показывался экран согласия
2026-08-06 10:38:48 +03:00
av 58519f1413 eget: обновлены версии инструментов
- rclone 1.75.0, restic 0.19.1, resticprofile 0.33.1, btop 1.4.7,
  gobackup 3.1.1, task 3.52.0, zellij 0.44.3; dust уже актуален
- gobackup 3.x переименовал ключи passphrase и MSSQL, но наши конфиги
  используют только local-хранилище и sqlite
2026-08-06 10:19:14 +03:00
av 1fcc189afc GoAccess: update to 1.11
- базовый образ и тег локальной сборки с jq подняты одной парой
2026-08-06 10:02:27 +03:00
av 16ab872ba4 Miniflux: update to 2.3.3 2026-08-06 10:02:26 +03:00
av 85ebeb4197 AI-модель tududi задаётся явно, правило роутинга в bifrost убрано
- с tududi 1.3.1 модель берётся из LLM_MODEL, а не захардкожена — шлём
  deepseek/deepseek-v4-pro, Bifrost резолвит провайдера по префиксу
- ключ и base URL переведены на пару LLM_*, приоритетную над OPENAI_*
- routing_rules оставлен пустым массивом, а не удалён: при
  source_of_truth=config.json отсутствующая секция не трогает строки в config.db
2026-08-06 10:00:47 +03:00
av a09f3c629e tududi: TUDUDI_TRUST_PROXY переведён на 1
- `true` доверял всей цепочке X-Forwarded-For, что позволяло подделать IP
  клиента; у нас ровно один хоп — Caddy
- tududi с 1.3.x сам приводит значение к 1 и пишет предупреждение на каждый старт
2026-08-06 10:00:34 +03:00
av 90bdb4a3ea Tududi: update to 1.3.1 2026-08-06 09:43:28 +03:00
av 88a53076e9 bifrost: в compose записано, как проверять обновления образа
- тег образа соответствует релизу transports/vX.Y.Z, а теги 2.1.x рядом на
  Docker Hub — это helm-chart
- конфиг после апдейта валидировать по config.schema.json: неизвестные ключи
  Bifrost игнорирует молча, в логах ошибки не будет
2026-08-06 09:20:50 +03:00
av 4b6d5cdc85 bifrost: исправлено расположение source_of_truth в конфиге
- ключ лежал внутри config_store, где Bifrost его молча игнорировал — шлюз
  работал в дефолтном режиме split вместо заявленного config.json
- поднят на верхний уровень, теперь providers и governance из config.json
  авторитетно перезаписывают строки в config.db на старте
2026-08-06 09:18:15 +03:00
av b794eea87a Bifrost: update to 1.6.8 2026-08-06 09:17:54 +03:00
av 9acf687530 Tuwunel: update to 1.8.3 2026-08-06 09:17:51 +03:00
av d814db591a Memos: update to 0.30.0
Linting / YAML Lint (push) Canceled after 0s
Linting / Ansible Lint (push) Canceled after 0s
2026-07-31 08:43:54 +03:00
av 3da10a0759 Gramps: update to 26.7.1 2026-07-31 08:43:52 +03:00
av 6a2f5a72f4 Gitea: update to 1.27.1 2026-07-31 08:43:51 +03:00
av dd2b684b33 Dozzle: update to 10.6.14 2026-07-31 08:43:49 +03:00
av bb141807c9 adr: записана локальная сборка образов вместо Yandex Container Registry
Linting / YAML Lint (push) Canceled after 0s
Linting / Ansible Lint (push) Canceled after 0s
Постфактум по сегодняшней работе: почему ушли от cr.yandex к роли app_image
(упрощение деплоя без раннера + сокращение секретов) и чем за это платим.
2026-07-25 16:31:38 +03:00
av 0b2a3bd19c backlog: удалена задача local-image-build, она реализована
- все три приложения (homepage, transcriber, remembos) собирают образы
  локально ролью app_image, реестр и yc_*-переменные выведены из оборота
- ссылавшиеся задачи обновлены: narrow-secret-scopes и
  no-secrets-in-playbook-output больше не ждут эту работу
2026-07-25 16:26:31 +03:00
av 6a0d786bdd secrets: убраны переменные Yandex Container Registry
yc_oauth_token, yc_container_registry и yc_container_registry_repository
больше не используются: homepage, transcriber и remembos собирают образы
локально ролью app_image.
2026-07-25 16:24:04 +03:00
av 6d28d361e2 ee: редактор запускается через subprocess вместо pty invoke
- invoke оставлял на внешнем терминале ixon, и Ctrl+S/Ctrl+Q съедал драйвер
  терминала как XOFF/XON — до micro они не доходили
- код возврата ansible-vault теперь пробрасывается наружу
2026-07-25 16:23:03 +03:00
av 1b2bc61cff docs: реестр убран из описаний, беклог отмечен по локальной сборке
- AGENTS.md: homepage/transcriber собираются ролью app_image, registry-плейбуков
  и vars/transcriber.images.yml больше нет
- local-image-build: закрыты шаги по homepage и transcriber, остался вынос
  yc_*-переменных из vault; добавлены замеры архивов
- container-hardening-nits: снят пункт про плавающий `:latest` у homepage
2026-07-25 14:37:08 +03:00
av fad0d78718 transcriber: деплой через локальную сборку образа вместо Yandex Registry
- плейбук зовёт роль app_image, docker_login убран
- compose тянет `transcriber:{{ app_image_tag }}` с pull_policy: never
- удалены playbook-transcriber-registry.yml и vars/transcriber.images.yml
  с зашитым тегом образа
2026-07-25 14:36:50 +03:00
av cb43273ad5 homepage: деплой через локальную сборку образа вместо Yandex Registry
- плейбук зовёт роль app_image, docker_login и `pull: always` убраны
- compose тянет `homepage-nginx:{{ app_image_tag }}` с pull_policy: never
- удалён playbook-homepage-registry.yml и registry-переменные из vars
2026-07-25 14:36:39 +03:00
av d9656b8cdb app_image: перед передачей печатается размер архива с образом
Копирование tar — самый долгий шаг деплоя; теперь видно, сколько мегабайт
едет на сервер и не распух ли образ. Роль синхронизирована с каноном.
2026-07-25 14:21:36 +03:00
av 049ced48f5 remembos: в шаблоне конфига описано поведение бота при недоступном telegram 2026-07-25 14:18:06 +03:00
av 5dc199f3ce remembos: деплой через локальную сборку образа вместо Yandex Registry
- плейбук зовёт роль app_image, docker_login в cr.yandex удалён
- compose тянет `{{ app_name }}:{{ app_image_tag }}` с pull_policy: never
  вместо зашитого тега из реестра
2026-07-25 14:06:30 +03:00
av 156df94f00 roles: завендорена общая роль app_image
- подписка в SYNCED_ROLES + копия из канона ansible-roles (inv roles-pull)
- роль собирает образ приложения на control-хосте по контракту `task image`
  и везёт его на сервер через docker save/load, без реестра
2026-07-25 14:06:20 +03:00
av d211052914 docs: заведён склад конвенций, первая — категории директорий приложения
- docs/conventions/ с индексом: чем конвенция отличается от ADR и drafts,
  статусы (рекомендуемая / обязательная), когда заводить
- app-directories.md: конфигурация / данные / кеш по принципу создания и
  ценности; из категорий выводится backup-targets
- раздел «Конвенции» в AGENTS.md со ссылкой на склад
2026-07-25 13:52:00 +03:00
av f505e9ebeb backlog: отмечен сделанный шаг с no_log
- Первый шаг задачи закрыт коммитом b7b1f49; остались вынос секретов
  в файлы и проверка от дрейфа.
- Записано исключение: compose miniflux оставлен без no_log, из vault он
  тянет только имя пользователя и имя базы.
2026-07-25 12:30:41 +03:00
av b7b1f49c8a ansible: секреты закрыты no_log в выводе плейбуков
- Пятнадцать задач, рендерящих файлы с секретами из vault, и три
  `docker_login` получили `no_log: true`: раньше `--diff` печатал значения
  в терминал целой строкой при каждом изменении файла.
- Цена — дифф этих файлов больше не виден. Снимать по мере переезда
  секретов в отдельные файлы; у tududi и wanderer снять не выйдет.
2026-07-25 12:30:06 +03:00
av fbd0a66f44 backlog: secrets-env-to-file разложена по приложениям
- Шесть задач `secrets-file-*` вместо одной: outline, wakapi, authelia,
  gramps (средний), gitea, tududi (низкий, выигрыш частичный). Wanderer
  отпал — файловых секретов не умеет ни meilisearch, ни pocketbase.
- Матрица механизмов со ссылками на код уехала в
  docs/drafts/secrets-file-support.md, родитель — на кладбище.
2026-07-25 12:25:50 +03:00
av f9a8c64377 backlog: матрица поддержки файловых секретов по приложениям
- Проверено по исходникам закреплённых версий: outline, wakapi, gitea,
  gramps и authelia умеют брать секреты из файла, tududi и wanderer — нет.
- Отмечено, что `secrets-env-to-file` переросла в эпик: разбивать по
  приложениям отдельным заходом.
2026-07-25 12:20:20 +03:00
av 3b846536f9 backlog: заведён запрет секретов в выводе плейбуков
- `inv pl` идёт с `--diff`, и 14 задач в 12 плейбуках печатают отрендеренные
  секреты в терминал; `no_log` есть только в `roles/secrets`. Инвентарь,
  рычаги (`no_log`, `diff: false`, вынос в отдельный файл) и порядок — в задаче.
- Актуализирована `secrets-env-to-file`: список приложений сверен с шаблонами
  (tuwunel чистый, у gitea только SMTP), добавлен мотив с диффом.
2026-07-25 12:03:08 +03:00
av 0e7300ed0e backlog: добавлены задачи про локальную сборку образов и права секретов
- Уход от Yandex Container Registry на роль `app_image`: homepage,
  transcriber, remembos собираются локально и едут через docker save/load.
- Сужение прав секретов: `yc_oauth_token` открывает весь аккаунт ради
  одного `docker login` — вторая задача зависит от первой.
2026-07-25 11:54:31 +03:00
av f3c76ca583 backlog: добавлена идея о хранении пароля ansible-vault
- Пароль лежит открытым текстом рядом с репозиторием, а `ansible.cfg`
  подставляет его в любую ansible-команду — отсюда дамп секретов
  через `ansible-inventory --list`.
- Заведена идеей, а не задачей: компромисс «удобство против доступности
  автоматизации агенту» не разрешён, варианты с KeePassXC записаны с ценой.
2026-07-25 11:43:14 +03:00
av 72d8a9932e backlog: синхронизированы заголовки 10 задач в индексе
- Заголовки в README.md разошлись с H1 файлов задач; выровнено
  механически через `backlog.py check --fix`, тексты задач не менялись.
2026-07-25 11:42:59 +03:00
av 564432755d Ansible: точечные фиксы идемпотентности
- backup-targets собирается одной copy вместо lineinfile в цикле: теперь
  удаление цели из списка реально убирает строку из файла (7 плейбуков)
- netdata: getent вместо grep по /etc/group, ушёл бессмысленный changed_when
  и мёртвый фолбэк PGID
- исправлены копипастные имена play в wanderer и remove-user-and-app
2026-07-24 21:26:39 +03:00
av 4d976585ec Ansible: единственный инвентарь production.yml
- production.yml переписан на живой Timeweb-хост, timeweb.yml удалён,
  HOSTS_FILE в tasks.py и примеры в README поправлены
- host-vars application_dir и mount_external_storage не перенесены: это
  наследие YC-диска, а после переезда в group_vars инвентарь их перебивает
- ansible.cfg: inventory по умолчанию, чтобы забытый -i не уводил в никуда
2026-07-24 21:15:12 +03:00
av 9c156df0c7 Ansible: рестарт контейнеров переведён на handlers
- caddyproxy, netdata, authelia, bifrost, remembos: конфиг-задачи нотифаят
  handler "Restart application" вместо безусловного или условного рестарта
- ansible.cfg: force_handlers, иначе упавший прогон теряет notify и рестарт,
  применяющий правку конфига, не случится уже никогда
- authelia рестартит только authelia_app, чтобы не сбрасывать сессии в redis
2026-07-24 21:05:27 +03:00
av 1cf660b4fd Ansible: общие переменные перенесены в group_vars/all
- vars/vars.yml → group_vars/all/main.yml, vars/secrets.yml →
  group_vars/all/secrets.yml; boilerplate vars_files убран из всех плейбуков
- имя secrets.yml сохранено намеренно: pre-commit-хук и .crushignore ищут
  слово secret(s) в имени файла
- host_vars инвентаря теперь имеют приоритет над общими переменными
2026-07-24 20:44:51 +03:00
av cd0d014c99 Claude: подключить плагины av-dev-skills (backlog, git)
Linting / YAML Lint (push) Canceled after 0s
Linting / Ansible Lint (push) Canceled after 0s
2026-07-24 15:29:19 +03:00
av d22f7a203b Wakapi: update to 2.17.5 2026-07-24 15:28:43 +03:00
av 76fa640010 Outline: update to 1.9.2 2026-07-24 15:28:41 +03:00
av c4a044063b Netdata: update to 2.10.4 2026-07-24 15:28:40 +03:00
av 516a796f27 Gramps: update to 26.7.0 2026-07-24 15:28:38 +03:00
av bd6b574f1e Dozzle: update to 10.6.11 2026-07-24 15:28:36 +03:00
av 9dc57e0d29 eget: вынести роль в ansible-roles, декларативные пакеты
Linting / YAML Lint (push) Canceled after 0s
Linting / Ansible Lint (push) Canceled after 0s
2026-07-16 21:03:27 +03:00
av f31f51959e Invoke: задачи синхронизации общих ролей (roles-pull/push/status) 2026-07-16 21:03:26 +03:00
av 3b572b9c25 Tududi: update to 1.2.4
Linting / YAML Lint (push) Canceled after 0s
Linting / Ansible Lint (push) Canceled after 0s
2026-07-15 08:44:39 +03:00
av 25bd6bedf8 Gitea: update t0 1.27.0
Linting / YAML Lint (push) Canceled after 0s
Linting / Ansible Lint (push) Canceled after 0s
2026-07-14 09:42:38 +03:00
av 9b8cd5cab6 Backlog: уточнить SSH-задачу, завести очистку устаревшего инвентаря
Linting / YAML Lint (push) Canceled after 0s
Linting / Ansible Lint (push) Canceled after 0s
2026-07-13 19:52:01 +03:00
av b60e55ec5e Backlog: security-ревью (fable) — 13 находок по приоритетам 2026-07-13 19:47:51 +03:00
av f110cf7208 Backlog: перенести задачи из tududi (диск-алерты, logrotate, аудит безопасности) 2026-07-13 19:30:53 +03:00
av 30358de9d9 Docs: собрать беклог задач из drafts 2026-07-13 19:25:20 +03:00
av a41f6bca8e Tasks: add mc and edit-encrypted, unify base command set 2026-07-13 12:57:42 +03:00
av 93a0af7b94 Outline: update to 1.9.1
Linting / YAML Lint (push) Canceled after 0s
Linting / Ansible Lint (push) Canceled after 0s
2026-07-13 09:35:29 +03:00
av ce553591b1 Miniflux: allow fetching feeds from private network
Linting / YAML Lint (push) Has been cancelled
Linting / Ansible Lint (push) Has been cancelled
Since 2.2.18 the fetcher blocks private/loopback hosts by default (SSRF
protection), which broke feeds served via rssbridge_app in the docker network.
2026-07-11 19:25:02 +03:00
av 57b9df201e Apprise: update to 1.5.1
Linting / YAML Lint (push) Has been cancelled
Linting / Ansible Lint (push) Has been cancelled
2026-07-11 15:21:36 +03:00
av 240ef3e1ad Miniflux: update to 2.3.2 2026-07-11 15:12:29 +03:00
av 84fc16d54d Tuwunel: update to 1.8.1 2026-07-11 15:09:49 +03:00
av aa9e566044 Gramps: update to 26.6.2 2026-07-11 15:03:54 +03:00
av 143f0a2429 Dozzle: update to 10.6.9 2026-07-11 14:57:45 +03:00
av 2da995ee72 GoAccess: update Caddy to 2.11.4 2026-07-11 14:57:44 +03:00
av c206db7344 Caddyproxy: update to 2.11.4 2026-07-11 14:57:41 +03:00
av 1ad4005ad0 Outline: update to 1.9.0
Linting / YAML Lint (push) Has been cancelled
Linting / Ansible Lint (push) Has been cancelled
2026-07-11 14:39:41 +03:00
av e6579d9b5f Tududi: update to 1.2.0 2026-07-11 14:39:27 +03:00
134 changed files with 3453 additions and 1242 deletions
+14
View File
@@ -0,0 +1,14 @@
{
"enabledPlugins": {
"av-dev-backlog@av-dev-skills": true,
"av-dev-git@av-dev-skills": true
},
"extraKnownMarketplaces": {
"av-dev-skills": {
"source": {
"source": "git",
"url": "https://git.vakhrushev.me/av/dev-skills.git"
}
}
}
}
+29 -12
View File
@@ -2,19 +2,20 @@
## Обзор ## Обзор
Ansible-проект для автоматизации личного сервера. Плейбуки разворачивают докеризированные приложения (gitea, authelia, miniflux, wakapi, memos, outline, gramps, calibre, wanderer, remembos, transcriber и др.) через выделенных системных пользователей, Caddy-прокси и Yandex Docker Registry. Секреты управляются через Ansible Vault. Ansible-проект для автоматизации личного сервера. Плейбуки разворачивают докеризированные приложения (gitea, authelia, miniflux, wakapi, memos, outline, gramps, calibre, wanderer, remembos, transcriber и др.) через выделенных системных пользователей и Caddy-прокси. Секреты управляются через Ansible Vault.
## Структура проекта ## Структура проекта
- `playbook-*.yml` — плейбуки по одному на сервис, `playbook-all-*.yml` для групповых запусков. - `playbook-*.yml` — плейбуки по одному на сервис, `playbook-all-*.yml` для групповых запусков.
- `production.yml` — инвентарь с единственным хостом `server`. - `production.yml` — инвентарь с единственным хостом `server`.
- `vars/*.yml` — переменные приложений и образов, `vars/secrets.yml` зашифрованные секреты (vault). - `group_vars/all/` — общие переменные для всех хостов: `main.yml` (открытые) и `secrets.yml` (зашифрованные vault). Подхватываются Ansible автоматически, `vars_files` в плейбуках не нужен. Имя `secrets.yml` обязательно: pre-commit-хук и `.crushignore` ищут слово `secret(s)` в имени файла.
- `roles/` — кастомные роли (`eget`, `owner`, `secrets`), галактические роли в `galaxy.roles/`. - `vars/*.yml` — переменные отдельных приложений (homepage, transcriber), подключаются через `vars_files`.
- `roles/` — кастомные роли (`eget`, `owner`, `secrets`, `app_image`), галактические роли в `galaxy.roles/`.
- `files/<app>/` — docker-compose шаблоны, конфиги, скрипты бэкапов для каждого сервиса. - `files/<app>/` — docker-compose шаблоны, конфиги, скрипты бэкапов для каждого сервиса.
- `templates/` — общие шаблоны (например `env.template`). - `templates/` — общие шаблоны (например `env.template`).
- `scripts/` — вспомогательные Python-скрипты (SMTP-утилиты для Yandex Cloud Postbox). - `scripts/` — вспомогательные Python-скрипты (SMTP-утилиты для Yandex Cloud Postbox).
- `.gitea/workflows/lint.yml` — CI: yamllint + ansible-lint. - `.gitea/workflows/lint.yml` — CI: yamllint + ansible-lint.
- `lefthook.yml` — pre-commit хуки (ruff, mypy, yamllint, ansible-lint, gitleaks, проверка vault). - `lefthook.yml` — pre-commit хуки (ruff, pyrefly, yamllint, ansible-lint, gitleaks, проверка vault).
- `tasks.py` — задачи через invoke (`inv <task>`). - `tasks.py` — задачи через invoke (`inv <task>`).
- `pyproject.toml` — зависимости Python, управляются через `uv`. - `pyproject.toml` — зависимости Python, управляются через `uv`.
@@ -64,15 +65,16 @@ uv run ansible-galaxy install --role-file requirements.yml
- `playbook-wakapi.yml` — трекинг времени. - `playbook-wakapi.yml` — трекинг времени.
- `playbook-memos.yml` — заметки. - `playbook-memos.yml` — заметки.
- `playbook-outline.yml` — вики/база знаний. - `playbook-outline.yml` — вики/база знаний.
- `playbook-homepage.yml` — кастомная домашняя страничка на apex `vakhrushev.me` (образ из Yandex Registry). - `playbook-homepage.yml` — кастомная домашняя страничка на apex `vakhrushev.me` (образ собирается локально ролью `app_image`).
- `playbook-dashboard.yml` — дашборд со списком сервисов (gethomepage.dev) на `start.vakhrushev.me`, закрыт за Authelia. - `playbook-dashboard.yml` — дашборд со списком сервисов (gethomepage.dev) на `start.vakhrushev.me`, закрыт за Authelia.
- `playbook-rssbridge.yml` — RSS-агрегатор. - `playbook-rssbridge.yml` — RSS-агрегатор.
- `playbook-netdata.yml` — мониторинг. - `playbook-netdata.yml` — мониторинг.
- `playbook-dozzle.yml` — просмотр Docker-логов. - `playbook-dozzle.yml` — просмотр Docker-логов.
- `playbook-goaccess.yml` — аналитика веб-логов Caddy в реальном времени. - `playbook-goaccess.yml` — аналитика веб-логов Caddy в реальном времени.
- `playbook-goatcounter.yml` — веб-аналитика посещений (GoatCounter, SQLite), собственный логин. Один инстанс на несколько сайтов, сайт выбирается по `Host`; список доменов — `goatcounter_sites` в `group_vars/all/main.yml`.
- `playbook-gramps.yml` — генеалогия. - `playbook-gramps.yml` — генеалогия.
- `playbook-calibre.yml` — управление электронными книгами. - `playbook-calibre.yml` — управление электронными книгами.
- `playbook-transcriber.yml` — транскрибация (образ из Yandex Registry). - `playbook-transcriber.yml` — транскрибация (образ собирается локально ролью `app_image`).
- `playbook-wanderer.yml` — пешие маршруты. - `playbook-wanderer.yml` — пешие маршруты.
- `playbook-remembos.yml` — интервальное повторение. - `playbook-remembos.yml` — интервальное повторение.
- `playbook-tuwunel.yml` — Matrix-сервер (Tuwunel) с federation-делегацией на apex-домен. - `playbook-tuwunel.yml` — Matrix-сервер (Tuwunel) с federation-делегацией на apex-домен.
@@ -83,7 +85,6 @@ uv run ansible-galaxy install --role-file requirements.yml
- `playbook-all-setup.yml` — системная настройка целиком (system + docker + eget + backups). - `playbook-all-setup.yml` — системная настройка целиком (system + docker + eget + backups).
- `playbook-all-applications.yml` — деплой всех приложений. - `playbook-all-applications.yml` — деплой всех приложений.
- `playbook-homepage-registry.yml` / `playbook-transcriber-registry.yml` — загрузка образов в Yandex Registry.
- `playbook-remove-user-and-app.yml` — удаление пользователя и приложения (`--extra-vars user_name=<name>`). - `playbook-remove-user-and-app.yml` — удаление пользователя и приложения (`--extra-vars user_name=<name>`).
## Роли ## Роли
@@ -91,29 +92,41 @@ uv run ansible-galaxy install --role-file requirements.yml
- `roles/owner` — создаёт системного пользователя/группу для приложения, настраивает SSH-ключи, переменные окружения (~/.env, ~/.bashrc). - `roles/owner` — создаёт системного пользователя/группу для приложения, настраивает SSH-ключи, переменные окружения (~/.env, ~/.bashrc).
- `roles/eget` — скачивает и устанавливает утилиту eget. - `roles/eget` — скачивает и устанавливает утилиту eget.
- `roles/secrets` — управляет vault-зашифрованными файлами секретов для приложений. - `roles/secrets` — управляет vault-зашифрованными файлами секретов для приложений.
- `roles/app_image` — собирает docker-образ приложения на control-хосте (контракт приложения — `task image` с тегом из `$BUILD_ID`) и везёт его на сервер через `docker save`/`load`, без реестра; отдаёт факт `app_image_tag` для compose. Общая роль, синхронизируется с каноном `ansible-roles` (`inv roles-pull`).
Галактические роли (`galaxy.roles/`): `geerlingguy.security`, `geerlingguy.docker`, `yatesr.timezone`. Галактические роли (`galaxy.roles/`): `geerlingguy.security`, `geerlingguy.docker`, `yatesr.timezone`.
## Шаблоны и переменные ## Шаблоны и переменные
- Суффиксы шаблонов: `.template.yml`, `.template.sh`, `.template.cfg`, `.template.conf`, `.template.toml`, `.template` (для файлов без естественного расширения) — рендерятся Ansible модулем `template`. Расширение оригинального формата сохраняется после `.template.` ради подсветки синтаксиса в редакторе. - Суффиксы шаблонов: `.template.yml`, `.template.sh`, `.template.cfg`, `.template.conf`, `.template.toml`, `.template` (для файлов без естественного расширения) — рендерятся Ansible модулем `template`. Расширение оригинального формата сохраняется после `.template.` ради подсветки синтаксиса в редакторе.
- Большинство приложений определяют переменные inline в плейбуке. Отдельные файлы переменных только у homepage и transcriber (`vars/homepage.yml`, `vars/transcriber.yml` + `vars/transcriber.images.yml`). - Большинство приложений определяют переменные inline в плейбуке. Отдельные файлы переменных только у homepage и transcriber (`vars/homepage.yml`, `vars/transcriber.yml`).
- Общие переменные из `vars/secrets.yml`: `application_dir`, `bin_prefix`, `primary_user` и др. - `vars_files` в precedence выше `group_vars`: одноимённая переменная в `vars/<app>.yml` молча перебьёт значение из `group_vars`, в том числе из vault.
- Переменную, которую читает больше одного плейбука, кладём в `group_vars/all/main.yml`, а не в `vars/<app>.yml` — так её видят все. Пример: `goatcounter_sites` (плейбук goatcounter заводит по нему сайты, caddyproxy рендерит из него имена в Caddyfile).
- Общие переменные из `group_vars/all/` (`main.yml` + vault-файл `secrets.yml`): `application_dir`, `bin_prefix`, `primary_user` и др. Загружаются автоматически для группы `all`, поэтому плейбуки их не перечисляют. Хост-специфичные значения переопределяются в инвентаре — теперь они имеют приоритет над `group_vars`.
- Каждое приложение: `app_name`, `app_user`, `app_owner_uid`, `app_owner_gid`, `base_dir`, `data_dir`. - Каждое приложение: `app_name`, `app_user`, `app_owner_uid`, `app_owner_gid`, `base_dir`, `data_dir`.
- UID/GID сервисов: новое соглашение — диапазон `11xx`, причём `app_owner_uid == app_owner_gid` (одно число на сервис). Новому приложению берём следующий свободный номер по возрастанию. Старые сервисы ещё сидят на легаси-нумерации `10xx` (часто с разными uid/gid) — их не трогаем, но новые заводим только в `11xx`. - UID/GID сервисов: новое соглашение — диапазон `11xx`, причём `app_owner_uid == app_owner_gid` (одно число на сервис). Новому приложению берём следующий свободный номер по возрастанию. Старые сервисы ещё сидят на легаси-нумерации `10xx` (часто с разными uid/gid) — их не трогаем, но новые заводим только в `11xx`.
## Линтинг и CI ## Линтинг и CI
- CI (`.gitea/workflows/lint.yml`): два параллельных job — yamllint и ansible-lint. - CI (`.gitea/workflows/lint.yml`): два параллельных job — yamllint и ansible-lint.
- Конфиги: `.yamllint.yml` (макс. длина строки 120), `.ansible-lint.yml` (профиль production, offline). - Конфиги: `.yamllint.yml` (макс. длина строки 120), `.ansible-lint.yml` (профиль production, offline),
`[tool.ruff.lint]` и `[tool.pyrefly]` в `pyproject.toml`.
- Набор правил ruff расширен относительно дефолтного (ANN, PTH, ERA, PT, C90, RET, N, Q, TID, G, LOG,
FURB, PLC) и синхронизирован с остальными репозиториями — канон настройки лежит в `rp-local-env`.
- Pre-commit хуки через lefthook: - Pre-commit хуки через lefthook:
- `ruff format` + `ruff check` — форматирование и линтинг Python. - `ruff format` + `ruff check --fix` + `ruff check` — форматирование и линтинг Python.
- `mypy` — проверка типов Python. - `pyrefly` — проверка типов Python (заменил mypy).
- `yamllint` — линтинг YAML. - `yamllint` — линтинг YAML.
- `ansible-lint` — линтинг Ansible (профиль production). - `ansible-lint` — линтинг Ansible (профиль production).
- `gitleaks` — поиск секретов в staged-файлах. - `gitleaks` — поиск секретов в staged-файлах.
- Проверка что секретные файлы зашифрованы vault. - Проверка что секретные файлы зашифрованы vault.
## Конвенции
Договорённости о том, как делать однотипные вещи, живут в [`docs/conventions/`](docs/conventions) — одна конвенция на файл, у каждой статус (рекомендуемая / обязательная) и честный список уже существующих отступлений. Это правила на будущее, в отличие от [`docs/adr/`](docs/adr) (однажды принятые решения, постфактум и неизменяемо) и [`docs/drafts/`](docs/drafts) (черновики и хроника). Перед тем как заводить новое приложение или директорию — заглянуть туда.
- [Категории директорий приложения](docs/conventions/app-directories.md) — содержимое `base_dir` делится на конфигурацию (восстанавливается плейбуком, бэкап не нужен), данные (создаёт приложение, бэкапить обязательно) и кеш (создаёт приложение, перегенерирует само). Из категорий механически выводится `backup-targets`.
## Соглашения по коду ## Соглашения по коду
- Отступы: 2 пробела для YAML/Jinja, 4 пробела в остальных файлах (`.editorconfig`). - Отступы: 2 пробела для YAML/Jinja, 4 пробела в остальных файлах (`.editorconfig`).
@@ -139,3 +152,7 @@ ansible-playbook -i production.yml --diff playbook-gitea.yml
- Шаблоны скриптов бэкапов в `files/<app>/` (backup.template.sh, gobackup.template.yml и др.). - Шаблоны скриптов бэкапов в `files/<app>/` (backup.template.sh, gobackup.template.yml и др.).
- `files/backups/backup-all.py` — оркестратор, запускает все бэкапы через restic. - `files/backups/backup-all.py` — оркестратор, запускает все бэкапы через restic.
- Cron-расписание настраивается в `playbook-backups.yml`. - Cron-расписание настраивается в `playbook-backups.yml`.
- Уведомление включает список всех найденных приложений со значком статуса (✅ забекаплено,
❌ упал скрипт дампа, ⏭ бекапить нечего) и занятым местом, а в конце — свободное место
на дисках. Размеры считает `dust` (ставится ролью eget); если его нет, прогон продолжается
без размеров.
+3 -3
View File
@@ -31,7 +31,7 @@ uv run ansible-galaxy install --role-file requirements.yml
- Безопасность осуществляется с помощью `ufw` и `fail2ban`. - Безопасность осуществляется с помощью `ufw` и `fail2ban`.
- Докер используется для запуска и изоляции приложений. Для загрузки образов настраивается Yandex Docker Registry. - Докер используется для запуска и изоляции приложений. Для загрузки образов настраивается Yandex Docker Registry.
- Выход во внешнюю сеть через proxy server [Caddy](https://caddyserver.com/). - Выход во внешнюю сеть через proxy server [Caddy](https://caddyserver.com/).
- Чувствительные данные в [secrets.yml](vars/secrets.yml) зашифрованы с помощью Ansible Vault. - Чувствительные данные в [secrets.yml](group_vars/all/secrets.yml) зашифрованы с помощью Ansible Vault.
- Для мониторинга за сервером устанавливается [netdata](https://github.com/netdata/netdata). - Для мониторинга за сервером устанавливается [netdata](https://github.com/netdata/netdata).
## Настройка DNS ## Настройка DNS
@@ -43,7 +43,7 @@ uv run ansible-galaxy install --role-file requirements.yml
Деплой приложения через ansible: Деплой приложения через ansible:
```bash ```bash
uv run ansible-playbook ansible-playbook -i timeweb.yml --diff playbook-gitea.yml uv run ansible-playbook -i production.yml --diff playbook-gitea.yml
``` ```
Или через таску invoke: Или через таску invoke:
@@ -55,5 +55,5 @@ uv run ansible-playbook ansible-playbook -i timeweb.yml --diff playbook-gitea.ym
## Удаление приложения <name> ## Удаление приложения <name>
```bash ```bash
uv run ansible-playbook -i timeweb.yml --diff playbook-remove-user-and-app.yml --extra-vars user_name=<name> uv run ansible-playbook -i production.yml --diff playbook-remove-user-and-app.yml --extra-vars user_name=<name>
``` ```
+6
View File
@@ -1,4 +1,10 @@
[defaults] [defaults]
inventory = ./production.yml
host_key_checking = True host_key_checking = True
vault_password_file = ./ansible-vault-password-file vault_password_file = ./ansible-vault-password-file
roles_path = ./galaxy.roles roles_path = ./galaxy.roles
# Выполнять handlers даже если play упал: иначе упавший прогон теряет
# накопленные notify, а на следующем конфиг уже не changed — и рестарт,
# применяющий правку, не случится никогда.
force_handlers = True
@@ -0,0 +1,78 @@
# Локальная сборка образов вместо Yandex Container Registry
- Дата: 2026-07-25
## Контекст
Три приложения собственной разработки — homepage, transcriber, remembos —
деплоились через Yandex Container Registry. Схема у всех одинаковая: образ
собирается локально, пушится в `cr.yandex/...` отдельным плейбуком
(`playbook-*-registry.yml`), плейбук деплоя делает `docker_login` с
`yc_oauth_token`, а compose тянет образ по registry-пути с тегом, зашитым в git.
Схема тянула за собой три проблемы.
**Секрет несопоставимого радиуса.** Ради одного `docker login` в vault лежал
OAuth-токен всего аккаунта Yandex Cloud — реестр, виртуалки, биллинг. 24 июля он
вместе с ещё 52 переменными ушёл открытым текстом в транскрипт: цена одной такой
утечки — весь аккаунт, а не один сервис.
**Разнесённый деплой.** Сборка жила в `task deploy` репозитория приложения,
который сам звал `ansible-playbook`. У transcriber этот таск дополнительно правил
`vars/transcriber.images.yml` в чужом репозитории через `yq` и делал там `git
commit` с новым тегом. У remembos registry-плейбука не было вовсе — образ пушился
руками, а в compose стоял тег `v0.2.0`, вбитый в git. У homepage тег был
плавающий `:latest`, то есть ночной `docker image prune` мог подтянуть другое
содержимое без возможности отката.
**Автоматизировать сборку пока нечем.** Стабильного онлайн-раннера для
workflow gitea у нас нет (отдельная задача — раннер on-demand), так что уехать в
CI сборки не могут. Плюс после переезда сервера в Timeweb реестр остался
единственной ниткой, тянущейся в Yandex Cloud.
## Решение
Приложения переведены на общую роль `app_image` (канон —
`~/projects/private/ansible-roles`, в репозитории лежит завендоренная копия в
`roles/`, синхронизация — `inv roles-pull`/`roles-push`). Роль:
1. генерит случайный `BUILD_ID` — он же тег образа;
2. запускает в директории приложения его собственную команду сборки (контракт —
`task image`, `BUILD_ID` приходит через окружение), приложение обязано собрать
полный образ и затегать его `<app_image_name>:$BUILD_ID`;
3. печатает размер архива, везёт его на сервер через `docker save``copy`
`docker load`;
4. отдаёт факт `app_image_tag`, которым compose-шаблон подставляет образ
(`pull_policy: never` — в реестре образа нет).
Два мотива за этим. Первый — **упростить деплой**, пока нет стабильного
онлайн-раннера: теперь `inv pl -- <app>` собирает, доставляет и запускает
приложение одной командой, отдельного ритуала в репозитории приложения не
осталось. Второй — **сократить набор секретов**: реестр и OAuth-токен выпали из
схемы, `yc_oauth_token`, `yc_container_registry` и
`yc_container_registry_repository` удалены из vault (коммит `6a0d786`).
Registry-плейбуки и `vars/transcriber.images.yml` удалены; у remembos заодно
снесены gitea-workflow с goreleaser — они существовали только ради пуша образа в
`cr.yandex`.
## Последствия
- `+` Из деплоя ушли внешний реестр и токен на весь аккаунт облака; в vault на
три переменные меньше.
- `+` Деплой любого из трёх приложений — одна команда `inv pl -- <app>`;
«правка тега в чужом репозитории + коммит» как шаг деплоя исчезла.
- `+` Тег каждого деплоя уникален, плавающего `:latest` больше нет. Откат —
повторный деплой нужного коммита приложения.
- `-` Дедупликации по слоям нет: каждый деплой везёт полный архив. Замеры
`docker save`: homepage — 61 МБ, transcriber — 73 МБ, remembos — 21 МБ. Для
ручного нечастого деплоя размен принят осознанно.
- `-` Для деплоя нужен ноутбук с docker и тулчейнами: с чужой машины
задеплоить нельзя, а `playbook-all-applications.yml` теперь собирает три
образа за прогон.
- `-` На сервере копятся образы от каждого прогона; чистит их ночной
`docker image prune -af` (см. `playbook-system.yml`).
- Контракт для новых приложений: чтобы деплоиться этой ролью, приложение должно
иметь `task image`, тегающий образ из `$BUILD_ID`.
- Осталось: `buckland/playbook-trackers.yml` всё ещё ходит в `cr.yandex` со своей
копией токена в собственном vault — пока он жив, отзывать токен в облаке рано.
+1
View File
@@ -63,6 +63,7 @@
| Дата | Запись | Статус | | Дата | Запись | Статус |
| ---------- | ---------------------------------------------------------------------------------------------- | ------ | | ---------- | ---------------------------------------------------------------------------------------------- | ------ |
| 2026-07-25 | [Локальная сборка образов вместо Yandex Container Registry](ADR-2026-07-25-local-image-build.md) | — |
| 2026-06-22 | [Разнесение restic-операций на фазы под Intelligent Tiering](ADR-2026-06-22-restic-intelligent-tiering-phases.md) | — | | 2026-06-22 | [Разнесение restic-операций на фазы под Intelligent Tiering](ADR-2026-06-22-restic-intelligent-tiering-phases.md) | — |
| 2026-05-23 | [Переезд сервера с Yandex Cloud на Timeweb VPS](ADR-2026-05-23-migrate-to-timeweb.md) | — | | 2026-05-23 | [Переезд сервера с Yandex Cloud на Timeweb VPS](ADR-2026-05-23-migrate-to-timeweb.md) | — |
| 2026-04-04 | [Apprise как шлюз уведомлений](ADR-2026-04-04-apprise-notifications.md) | — | | 2026-04-04 | [Apprise как шлюз уведомлений](ADR-2026-04-04-apprise-notifications.md) | — |
+2
View File
@@ -0,0 +1,2 @@
# Кладбище беклога
- 2026-07-25 `secrets-env-to-file` — Секреты приложений через `*_FILE`, а не inline `environment:`. Причина: разложена по приложениям на secrets-file-outline/-wakapi/-authelia/-gramps/-gitea/-tududi; матрица поддержки уехала в docs/drafts/secrets-file-support.md, wanderer отпал — файловых секретов не умеет. Был приоритет: средний.
+50
View File
@@ -0,0 +1,50 @@
# Беклог
Единый список будущих задач по серверу: то, что уже решили сделать, и идеи,
которые ещё надо обдумать. Это **источник истины по беклогу** — одна задача =
один файл в этом каталоге. Не план реализации: детальные проработки живут в
[`docs/drafts`](../drafts) (беклог даёт сводку и ссылку), а реализованное
переезжает в ADR/спеку и пункт беклога удаляется.
Приоритет — грубая оценка «ценность / стоимость», не обязательство к порядку.
Источники пунктов помечены:
_(ансибл-ревью)_ — ревью плейбуков [`docs/drafts/ansible-review.md`](../drafts/ansible-review.md) (2026-05-25);
_(tududi)_ — перенос из инбокса задач tududi (проект Pet Project Server);
_(sec-ревью)_ — три независимых security-ревью на модели fable (2026-07-13).
Драфты `timeweb.md` и `timeweb-migration-log.md` — исторические логи уже
завершённой миграции в Timeweb (cutover 2026-05-23), в беклог не входят.
## Высокий
- [Изолировать/запаролить сессионный redis Authelia](authelia-redis-isolation.md) — стор сессий SSO в общей сети без пароля; запись в него = hijack SSO всех сервисов _(sec-ревью)_
- [Алерты на проблемные контейнеры](container-alerts.md) — wakapi крутился в restart-loop несколько дней незамеченным; healthcheck в compose + алерты Netdata
- [Никаких секретов в выводе плейбуков](no-secrets-in-playbook-output.md) — `inv pl` всегда с `--diff`, и каждая задача, рендерящая файл с секретом, печатает его в терминал; `no_log` стоит ровно в одном месте из четырнадцати
## Средний
- [Дампы бэкапов без группы `docker`](backup-dump-without-docker-group.md) — у miniflux/gitea/outline группа осталась: их backup.sh дампит через `docker compose exec` из-под самого приложения
- [Захардить мониторинг-стек (netdata / dozzle / goaccess)](harden-monitoring-stack.md) — netdata/dozzle: лишние привилегии + docker.sock + доверие Remote-User в общей сети; socket-proxy + monitoring_network _(sec-ревью)_
- [Закрыть calibre-web forward-auth и проверить дефолтные креды](calibre-forward-auth.md) — домен без forward_auth, защита на встроенной авторизации с дефолтом `admin/admin123` _(sec-ревью)_
- [Включить автообновления безопасности](security-autoupdates.md) — патчи openssh/libssl только вручную; окна в недели между CVE и фиксом _(sec-ревью)_
- [Запретить SSH-вход под root](ssh-disable-root-login.md) — `PermitRootLogin yes` пускает root по ключу; наименьшие привилегии + defense-in-depth (пароль уже off) _(sec-ревью)_
- [Gitea runner on-demand в Yandex Cloud](gitea-runner-on-demand.md) — раннер активен только во время сборки; webhook→Cloud Function стартует ВМ, probe/decide гасят по idle; экономия ~95%
- [Синхронизация общих Ansible-ролей между репозиториями](shared-roles-sync.md) — `owner`/`eget`/`secrets` дублируются между репозиториями и дрейфуют; канон в ansible-shared + rsync-таски invoke
- [Уведомления о размере приложений и заполнении диска](disk-usage-alerts.md) — алерты на заполнение диска и аномальный рост data-томов; сервер ресурсно ограничен _(tududi)_
- [Composable-роль `backup`](ansible-backup-role.md) — бэкап — самый чистый шов для extraction (одинаков у всех, различается только список targets) _(ансибл-ревью)_
- [Вывести из оборота секреты с широкими правами](narrow-secret-scopes.md) — `yc_oauth_token` (весь аккаунт Yandex Cloud) уже выведен; осталось проставить радиус остальным переменным vault и сузить широкие
- [outline: секреты из файлов](secrets-file-outline.md) — умеет generic `<NAME>_FILE` для всех пяти секретов; единственное приложение, где выносится вообще всё, включая пароль БД
- [wakapi: секреты из файлов](secrets-file-wakapi.md) — salt, SMTP и OIDC выносятся либо через generic `_FILE`, либо через `config.yml`; для OIDC второй путь надёжнее из-за бага с `=` в значении
- [authelia: секреты из файлов](secrets-file-authelia.md) — пять полей закрывает `_FILE`, jwks-ключ и client secret — только template-фильтр; заодно четыре client secret можно перевести в хэш и перестать считать секретами
- [gramps: секретный ключ из файла](secrets-file-gramps.md) — entrypoint читает `/app/secret/secret`, если переменная не задана, и этот путь уже смонтирован — самая дешёвая правка из всей серии
## Низкий
- [Мелкий хардненинг контейнеров](container-hardening-nits.md) — `no-new-privileges`, non-root `user:`, homepage off `:latest`, соглашение о `127.0.0.1:` портах _(sec-ревью)_
- [Унифицировать ACL техпанелей Authelia](authelia-acl-unify.md) — rssbridge (SSRF) и dashboard за `one_factor` без subject; свести к `group:admins` + `two_factor` _(sec-ревью)_
- [Инвентарь: `host_vars`, именованные группы, точечный `become`](ansible-inventory-hostvars.md) — хост-специфику в `host_vars/server.yml`, хост в именованную группу, глобальный root → точечный become _(ансибл-ревью)_
- [Фоновая зачистка стиля и конфигурации Ansible](ansible-style-nits.md) — sudoers.d, профиль ansible-lint, `ansible.cfg`, кавычки, `cache_valid_time` _(ансибл-ревью)_
- [Logrotate для логов бэкапов](backup-logs-logrotate.md) — логи `backup-all.py`/restic копятся без ротации _(tududi)_
- [[idea] Где хранить пароль ansible-vault](vault-password-storage.md) — пароль открытым текстом рядом с репо, `ansible.cfg` подставляет его в любую ansible-команду; вариант — доставать из KeePassXC
- [gitea: пароль SMTP из файла](secrets-file-gitea.md) — умеет `GITEA__mailer__PASSWD__FILE`, но пишет значение открытым текстом в `app.ini` — выигрыш только против `docker inspect`
- [tududi: секреты в смонтированный `.env`](secrets-file-tududi.md) — файловых секретов не умеет вовсе; единственный путь — смонтировать `.env` под `dotenv`, чтобы значения не попадали в `Config.Env`
+17
View File
@@ -0,0 +1,17 @@
# Composable-роль `backup`
**Приоритет:** средний
Бэкап — самый чистый шов для extraction: `gobackup.yml` + `backup.sh` +
`backup-targets` + интеграция с restic одинаковы у всех сервисов, различается
только список целей. Вынести в роль `backup` с параметром «список targets» — она
не трогает индивидуальность сервиса. Это правильный размер абстракции (как уже
сделанный `owner`), а **не** мега-роль `docker_app` (та отклонена осознанно:
приложения реально разные, catch-all обрастает `when:`). Обкатать на одном
сервисе, затем раскатать.
Полный контекст: [docs/drafts/ansible-review.md](../drafts/ansible-review.md) §1.
Связано: roles/owner (образец), files/<app>/gobackup.template.yml,
files/<app>/backup.template.sh, playbook-backups.yml,
feedback_per_app_playbook_duplication.
@@ -0,0 +1,15 @@
# Инвентарь: `host_vars`, именованные группы, точечный `become`
**Приоритет:** низкий
- **`production.yml`** объявляет хост `server` под `ungrouped:`,
хост-специфичные данные (`ansible_host`, `ansible_user`, `ansible_become`)
вписаны инлайн. Конвенциональнее — `host_vars/server.yml` и хост в именованной
группе, а не в `ungrouped`.
- `ansible_become: true` глобально — всё бежит под root. Для личного сервера
прагматично; точечный `become`/`become_user` ближе к наименьшим привилегиям, но
это низкий приоритет.
Контекст: [docs/drafts/ansible-review.md](../drafts/ansible-review.md) §6.
Связано: production.yml.
+25
View File
@@ -0,0 +1,25 @@
# Фоновая зачистка стиля и конфигурации Ansible
**Приоритет:** низкий
Косметика и мелочи конфигурации, копятся в один проход:
- **sudoers**: `playbook-backups.yml:52-59` правит `/etc/sudoers` через
`lineinfile`. Конвенция — отдельный файл в `/etc/sudoers.d/` (через
`copy`/`template` с `validate: visudo -cf %s`).
- **`.ansible-lint.yml`** содержит только `exclude_paths`, профиль не задан явно,
хотя AGENTS.md заявляет «профиль production». Прописать `profile: production`
либо поправить документацию.
- **`ansible.cfg`** минимален — добавить `stdout_callback = yaml`,
`interpreter_python = auto_silent`, `force_handlers = true` (последнее нужно
вместе с переходом на handlers).
- Несогласованные кавычки и пути (`'directory'` vs `"directory"`,
`src: "./files/..."` vs `src: "files/..."`, одинарные кавычки в
`playbook-all-setup.yml`).
- **`playbook-system.yml:24`** — `apt` без `cache_valid_time`, обновляет кэш
каждый прогон.
Контекст: [docs/drafts/ansible-review.md](../drafts/ansible-review.md) §8.
Связано: playbook-backups.yml, .ansible-lint.yml, ansible.cfg,
playbook-system.yml, playbook-all-setup.yml.
+15
View File
@@ -0,0 +1,15 @@
# Унифицировать ACL техпанелей Authelia
**Приоритет:** низкий
В `files/authelia/configuration.template.yml` `rssbridge` и `start` (dashboard)
закрыты `one_factor` вообще без `subject`, тогда как
netdata/dozzle/goaccess/bifrost/wanderbase/remembos — `two_factor` +
`group:admins`. rssbridge умеет ходить по произвольным URL (SSRF-поверхность) и при
этом живёт за одним фактором, доступен любому пользователю из `users.yml`. Для
одного пользователя риск невелик, но логичнее свести техпанели к `group:admins` +
`two_factor`, а `one_factor` без субъекта оставить только там, где это осознанно.
Из sec-ревью (fable, 2026-07-13).
Связано: files/authelia/configuration.template.yml.
+15
View File
@@ -0,0 +1,15 @@
# Изолировать/запаролить сессионный redis Authelia
**Приоритет:** высокий
`authelia_redis` — стор сессий SSO — живёт в общей `monitoring_network` без пароля
(`files/authelia/docker-compose.template.yml`), а в той же сети сидят transcriber,
gramps_redis, outline_redis/postgres, miniflux_postgres, netdata. Компрометация
любого из них даёт запись в стор сессий Authelia → hijack SSO для всех сервисов.
Вынести пару authelia_app+authelia_redis в собственную internal-сеть (как уже
сделано у gramps/outline/miniflux с их `*_network`) и/или включить `requirepass`;
из `monitoring_network` redis убрать, если netdata его реально не скрейпит.
Из sec-ревью (fable, 2026-07-13).
Связано: files/authelia/docker-compose.template.yml, playbook-docker.yml.
@@ -0,0 +1,33 @@
# Дампы бэкапов без группы `docker` у сервисных пользователей
**Приоритет:** средний
Группу `docker` (= root на хосте) убрали у всех сервисных пользователей, кроме
трёх: `miniflux`, `gitea`, `outline`. Их `backup.sh` делает `docker compose exec`
(pg_dump, `gitea dump`), а `files/backups/backup-all.py` запускает такие скрипты
через `su --login <app_user>`, то есть от имени самого приложения — без членства
в группе дамп упадёт.
Дампить снаружи докера нельзя: postgres порт не публикует, `gitea dump` живёт
только внутри контейнера.
Варианты, как развязать:
- **A. sudo-обёртка на дамп.** Плейбук кладёт `/usr/local/sbin/backup-<app>-dump.sh`
(root:root, 0755 — сам app_user его не перепишет) с единственным
`docker compose exec …`, в sudoers — `<app_user> ALL=(root) NOPASSWD:` ровно на
этот файл, а `backup.sh` дёргает его через `sudo`. Пользователь получает право
на одну фиксированную команду вместо права на весь докер. Механизм в проекте
уже обкатан: так `playbook-backups.yml` разрешает `primary_user` запускать
`backup-all.py`.
- **B. root-скрипты в оркестраторе.** Новый контракт в `backup-all.py`: если у
приложения есть `backup-root.sh` с владельцем root — запускать его без `su`.
Механизм один на всех, но от root пойдёт скрипт целиком, включая
`keep-files.py`, и дампы станут root-овыми файлами в директориях приложения.
- **C. оставить как есть** — три аккаунта как осознанное исключение,
задокументированное в плейбуках.
Предпочтение на момент записи — A.
Связано: playbook-miniflux.yml, playbook-gitea.yml, playbook-outline.yml,
files/*/backup.template.sh, files/backups/backup-all.py.
+13
View File
@@ -0,0 +1,13 @@
# Logrotate для логов бэкапов
**Приоритет:** низкий
Логи бэкапов на сервере (вывод `backup-all.py` / restic) копятся без ротации —
со временем растут неограниченно. Настроить logrotate (отдельный файл в
`/etc/logrotate.d/` через `copy`/`template`) с разумным retention и сжатием.
Завести в `playbook-backups.yml` рядом с настройкой самих бэкапов.
Из tududi (проект Pet Project Server).
Связано: playbook-backups.yml, files/backups/backup-all.py,
project_restic_intelligent_tiering.
+17
View File
@@ -0,0 +1,17 @@
# Закрыть calibre-web forward-auth и проверить дефолтные креды
**Приоритет:** средний
`calibre.vakhrushev.me` проксируется на `calibre_web_app:8083` без блока
`forward_auth` (`files/caddyproxy/Caddyfile.template`), правила на этот домен нет и
в ACL Authelia — защита целиком на встроенной аутентификации calibre-web, которая
из коробки идёт с `admin`/`admin123`. Если пароль не сменён вручную, панель
управления библиотекой доступна в интернете с дефолтными кредами. Либо закрыть
домен `forward_auth` + правилом `group:admins`, либо подтвердить смену пароля
админа. Заодно перепроверить memos — не отключена ли публичная регистрация
первым-админом (`files/memos/docker-compose.template.yml`).
Из sec-ревью (fable, 2026-07-13).
Связано: files/caddyproxy/Caddyfile.template, files/authelia/configuration.template.yml,
files/calibre, files/memos.
+17
View File
@@ -0,0 +1,17 @@
# Алерты на проблемные контейнеры
**Приоритет:** высокий
wakapi однажды упал на миграциях и несколько дней крутился в restart-loop —
никто не узнал. Docker считал контейнер «running», пока процесс жив. Нужны два
слоя: (1) healthcheck + `start_period` в compose-шаблонах, чтобы Docker видел
реальное состояние (окно `start_period` даёт миграциям отработать); (2) алерты
через Netdata на restart-loop (счётчик перезапусков растёт) и на
`container_health_status != healthy` дольше M минут, канал нотификаций — один
(Telegram/ntfy — выбрать). restart policy оставляем `unless-stopped` (алерт +
ручное решение вместо `on-failure`, который не встаёт после ребута). Опционально
позже — Uptime Kuma для внешнего HTTP-чека по публичным URL.
Полный план: [docs/drafts/alerts.md](../drafts/alerts.md).
Связано: playbook-netdata.yml, compose-шаблоны всех сервисов, project_server_specs.
+21
View File
@@ -0,0 +1,21 @@
# Мелкий хардненинг контейнеров
**Приоритет:** низкий
- **`no-new-privileges`**: ни один compose-шаблон не задаёт `security_opt:
["no-new-privileges:true"]`. Добавить всем — одна строка, режет эскалацию через
setuid-бинарники в образах.
- **non-root `user:`**: без него работают caddyproxy, gramps (данные в
`/root/.gramps`), rssbridge, dozzle, homepage и valkey/postgres-сайдкары gramps.
Дотянуть там, где образ позволяет (rssbridge, valkey) — продолжение уже принятой
в проекте практики.
- ~~**homepage на `:latest`**~~ — **снято**: переход на роль `app_image` дал
каждому деплою уникальный тег из `BUILD_ID`, плавающего `latest` больше нет
ни у homepage, ни у остальных.
- **Соглашение о портах**: зафиксировать в AGENTS.md, что новые `ports:` — только
с `127.0.0.1:`. Docker публикует порты мимо UFW, поэтому дисциплина
localhost-биндинга — единственный контроль (сейчас соблюдается: apprise, wanderer).
Из sec-ревью (fable, 2026-07-13).
Связано: все files/*/docker-compose*.template.yml, vars/homepage.yml, AGENTS.md.
+18
View File
@@ -0,0 +1,18 @@
# Уведомления о размере приложений и заполнении диска
**Приоритет:** средний
Сервер ресурсно ограничен (диск небольшой, RAM ~80% на штатной нагрузке —
`project_server_specs`), а рост данных сейчас никак не сигнализируется: узнаёшь,
только когда место кончилось. Нужны алерты на (1) заполнение диска выше порога
(общий том + data-тома приложений) и (2) аномальный рост размера отдельного
приложения (лог/БД/кэш распух). Реализация — через Netdata (disk space collector
+ алерты в `health.d/*.conf`), тот же канал нотификаций, что и у алертов на
контейнеры. Пороги калибровать под фактический объём диска.
Отдельный сигнал от [container-alerts](container-alerts.md) (там — здоровье
контейнеров, здесь — ёмкость). Канал нотификаций общий.
Из tududi (проект Pet Project Server).
Связано: playbook-netdata.yml, container-alerts, project_server_specs.
+20
View File
@@ -0,0 +1,20 @@
# Gitea runner on-demand в Yandex Cloud
**Приоритет:** средний
Self-hosted раннер Gitea Actions, активный только во время сборки. Сборок ~10/нед
по ~5 мин — ВМ 24/7 даёт утилизацию ~1% (≈$23/мес), on-demand — ≈$1/мес (экономия
~95%). Архитектура: push → Gitea webhook → Cloud Function (HMAC-валидация +
стейт-машина старта) → Compute API стартует ВМ → `act_runner` в docker забирает
джобу; на ВМ probe (телеметрия раз в 30с) + decide (решение раз в 1 мин) гасят ВМ
после idle-окна через Compute REST из metadata-токена. Три слоя страховки от
зависшей ВМ (soft idle-stop, probe-staleness, внешний алерт Cloud Monitoring на
uptime > 24ч). Плейбук `playbook-gitea-runner.yml` + набор invoke-тасков
(`runner-bootstrap`, `runner-deploy-function`, `runner-pl`, ...).
Дизайн проработан целиком (ресурсы YC, секреты, стоимость, принятые риски, план
внедрения из 11 шагов). Открытые вопросы: канал нотификаций, executor (docker),
webhook на PR. Полный дизайн:
[docs/drafts/gitea-runner-on-demand.md](../drafts/gitea-runner-on-demand.md).
Связано: playbook-gitea.yml, roles/owner, vars/secrets.yml, tasks.py.
+25
View File
@@ -0,0 +1,25 @@
# Захардить мониторинг-стек (netdata / dozzle / goaccess)
**Приоритет:** средний
Мониторинг-контейнеры — самые привилегированные на хосте и при этом торчат в общую
`web_proxy_network` рядом с приложениями:
- **netdata** (`files/netdata/docker-compose.template.yml`) — `SYS_ADMIN` +
`SYS_PTRACE` + `apparmor:unconfined`, плюс весь `/` и docker.sock: фактически
root-контейнер. Для метрик обычно хватает `SYS_PTRACE`; `SYS_ADMIN` и
`apparmor:unconfined` попробовать убрать (netdata деградирует мягко).
- **dozzle** (`files/dozzle/docker-compose.template.yml`) — RW docker.sock +
`DOZZLE_AUTH_PROVIDER: forward-proxy` (верит заголовку `Remote-User` от кого
угодно). Любой контейнер в `web_proxy_network` может напрямую сходить на
`dozzle_app:8080` с поддельным `Remote-User` и читать логи всех контейнеров (в
логах бывают токены/ссылки).
- docker.sock обоим пустить через `tecnativa/docker-socket-proxy` (только нужные
GET), напрямую не монтировать.
- перенести netdata/dozzle/goaccess в существующую `monitoring_network`
(`playbook-docker.yml`), а Caddy подключить к обеим сетям — уводит их из общей
сети с приложениями и устраняет lateral movement.
Из sec-ревью (fable, 2026-07-13).
Связано: files/netdata, files/dozzle, files/goaccess, files/caddyproxy, playbook-docker.yml.
+42
View File
@@ -0,0 +1,42 @@
# Вывести из оборота секреты с широкими правами
**Приоритет:** Средний
Показательный случай — `yc_oauth_token`: OAuth-токен всего аккаунта Yandex Cloud
(реестр, виртуалки, биллинг) ради одной операции, `docker login` в плейбуках
homepage, transcriber и remembos. 24 июля он вместе с ещё 52 переменными ушёл
открытым текстом в транскрипт: цена одной такой утечки — весь аккаунт, а не один
сервис. Из этого репозитория он уже выведен (см. ниже), но такой перекос радиуса
надо найти и снять у остальных переменных.
Принцип, к которому идём: секрет даёт доступ ровно к тому, ради чего заведён.
Всё, что шире, — либо заменяется на скоупированный доступ (сервисный аккаунт с
минимальными ролями вместо токена аккаунта, отдельный ключ на бакет вместо
общего), либо выводится из оборота совсем, либо риск принимается явно и
записывается.
Ядро задачи уже закрыто: homepage, transcriber и remembos собирают образы
локально ролью `app_image`, `docker_login` из плейбуков ушёл, а `yc_oauth_token`
вместе с `yc_container_registry*` удалён из vault (коммит `6a0d786`).
Дальше — ревизия остального: пройти по `group_vars/all/secrets.yml` и проставить
каждой переменной радиус («один сервис» / «весь аккаунт у провайдера» / «все
бэкапы»). Кандидаты в широкие видны уже по назначению — ключи S3 для restic,
доступ к Postbox, — но разбирать их надо в этой задаче, а не заранее.
Готово, когда у каждой переменной vault записан радиус, а каждая широкая либо
заменена на скоупированную, либо помечена принятым риском с причиной.
Держать в уме: [gitea-runner-on-demand](gitea-runner-on-demand.md) заводит новый
доступ к API Yandex Cloud (старт и остановка ВМ). Если она поедет раньше — сразу
сервисный аккаунт с минимальными ролями, а не переиспользование OAuth-токена,
иначе задача вернётся тем же текстом.
Ротация уже утёкших значений — отдельный сюжет, он ведётся в
`SECRETS-ROTATION.md` (файл вне git). Здесь речь не о смене значения, а о
сокращении того, что это значение открывает.
Связано: group_vars/all/secrets.yml, playbook-homepage.yml,
playbook-transcriber.yml, playbook-remembos.yml,
[vault-password-storage](vault-password-storage.md),
[справка по файловым секретам](../drafts/secrets-file-support.md).
@@ -0,0 +1,93 @@
# Никаких секретов в выводе плейбуков
**Приоритет:** Высокий
`inv pl` всегда запускает `ansible-playbook --diff`, поэтому любая задача
`template`/`copy`, чей целевой файл содержит переменную vault, печатает значение
в терминал — целой строкой, при каждом изменении файла. `no_log: true` стоит
ровно в одном месте на весь репозиторий (`roles/secrets`), и именно поэтому
miniflux деплоится молча, а остальные — нет.
Это тот же класс, что инцидент 24 июля: секрет попадает не в vault-файл, а в
поток вывода, у которого нет никакой модели угроз. Скроллбек терминала,
мультиплексор, лог CI, транскрипт агента — всё это места, куда расшифрованному
значению попадать не должно, и куда оно попадает бесплатно.
## Инвентарь (сверено 2026-07-25)
Задачи без `no_log`, рендерящие файл с секретом:
- **backups** — `config.template.toml`, `restic-shell.template.sh`,
`rclone.template.conf`: пароли репозиториев restic, ключи S3, доступ rclone
- **authelia** — `configuration.template.yml`: jwt-секрет, секрет сессий, ключ
шифрования хранилища, hmac и jwks OIDC, четыре client secret, SMTP; плюс
`users.secrets.yml` — vault-файл копируется как есть, дифф покажет его
расшифрованным
- **transcriber** — `config.secrets.toml`, тоже vault-файл копией
- **bifrost** — `config.template.json`: ключ DeepSeek, ключ шифрования
- **apprise** — `backups.template.cfg`: SMTP, пароль matrix-бота
- **remembos** — `config.template.toml`: токены memos и Telegram
- **outline, tududi, wakapi, wanderer, gramps, gitea** — секреты в
`environment:` docker-compose, см. серию задач `secrets-file-*`
Чисто: miniflux (образец), memos, calibre, tuwunel, dashboard, caddy, netdata,
dozzle, goaccess, rssbridge, homepage.
`docker_login` в homepage, transcriber и remembos с `yc_oauth_token` в аргументах
модуля — снят: приложения собирают образы локально ролью `app_image`, реестра и
токена в плейбуках больше нет.
## Рычаги и их цена
- **`no_log: true`** на задачу — прячет результат целиком, включая дифф. Так уже
сделано в `roles/secrets`. Цена: не видно, что именно изменилось; на отладке
снимать руками.
- **`diff: false`** на задачу — прячет только содержимое диффа, статус `changed`
остаётся виден. Хирургичнее, но не закрывает вывод при падении задачи, поэтому
для файлов, состоящих из одних секретов, слабее.
- **Структурный вынос** — секрет живёт в отдельном файле, который рендерит роль
`secrets` под `no_log`, а основной файл ссылается на путь. Тогда дифф основного
файла остаётся полезным. Это серия `secrets-file-*`, но она про `environment:`
и не покрывает конфиги.
- **Убрать `--diff` из `inv pl`** или выставить глобальный `ANSIBLE_NO_LOG`
режет обзор на всех задачах разом ради десятка проблемных. Не годится.
## Порядок
1. ~~`no_log: true` на весь инвентарь выше~~**сделано** (коммит `b7b1f49`,
2026-07-25): пятнадцать задач-рендеров и три `docker_login`, всего 13
плейбуков. Не под `no_log` осталась одна задача — compose miniflux: он тянет
из vault только `miniflux_postgres_user` и `miniflux_postgres_database`, а это
имя пользователя и имя базы, не секреты (в `secrets_vars` роли `secrets` их
нет). Дифф образцового файла оставлен читаемым сознательно.
2. Где файл смешанный (compose, конфиг authelia) — вернуться и вынести секреты в
отдельные файлы, сняв `no_log` с основного: он нужен ради диффа, который сейчас
и теряется. Что именно умеет каждое приложение — матрица в
[справке](../drafts/secrets-file-support.md), сверенная по исходникам, и
задачи `secrets-file-*` по одной на приложение. Для
authelia (конфиг на тысячу строк, дифф там самый ценный) ответ такой: пять
полей закрываются `AUTHELIA_..._FILE`, оставшиеся два — только
template-фильтром, а четыре `client_secret` штатно хранятся хэшем и тогда
вообще перестают быть секретами. Для tududi и wanderer ответа нет: они не
умеют читать секреты из файлов, и `no_log` для них — не первый шаг, а
единственный.
3. Защита от дрейфа: проверка в lefthook или CI — если шаблон ссылается на
переменную, которой нет в открытых `group_vars`/`vars`, задача, которая его
рендерит, обязана иметь `no_log`. Иначе следующее приложение заведут по
образцу без него. Единственное исключение на сегодня — miniflux (см. шаг 1);
чище не заводить исключение в правиле, а перенести `miniflux_postgres_user` и
`miniflux_postgres_database` из vault в `group_vars/all/main.yml` — секретами
они не являются. Правка в зашифрованном файле, поэтому за пользователем.
Готово, когда первый (создающий все файлы) прогон любого плейбука не печатает ни
одного значения из vault, и это держится проверкой, а не вниманием.
Попутно стоит выяснить, почему молчит правило `no-log-password` в ansible-lint:
похоже, оно смотрит на список модулей с параметром `password`, а не на содержимое
шаблонов — тогда на него в этой задаче полагаться нельзя.
Связано: tasks.py (`inv pl`), roles/secrets (образец), files/backups,
files/authelia, files/bifrost, files/apprise, files/remembos,
files/transcriber/config.secrets.toml,
[vault-password-storage](vault-password-storage.md),
[narrow-secret-scopes](narrow-secret-scopes.md).
+32
View File
@@ -0,0 +1,32 @@
# authelia: секреты из файлов
**Приоритет:** Средний
В `configuration.template.yml` прямыми значениями стоят семь позиций:
jwt-секрет валидации, секрет сессий, ключ шифрования хранилища, hmac-секрет OIDC,
приватный jwks-ключ, четыре `client_secret` и пароль SMTP. Файл на тысячу строк —
дифф по нему самый ценный в репозитории, и именно его сейчас приходится глушить
целиком.
Пять полей закрываются переменными `AUTHELIA_..._FILE`. Оставшиеся два —
`jwks[].key` и `client_secret` клиентов — через `_FILE` невозможны в принципе:
валидатор отвергает ключи, содержащие `[]`. Их покрывает template-фильтр
(`X_AUTHELIA_CONFIG_FILTERS=template` + `{{ secret "путь" }}`). Механизмы
несовместимы в одном поле, поэтому проще выбрать фильтр — он берёт все семь
позиций разом, `_FILE` только пять.
Отдельная и более дешёвая половина задачи: четыре `client_secret` штатно хранятся
**хэшем** `$pbkdf2-sha512$...` — плейнтекст с 4.38 вызывает warning валидатора и
нужен только при `client_secret_jwt`, чего у наших клиентов нет. После перевода
эти поля перестают быть секретами вообще. Генератор уже есть —
`inv authelia-gen-secret-and-hash`, проверка — `inv authelia-validate-config`.
Готово, когда в отрендеренном конфиге не осталось секретов открытым текстом и
`no_log` с задачи снят — дифф конфига снова читаем.
Разбор по каждому полю, версии и ссылки на код:
[справка по файловым секретам](../drafts/secrets-file-support.md).
Из декомпозиции `secrets-env-to-file` (2026-07-25).
Связано: files/authelia, tasks.py (`authelia-*`), roles/secrets,
[no-secrets-in-playbook-output](no-secrets-in-playbook-output.md).
+24
View File
@@ -0,0 +1,24 @@
# gitea: пароль SMTP из файла
**Приоритет:** Низкий
Единственный секрет gitea в compose — пароль SMTP. Механизм есть:
`GITEA__mailer__PASSWD__FILE` работает для любого ключа `app.ini` начиная с 1.20,
у нас 1.27.0.
Приоритет низкий из-за оговорки, которая съедает половину пользы:
`environment-to-ini` **записывает содержимое файла в `app.ini` открытым текстом**,
то есть пароль всё равно оседает на диске в `<data_dir>/gitea/conf/app.ini`. Это
открытая проблема апстрима (#35316), исправления нет. Выигрыш реальный, но
частичный — секрет уходит из `environment:`, `docker inspect` и окружения
процесса, а с диска не уходит.
Готово, когда `docker inspect` контейнера gitea не показывает пароль SMTP. Что он
остался в `app.ini` — известное ограничение, а не незакрытая часть задачи.
Механизм и ссылки на код:
[справка по файловым секретам](../drafts/secrets-file-support.md).
Из декомпозиции `secrets-env-to-file` (2026-07-25).
Связано: files/gitea, roles/secrets,
[no-secrets-in-playbook-output](no-secrets-in-playbook-output.md).
+26
View File
@@ -0,0 +1,26 @@
# gramps: секретный ключ из файла
**Приоритет:** Средний
`GRAMPSWEB_SECRET_KEY` стоит прямым значением в `environment:`. Если переменную
не задавать, entrypoint образа читает ключ из файла `/app/secret/secret` — и этот
путь **уже смонтирован** в нашем compose как `gramps_secret`. То есть правка
сводится к «записать значение в файл и убрать переменную»: самая дешёвая из всей
серии. Entrypoint общий у `gramps_app` и `gramps_celery`, оба читают тот же файл,
расхождения не будет.
Пароль SMTP так не выносится: конфиг читается через `from_prefixed_env`, никакой
обработки `_FILE` нет. Обходной путь существует (Flask исполняет `config.cfg` как
Python, значит значение можно прочитать из файла прямо в нём), но он держится на
детали реализации и нигде не обещан — брать только если решим, что оно того
стоит. Пока пароль SMTP остаётся в env и закрывается `no_log`.
Готово, когда `docker inspect` контейнеров gramps не показывает секретный ключ
приложения.
Механизм и ссылки на код:
[справка по файловым секретам](../drafts/secrets-file-support.md).
Из декомпозиции `secrets-env-to-file` (2026-07-25).
Связано: files/gramps, roles/secrets,
[no-secrets-in-playbook-output](no-secrets-in-playbook-output.md).
+24
View File
@@ -0,0 +1,24 @@
# outline: секреты из файлов
**Приоритет:** Средний
Сейчас в `environment:` стоят прямыми значениями пароль postgres (внутри
`DATABASE_URL`), `SECRET_KEY`, `UTILS_SECRET`, секрет OIDC и пароль SMTP. Outline
`1.9.2` умеет generic-механизм: пустая переменная читается из `<NAME>_FILE`,
поэтому все пять закрываются по образцу miniflux — роль `secrets` + монтирование
каталога `:ro`.
Отдельный шаг внутри задачи — пароль БД: `DATABASE_URL_FILE` выносит **весь** URL
одним куском, и чтобы вынести именно пароль, нужен переход на раздельные
`DATABASE_HOST/PORT/NAME/USER` (они взаимоисключимы с `DATABASE_URL`). Заодно
сайдкар postgres переводится на штатный `POSTGRES_PASSWORD_FILE`.
Готово, когда `docker inspect` контейнеров outline и его postgres не показывает
ни одного секрета, а в compose-шаблоне не осталось jinja-переменных с секретами —
и, как следствие, дифф этого шаблона снова можно смотреть без `no_log`.
Механизм и ссылки на код: [справка по файловым секретам](../drafts/secrets-file-support.md).
Из декомпозиции `secrets-env-to-file` (2026-07-25).
Связано: files/outline, files/miniflux (образец), roles/secrets,
[no-secrets-in-playbook-output](no-secrets-in-playbook-output.md).
+27
View File
@@ -0,0 +1,27 @@
# tududi: секреты в смонтированный `.env`
**Приоритет:** Низкий
Tududi `1.2.4` не умеет файловых секретов вообще: session secret, секрет OIDC,
ключ LLM и пароль SMTP читаются напрямую из `process.env`, конфиг-файла и
CLI-опций нет. Проверено грепом по репозиторию на теге, не по докам.
Единственный доступный путь — не `env_file:` (при нём значения всё равно попадают
в `Config.Env`), а **смонтированный файл**: `backend/app.js` вызывает `dotenv`, а
рабочая директория контейнера — `/app/backend`, значит `.env` можно положить туда
томом. Тогда секреты не видны в `docker inspect`, но остаются в окружении
процесса и в `/proc/<pid>/environ`.
Приоритет низкий именно поэтому: механизм нештатный (держится на том, что
`dotenv` остаётся в prod-зависимостях образа), а закрывает только один вектор из
трёх. Если апстрим когда-нибудь добавит `_FILE` — задача переписывается на него.
Готово, когда `docker inspect` контейнера tududi не показывает ни одного из
четырёх секретов.
Разбор и ссылки на код:
[справка по файловым секретам](../drafts/secrets-file-support.md).
Из декомпозиции `secrets-env-to-file` (2026-07-25).
Связано: files/tududi,
[no-secrets-in-playbook-output](no-secrets-in-playbook-output.md).
+28
View File
@@ -0,0 +1,28 @@
# wakapi: секреты из файлов
**Приоритет:** Средний
Сейчас в `environment:` прямыми значениями стоят salt паролей и пароль SMTP,
а в закомментированном блоке — секрет OIDC. Wakapi `2.17.5` даёт два пути, и
выбрать надо один:
- **generic `_FILE`** — `loadSecretFiles()` разворачивает любую переменную с
таким суффиксом. Ближе к образцу miniflux, меньше движений.
- **`config.yml`**, смонтированный в `/app/config.yml` — покрывает те же три
поля декларативно.
Для OIDC-секрета второй путь надёжнее: `renameEnvVars()` режет значение по
первому `=`, то есть секрет с base64-паддингом через env приедет битым. Там же
при включении OIDC надо поправить имена переменных — в 2.17.5 разбирается
`WAKAPI_OIDC_PROVIDERS_0_*`, а у нас закомментирован устаревший вариант, который
просто молча игнорируется.
Готово, когда `docker inspect` контейнера wakapi не показывает ни salt, ни пароль
SMTP, а дифф шаблона можно смотреть без `no_log`.
Механизм, версии и ссылки на код:
[справка по файловым секретам](../drafts/secrets-file-support.md).
Из декомпозиции `secrets-env-to-file` (2026-07-25).
Связано: files/wakapi, files/miniflux (образец), roles/secrets,
[no-secrets-in-playbook-output](no-secrets-in-playbook-output.md).
+13
View File
@@ -0,0 +1,13 @@
# Включить автообновления безопасности
**Приоритет:** средний
`playbook-system.yml:35``security_autoupdate_enabled: "no"`: хост с открытыми в
интернет 22/80/443/2222 получает патчи только при ручном запуске
`playbook-upgrade.yml`, т.е. окна в недели между публикацией CVE и патчем
openssh/libssl. Соразмерный фикс — включить unattended-upgrades только для
security-репозитория (одна переменная роли), без автоматических перезагрузок.
Из sec-ревью (fable, 2026-07-13).
Связано: playbook-system.yml, galaxy.roles/geerlingguy.security, playbook-upgrade.yml.
+21
View File
@@ -0,0 +1,21 @@
# Синхронизация общих Ansible-ролей между репозиториями
**Приоритет:** средний
Серверы управляются изолированными репозиториями (pet-project-server, buckland,
далее — торрент-бокс). Кастомные роли (`owner`, `eget`, `secrets`) концептуально
общие, но физически продублированы и дрейфуют (`owner` в buckland стал мёртвым
кодом). Решение под pets-подход: канон ролей в `~/projects/private/ansible-shared/`,
каждый репозиторий держит закоммиченную копию (она же «пин версии») и
синхронизируется через rsync-таски invoke — `roles-pull` / `roles-push` /
`roles-status`. Подписка на роли явная и пер-серверная (`SYNCED_ROLES` в
`tasks.py`). Обновление роли на сервере — осознанный коммит с читаемым диффом, не
скрытый сайд-эффект. Предохранитель от дрейфа — предупреждение в lefthook.
Отвергнуты: общий `roles_path`, симлинки, submodule/subtree, galaxy-из-git,
монорепо (все ломают самодостаточность или вводят лишнюю петлю).
Подход обсуждён, реализация не начата. Полный дизайн + план внедрения:
[docs/drafts/shared-roles-sync.md](../drafts/shared-roles-sync.md).
Связано: roles/owner, roles/eget, roles/secrets, tasks.py, lefthook.yml,
project_isolated_repos_shared_roles, project_shared_invoke_command_surface.
+26
View File
@@ -0,0 +1,26 @@
# Запретить SSH-вход под root
**Приоритет:** средний
`playbook-system.yml:34` переопределяет дефолт роли на
`security_ssh_permit_root_login: "yes"``PermitRootLogin yes`. Это управляет
**только** тем, пускать ли root по SSH, и не имеет отношения к паролю: вход по
паролю уже запрещён отдельной директивой `PasswordAuthentication no` (дефолт роли
`security_ssh_password_authentication: "no"`, не переопределён). То есть брутфорса
пароля тут нет — остаётся то, что root **явно разрешён к входу по ключу**.
Почему всё равно стоит закрыть:
- **Наименьшие привилегии.** Ansible ходит под непривилегированным `major` с
`become` (`production.yml`), root-логин для деплоя не нужен вовсе.
- **Defense-in-depth.** Если `PasswordAuthentication` когда-нибудь вернётся в
`yes` (правка, `Match`-блок, чужая роль), `PermitRootLogin yes` тут же откроет
root по паролю. `prohibit-password` держит root key-only независимо.
Рекомендация: поставить `"no"` (root по SSH недоступен, деплой под `major` не
страдает). Если нужен аварийный вход root по ключу — `"prohibit-password"`.
Из sec-ревью (fable, 2026-07-13); мотивировка уточнена после проверки конфига.
Связано: playbook-system.yml, production.yml,
galaxy.roles/geerlingguy.security.
+50
View File
@@ -0,0 +1,50 @@
# [idea] Где хранить пароль ansible-vault
**Приоритет:** Низкий
Пароль vault лежит открытым текстом в `./ansible-vault-password-file`
`.gitignore`, есть `.dist`-заглушка), а `ansible.cfg:4` объявляет
`vault_password_file` глобально — значит расшифровка происходит неявно в **любой**
ansible-команде, не только в деплое. Из-за этой связки 24 июля
`ansible-inventory --list` вывалил в транскрипт 53 из 64 vault-переменных
открытым текстом.
Зацепка: если файл из `vault_password_file` исполняемый, ansible запускает его и
берёт пароль из stdout. То есть пароль можно не хранить на диске, а доставать из
KeePassXC — `ansible.cfg` при этом не меняется, достаточно `chmod +x`. Сам скрипт
секрета не содержит, его можно закоммитить и выкинуть связку `.gitignore` +
`.dist`.
Два способа достать, оба проверены на наличие в системе:
- **Secret Service** — `secret-tool lookup` к разблокированной базе по D-Bus.
Требует `libsecret-tools` (не установлен) и включённой в KeePassXC интеграции с
экспортом отдельной группы. Деплой без запросов пароля, при заблокированной базе
— fail-closed.
- **`keepassxc-cli show -s -a Password <db> <entry>`** — без D-Bus и без новых
пакетов (`keepassxc-cli` установлен), но пароль от базы спрашивается на каждый
вызов; `inv pl` придётся перевести на `pty=True`, как уже сделано в
`authelia-cli` и `vault-edit`.
**Нерешённый компромисс — ради него задача и заведена.** Вводить пароль руками на
каждый деплой неудобно. Но любая автоматизация, которая эту неудобность снимает
(разблокированная база, D-Bus, кэш), ровно так же доступна агенту: он найдёт
скрипт и вызовет его сам. То есть выбор хранилища **не защищает от агента** — от
него защищают только deny-правила в `~/.claude/settings.json`, которые
enforcement-слой применяет снаружи. Что смена хранилища действительно даёт:
пароля больше нет на диске (а значит в снапшотах restic и в Syncthing), и при
заблокированной базе всё падает закрыто для всех.
Прежде чем решать, стоит честно ответить: от чего именно защищаемся — от
случайного дампа, от копии `~` в чужих руках, или от агента. Ответы ведут к разным
вариантам, а третий не решается на этом уровне вообще.
Смежное: серия `secrets-file-*` — тот же вопрос слоем ниже (секреты в
`environment:` контейнеров), разбор механизмов в
[справке](../drafts/secrets-file-support.md). Отдельно не заведена, но обсуждалась,
идея скоупинга: вернуть `group_vars/all/secrets.yml` в `vars_files`, оставив
`main.yml` в `group_vars` — это режет потолок утечки с 53 переменных до ~11, ценой
возврата boilerplate в плейбуки.
Связано: ansible.cfg, tasks.py, group_vars/all/secrets.yml,
ansible-vault-password-file.dist, .gitignore.
+46
View File
@@ -0,0 +1,46 @@
# Конвенции
Склад договорённостей о том, **как делать однотипные вещи** в этом
репозитории. Одна конвенция — один файл.
Конвенция описывает повторяющийся выбор: как называть директории, как
раскладывать данные, как оформлять шаблоны. Она отвечает на вопрос «как
принято», а не «что здесь происходит».
Чем отличается от соседей:
- [`../adr/`](../adr) — **решение**, принятое однажды и постфактум
(«почему выбрали Authelia, а не Keycloak»). Запись неизменяема.
- [`../drafts/`](../drafts) — оперативная хроника и черновики («что
собираюсь сделать»).
- `conventions/`**правило на будущее**, применяемое многократно.
Живой документ: правится, когда договорённость меняется.
## Статус
Каждая конвенция начинается со строки статуса:
- **Рекомендуемая** — так стоит делать в новом коде; существующий код
переезжает по мере касания, отдельной кампанией не переписывается.
- **Обязательная** — нарушение считается ошибкой; по возможности
проверяется линтером или хуком, а не вниманием.
Конвенция без механической проверки держится только на внимании — это
нормально для рекомендуемой и плохо для обязательной.
## Когда заводить
Когда одно и то же решение принимается третий раз и каждый раз чуть
по-другому. Единичный выбор — не конвенция; если он ещё и был спорным,
ему место в ADR.
## Соглашения
- Имя файла — kebab-case, по теме: `app-directories.md`.
- В теле честно перечислены отступления, которые уже есть в коде, —
иначе документ описывает не репозиторий, а пожелание.
## Список
- [Категории директорий приложения](app-directories.md) — конфигурация /
данные / кеш: делим по тому, кто создаёт и что будет при потере.
+73
View File
@@ -0,0 +1,73 @@
# Категории директорий приложения
**Статус:** рекомендуемая.
Директории приложения внутри `base_dir` делим на три категории по
принципу создания и ценности содержимого:
- **конфигурация** — то, что восстанавливается прогоном плейбука, в том
числе секреты;
- **данные** — то, что генерирует приложение и что нужно бэкапить;
- **кеш** — то, что генерирует приложение и что не нужно бэкапить:
приложение перегенерирует заново.
Цель — упростить оперирование данными. Категория сразу отвечает на два
вопроса, которые иначе приходится выяснять по коду приложения: **кто
создаёт** содержимое и **что будет, если его потерять**.
## Категории
| Категория | Директория | Создаёт | Потеря содержимого | Бэкап |
| --- | --- | --- | --- | --- |
| Конфигурация | `config/` | плейбук | `inv pl -- <app>` восстанавливает | не нужен |
| Данные | `data/` | приложение | невосполнима | обязателен |
| Кеш | `cache/` | приложение | приложение перегенерирует | не нужен |
Тест на границе данных и кеша: что будет, если сделать `rm -rf` и
поднять приложение заново. Поднимется само и наверстает — кеш. Не
поднимется или поднимется пустым — данные.
Конфигурацию бэкапить не только не нужно, но и не стоит: там лежат
секреты, а бэкапы уезжают в облако. Источник истины для конфигурации —
репозиторий и vault, а не снапшот restic.
## Практика
- Переменные плейбука — `config_dir`, `data_dir`, `cache_dir` внутри
`base_dir`; директории создаются одной задачей `Create application
internal directories`.
- Файл `backup-targets` выводится из категорий механически: в него идут
данные, не идут конфигурация и кеш.
- Монтирование в контейнер: конфигурацию — `:ro`, где приложение это
позволяет. Данные и кеш — на запись.
- `docker-compose.yml` остаётся в корне `base_dir`: туда смотрит
`project_src` модуля `docker_compose_v2`.
Пример, где категории разведены полностью, — `playbook-gramps.yml`:
`data` (база), `media` (файлы), `cache` (миниатюры и кеш отчётов),
`backups` (дампы). В `backup-targets` попадают первые три без `cache`.
## Отступления, которые уже есть
Конвенция рекомендуемая, поэтому список честный, а не пустой.
- **`backups/`** — четвёртая директория рядом с тремя категориями.
Формально это производное от данных (дамп, который делает gobackup или
`pg_dump`), но именно она уезжает в restic, а сырые данные приложения —
не всегда. Держим отдельно и относим к данным.
- **`media/`** (gramps, outline) — это данные, просто вынесенные в
отдельную директорию: крупные файлы отделены от базы, чтобы их можно
было двигать между дисками независимо.
- **Конфигурация без `config/`** — она есть только у девяти плейбуков из
тридцати трёх, у остальных конфиги лежат прямо в `base_dir` рядом с
`docker-compose.yml`. Переезд по мере касания приложения, отдельной
кампанией не переписываем.
- **Легаси-раскладка `data/` под всё** — у части сервисов внутри `data/`
лежит и то, что по этой конвенции было бы кешем. Разделять имеет смысл
тогда, когда кеш заметен по объёму в бэкапе, а не ради самой схемы.
## Связано
- [ADR-2025-12-07](../adr/ADR-2025-12-07-app-data-on-separate-disk.md) —
почему всё это лежит на отдельном диске в `/mnt/applications`.
- `files/backups/backup-all.py` — оркестратор, читающий `backup-targets`.
+203
View File
@@ -0,0 +1,203 @@
# Файловые секреты: что умеет каждое приложение
Дата: 2026-07-25. Статус: справка по итогам проверки исходников (не план работ).
Проверялись **закреплённые в наших compose-файлах версии**, не `main`. Источник —
код парсинга конфига в репозиториях приложений; доки использовались как
подтверждение, а не как основание. Из этой справки выросли задачи
`secrets-file-*` в беклоге, по одной на приложение.
## Зачем
Секреты стоят прямыми значениями в `environment:` docker-compose. Такие
переменные читает любой член группы `docker`, любой процесс с доступом к
`/proc/<pid>/environ`, и они попадают в `docker inspect`. Цель — там, где
приложение умеет, брать секрет из файла.
Образец — **miniflux**: роль `secrets` кладёт каждую переменную vault в отдельный
файл, каталог монтируется `:ro`, compose ссылается на `*_FILE`
(`files/miniflux/docker-compose.template.yml`).
Отдельная и более важная цель — чтобы секреты не печатались в терминал при
деплое — этой справкой **не** решается: там работает `no_log`, см. задачу
`no-secrets-in-playbook-output`. Для tududi и wanderer `no_log` — единственный
доступный ответ.
## Матрица
| Приложение | Секрет | Файл | Механизм |
|---|---|---|---|
| **outline** `1.9.2` | `SECRET_KEY`, `UTILS_SECRET`, OIDC, SMTP | да | generic `<NAME>_FILE` |
| | пароль postgres | да, с оговоркой | только вместе с переходом на раздельные `DATABASE_*` |
| | пароль сайдкара postgres | да | штатный `POSTGRES_PASSWORD_FILE` |
| **wakapi** `2.17.5` | salt, SMTP, OIDC | да | generic `_FILE` либо `config.yml` |
| **gitea** `1.27.0` | SMTP | да, частично | `GITEA__mailer__PASSWD__FILE`, но значение оседает в `app.ini` |
| **gramps** `26.7.0` | `GRAMPSWEB_SECRET_KEY` | да | файл `/app/secret/secret`, путь уже смонтирован |
| | SMTP | нет | только недокументированный трюк с `config.cfg` |
| **authelia** `4.39.20` | jwt, session, ключ хранилища, hmac OIDC, SMTP | да | `AUTHELIA_..._FILE` |
| | jwks-ключ, `client_secret` × 4 | нет через `_FILE` | только template-фильтр; секреты клиентов лучше хэшировать |
| **tududi** `1.2.4` | все четыре | **нет** | обходной путь — смонтированный `.env` |
| **wanderer** `0.18.3` | ключ meilisearch, ключ pocketbase | **нет** | ничего |
## Подробности по приложениям
### outline `1.9.2`
`server/utils/environment.ts` оборачивает `process.env` в Proxy: если переменная
пуста, читается `<NAME>_FILE` как путь к файлу, содержимое обрезается по
пробелам. Весь `server/env.ts` и плагины читают именно через него, поэтому
механизм покрывает **любую** переменную, включая OIDC-плагин.
- Появилось в v1.7.0 (PR [#11906](https://github.com/outline/outline/pull/11906),
merged 2026-03-30), в v1.9.0 переписано на ленивый Proxy (PR
[#12889](https://github.com/outline/outline/pull/12889)). Проверено бисекцией:
в v1.6.1 механизма нет, в v1.7.0 есть.
- Конвенция описана в
[.env.sample](https://github.com/outline/outline/blob/v1.9.2/.env.sample)
строки 4–18, включая правило приоритета: заданы обе — побеждает прямая
переменная.
- Пароль БД: `DATABASE_URL_FILE` выносит **весь** URL. Чтобы вынести только
пароль, нужны раздельные `DATABASE_HOST/PORT/NAME/USER` + `DATABASE_PASSWORD`,
они взаимоисключимы с `DATABASE_URL` через `@CannotUseWith`
([env.ts L95L143](https://github.com/outline/outline/blob/v1.9.2/server/env.ts)).
- Сайдкар `postgres:16.3` — штатный `POSTGRES_PASSWORD_FILE`.
### wakapi `2.17.5`
Два независимых пути.
- `loadSecretFiles()`
([config.go L829](https://github.com/muety/wakapi/blob/2.17.5/config/config.go))
— generic: проходит по всему окружению, для любой переменной с суффиксом
`_FILE` читает файл, `TrimSpace`, кладёт в базовое имя, файловую снимает.
Заданы обе — процесс падает с «both environment variables are set».
- Порядок в `Load()`: `loadSecretFiles()``renameEnvVars()``configor.Load`,
поэтому `_FILE` работает и для OIDC-переменных в configor-формате.
- История: PR [#679](https://github.com/muety/wakapi/pull/679) (релиз 2.12.1)
сделал это в `entrypoint.sh` для пяти переменных; коммит `a45732b2` (релиз
2.17.2, переход на distroless) перенёс логику в Go и сделал универсальной.
README (L92) перечисляет только три переменные — **список устарел**
относительно кода.
- `config.yml` покрывает все три поля: `security.password_salt`,
`security.oidc[]`, `mail.smtp.password`. Образ кладёт `config.default.yml` в
`/app/config.yml`, туда же монтируется свой. Env перекрывает значения из yaml.
Две ловушки в нашем **закомментированном** OIDC-блоке (не живой баг, но при
включении выстрелит):
1. Имена `WAKAPI_OIDC_PROVIDER_CLIENT_ID` устарели — в 2.17.5 разбирается
`WAKAPI_OIDC_PROVIDERS_(\d+)_([A-Z_]+)`, старые молча игнорируются.
2. `renameEnvVars()` разбирает строку как `strings.Split(e, "=")` и берёт
`parts[1]` — значение обрезается по первому `=`, то есть секрет с
base64-паддингом через env поедет битым.
Оба довода — за `config.yml` для OIDC-секрета.
### gitea `1.27.0`
`GITEA__mailer__PASSWD__FILE=/path` — механизм универсальный, работает для любого
ключа `app.ini`, хвостовой перевод строки обрезается
([config_env.go L100141](https://github.com/go-gitea/gitea/blob/v1.27.0/modules/setting/config_env.go),
PR [#24832](https://github.com/go-gitea/gitea/pull/24832), с 1.20).
Оговорка, которая режет пользу: `environment-to-ini` **записывает содержимое
файла в `app.ini` открытым текстом**, то есть секрет всё равно оседает на
хостовом диске в `<data_dir>/gitea/conf/app.ini`. Открытая проблема апстрима
[#35316](https://github.com/go-gitea/gitea/issues/35316) (ранее #25316), PR нет.
Выигрыш реальный, но частичный: уходит из `environment:`, `docker inspect` и
окружения процесса — не с диска.
### gramps `26.7.0` (gramps-web-api v3.18.0)
Если `GRAMPSWEB_SECRET_KEY` не задана, entrypoint образа читает ключ из файла
`/app/secret/secret` (и генерирует, если файла нет) —
[docker-entrypoint.sh](https://github.com/gramps-project/gramps-web-api/blob/v3.18.0/docker-entrypoint.sh).
**Этот путь у нас уже смонтирован** как `gramps_secret`. Entrypoint общий у
`gramps_app` и `gramps_celery` через YAML merge key, оба читают тот же файл.
SMTP-пароль файлового источника не имеет: конфиг читается через
`app.config.from_prefixed_env(prefix="GRAMPSWEB")`, никакой обработки `_FILE`
([app.py L99121](https://github.com/gramps-project/gramps-web-api/blob/v3.18.0/gramps_webapi/app.py)).
Обходной путь: `Dockerfile` задаёт `GRAMPS_API_CONFIG=/app/config/config.cfg`, а
`from_envvar()` во Flask — это `from_pyfile()`, то есть **config.cfg исполняется
как Python**, и `EMAIL_HOST_PASSWORD = open("/run/secrets/smtp").read().strip()`
сработает. Механизм рабочий, но держится на детали реализации Flask и нигде не
обещан.
### authelia `4.39.20`
`IsSecretKey()` возвращает `false` для любого ключа, содержащего `[]` (элемент
списка), и требует, чтобы ключ оканчивался на `key`/`secret`/`password`/`token`/
`certificate_chain`
([helpers.go](https://github.com/authelia/authelia/blob/v4.39.20/internal/configuration/helpers.go)).
Отсюда деление:
- **Умеют `_FILE`** (5 полей): `identity_validation.reset_password.jwt_secret`,
`session.secret`, `storage.encryption_key`,
`identity_providers.oidc.hmac_secret`, `notifier.smtp.password`.
- **Не умеют** (2 позиции): `identity_providers.oidc.jwks[].key` и
`client_secret` четырёх клиентов — оба содержат `[]`.
Для них есть [template-фильтр](https://www.authelia.com/configuration/methods/files/)
(`X_AUTHELIA_CONFIG_FILTERS=template`, с 4.38): `{{ secret "/path" | nindent 10 }}`.
Механизмы **несовместимы в одном поле** — если задано и `_FILE`, и значение в
конфиге, Authelia падает с `errFmtSecretAlreadyDefined`. Поэтому проще выбрать
один: фильтр покрывает все семь позиций, `_FILE` — только пять.
Отдельно про `client_secret`: штатная форма хранения — PHC-дайджест
`$pbkdf2-sha512$310000$...`, плейнтекст с 4.38 вызывает warning валидатора
(`errFmtOIDCClientInvalidSecretPlainText`). Плейнтекст обязателен только при
`client_secret_jwt` или симметричном шифровании JWT — у наших четырёх клиентов
(miniflux, wakapi, tududi — дефолт; outline — `access_token_signed_response_alg:
none`) ничего такого нет. То есть эти поля можно **перестать считать секретами**:
в конфиге хэш, сам секрет живёт в vault и в конфиге клиентского приложения.
Генератор уже есть — `inv authelia-gen-secret-and-hash`.
### tududi `1.2.4` — не умеет
Все четыре секрета читаются напрямую из `process.env`
([config.js L94, L40](https://github.com/chrisvel/tududi/blob/v1.2.4/backend/config/config.js),
[providerConfig.js L82](https://github.com/chrisvel/tududi/blob/v1.2.4/backend/modules/oidc/providerConfig.js),
[service.js L20](https://github.com/chrisvel/tududi/blob/v1.2.4/backend/modules/ai-assistant/service.js)).
Греп по репозиторию на теге даёт только `DB_FILE` и `FILE_UPLOAD_LIMIT_MB`; ни
`readFileSync` для секретов, ни конфиг-файла, ни CLI-опции нет. Собственная дока
по OIDC советует «consider Docker secrets» — это совет про хранение, читать файлы
приложение не умеет.
Обходной путь: `backend/app.js` вызывает `require('dotenv').config()`, рабочая
директория контейнера — `/app/backend`, значит смонтированный туда `.env`
подхватится. Это не per-secret файлы, но значения не попадают в `Config.Env`
в отличие от `env_file:`, где попадают. `dotenv` не перезаписывает уже
установленные переменные окружения.
### wanderer `0.18.3` — не умеет ничего
- `MEILI_MASTER_KEY` (meilisearch v1.20.0): только CLI `--master-key`, env или
инлайн в `config.toml`; `_FILE` нет
([option.rs](https://github.com/meilisearch/meilisearch/blob/v1.20.0/crates/meilisearch/src/option.rs)).
Апстрим отклонил запрос —
[discussion #201](https://github.com/orgs/meilisearch/discussions/201), 2023.
Клиентская сторона (`wanderer_db`, `wanderer_web`) тоже читает `os.Getenv`.
- `POCKETBASE_ENCRYPTION_KEY` — это **не** штатный механизм pocketbase
(`--encryptionEnv`), а собственная переменная wanderer, читается `os.Getenv` в
четырёх местах
([db/main.go](https://github.com/Flomp/wanderer/blob/v0.18.3/db/main.go)).
ENTRYPOINT в exec-форме, подстановку через шелл не сделать. Найденный в поиске
`POCKETBASE_ENCRYPTION_KEY_FILE` — фича стороннего образа
`adrianmusante/pocketbase`, к `flomp/wanderer-db` отношения не имеет.
Доступен только `env_file:`, где значения всё равно попадают в `Config.Env`, то
есть косметика. Задачи по wanderer не заведено.
## Docker secrets — покрытия не расширяет
`secrets:` в compose вне swarm — это bind-mount файла в `/run/secrets/<name>`, то
есть **тот же способ доставки**, что и `*_FILE`. Приложение, умеющее только
переменную окружения, из `/run/secrets` не прочитает: ограничение в приложении, а
не в доставке. На матрицу выше не влияет.
Что даёт: секрет объявляется на сервис, а не монтируется каталогом. У miniflux
сейчас `{{ secrets_dir }}:/secrets:ro` висит на обоих сервисах, и postgres видит
все шесть файлов, включая секреты OIDC, хотя нужен ему один пароль. Улучшение
поверх образца, а не замена ему. `uid`/`gid`/`mode` вне swarm игнорируются —
права остаются с хост-файла, их ставит роль `secrets`.
+5
View File
@@ -0,0 +1,5 @@
mailtos://{{ postbox_user }}:{{ postbox_pass }}@{{ postbox_host }}:{{ postbox_port }}/?from=notifications@vakhrushev.me&to={{ notifications_email }}
# Пароль бота экранируем дважды: jinja-фильтр urlencode оставляет '/' как есть
# (safe='/'), а в пароле он развалил бы путь URL. После urlencode литеральных
# процентов в строке нет, поэтому замена '/' на %2F безопасна.
matrix://apprise:{{ notifications_matrix_bot_password | urlencode | regex_replace('/', '%2F') }}@tuwunel:6167/{{ notifications_matrix_room }}?msgtype=text
+1 -1
View File
@@ -1,7 +1,7 @@
services: services:
apprise_app: apprise_app:
image: caronc/apprise:v1.3.3 image: caronc/apprise:v1.5.2
container_name: apprise_app container_name: apprise_app
restart: unless-stopped restart: unless-stopped
ports: ports:
-2
View File
@@ -1,2 +0,0 @@
tgram://{{ notifications_tg_bot_token }}/{{ notifications_tg_chat_id }}
mailtos://{{ postbox_user }}:{{ postbox_pass }}@{{ postbox_host }}:{{ postbox_port }}/?from=notifications@vakhrushev.me&to={{ notifications_email }}
+31 -18
View File
@@ -121,7 +121,7 @@ server:
## ##
log: log:
## Level of verbosity for logs: info, debug, trace. ## Level of verbosity for logs: info, debug, trace.
level: "debug" level: "info"
## Format the logs are written as: json, text. ## Format the logs are written as: json, text.
format: "json" format: "json"
@@ -875,16 +875,18 @@ session:
## The inactivity time before the session is reset. If expiration is set to 1h, and this is set to 5m, if the user ## The inactivity time before the session is reset. If expiration is set to 1h, and this is set to 5m, if the user
## does not select the remember me option their session will get destroyed after 1h, or after 5m since the last ## does not select the remember me option their session will get destroyed after 1h, or after 5m since the last
## time Authelia detected user activity. ## time Authelia detected user activity.
# inactivity: '5 minutes' ## Дефолты (5 минут / 1 час) для личного сервера слишком агрессивны: любая пауза в работе
## заставляет логиниться заново. Держим сессию живой неделю простоя.
inactivity: "1 week"
## The time before the session cookie expires and the session is destroyed if remember me IS NOT selected by the ## The time before the session cookie expires and the session is destroyed if remember me IS NOT selected by the
## user. ## user.
# expiration: '1 hour' expiration: "1 month"
## The time before the cookie expires and the session is destroyed if remember me IS selected by the user. Setting ## The time before the cookie expires and the session is destroyed if remember me IS selected by the user. Setting
## this value to -1 disables remember me for this session cookie domain. If allowed and the user uses the remember ## this value to -1 disables remember me for this session cookie domain. If allowed and the user uses the remember
## me checkbox this overrides the expiration option and disables the inactivity option. ## me checkbox this overrides the expiration option and disables the inactivity option.
# remember_me: '1 month' remember_me: "3 months"
## Cookie Session Domain default 'name' value. ## Cookie Session Domain default 'name' value.
# name: 'authelia_session' # name: 'authelia_session'
@@ -1370,13 +1372,17 @@ identity_providers:
## The lifespans configure the expiration for these token types in the duration common syntax. In addition to this ## The lifespans configure the expiration for these token types in the duration common syntax. In addition to this
## syntax the lifespans can be customized per-client. ## syntax the lifespans can be customized per-client.
# lifespans: ## Дефолты: access_token '1 hour', authorize_code '1 minute', id_token '1 hour', refresh_token '90 minutes'.
## Configures the default/fallback lifespan for given token types. This behaviour applies to all clients and all ##
## grant types but you can override this behaviour using the custom lifespans. ## Outline раз в час (после каждого auth.info, если lastSignedInAt старше часа) запускает
# access_token: '1 hour' ## ValidateSSOAccessTask: обновляет access token по refresh token и дёргает userinfo. Если обновить
# authorize_code: '1 minute' ## не удалось — ротирует jwtSecret пользователя и убивает ВСЕ свои сессии, то есть выкидывает на
# id_token: '1 hour' ## повторный вход. С дефолтным refresh token в 90 минут это происходит почти при каждом визите.
# refresh_token: '90 minutes' lifespans:
custom:
outline:
access_token: "1 hour"
refresh_token: "3 months"
## Cross-Origin Resource Sharing (CORS) settings. ## Cross-Origin Resource Sharing (CORS) settings.
# cors: # cors:
@@ -1486,20 +1492,25 @@ identity_providers:
# audience: [] # audience: []
## Scopes this client is allowed to request. ## Scopes this client is allowed to request.
## offline_access нужен, чтобы Authelia выдала refresh token: без него Outline не может
## продлить access token и убивает свою сессию (см. кастомный lifespan 'outline' выше).
scopes: scopes:
- "openid" - "openid"
- "profile" - "profile"
- "email" - "email"
- "offline_access"
## Grant Types configures which grants this client can obtain. ## Grant Types configures which grants this client can obtain.
## It's not recommended to define this unless you know what you're doing. ## It's not recommended to define this unless you know what you're doing.
# grant_types: grant_types:
# - 'authorization_code' - "authorization_code"
- "refresh_token"
## Response Types configures which responses this client can be sent. ## Response Types configures which responses this client can be sent.
## It's not recommended to define this unless you know what you're doing. ## It's not recommended to define this unless you know what you're doing.
# response_types: ## Задан явно: без этого Authelia ругается на scope 'offline_access'.
# - 'code' response_types:
- "code"
## Response Modes configures which response modes this client supports. ## Response Modes configures which response modes this client supports.
# response_modes: # response_modes:
@@ -1512,14 +1523,16 @@ identity_providers:
## The custom lifespan name to use for this client. This must be configured independent of the client before ## The custom lifespan name to use for this client. This must be configured independent of the client before
## utilization. Custom lifespans are reusable similar to authorization policies. ## utilization. Custom lifespans are reusable similar to authorization policies.
# lifespan: '' lifespan: "outline"
## The consent mode controls how consent is obtained. ## The consent mode controls how consent is obtained.
# consent_mode: 'auto' ## 'pre-configured' — согласие запоминается на pre_configured_consent_duration, поэтому при
## повторном входе экран согласия не показывается и редирект проходит незаметно.
consent_mode: "pre-configured"
## This value controls the duration a consent on this client remains remembered when the consent mode is ## This value controls the duration a consent on this client remains remembered when the consent mode is
## configured as 'auto' or 'pre-configured' in the duration common syntax. ## configured as 'auto' or 'pre-configured' in the duration common syntax.
# pre_configured_consent_duration: '1 week' pre_configured_consent_duration: "3 months"
## Requires the use of Pushed Authorization Requests for this client when set to true. ## Requires the use of Pushed Authorization Requests for this client when set to true.
# require_pushed_authorization_requests: false # require_pushed_authorization_requests: false
+366 -136
View File
@@ -11,13 +11,18 @@ restic-операции разнесены на фазы с разной час
- verify -- check --read-data-subset, помесячно (полное покрытие за год). - verify -- check --read-data-subset, помесячно (полное покрытие за год).
Один прогон выполняет фазы строго последовательно, поэтому restic-локи между фазами Один прогон выполняет фазы строго последовательно, поэтому restic-локи между фазами
не конфликтуют. Наложение соседних прогонов предотвращается flock в cron-задаче. не конфликтуют. Наложение соседних прогонов предотвращается flock в cron-задаче.
Размеры приложений считает dust (ставится ролью eget в bin_prefix); если его нет
или он упал, прогон продолжается, а размеры в уведомлении просто не показываются.
""" """
import argparse import argparse
import itertools import itertools
import json
import logging import logging
import os import os
import pwd import pwd
import shutil
import subprocess import subprocess
import sys import sys
import time import time
@@ -25,8 +30,9 @@ import tomllib
from abc import ABC from abc import ABC
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime, timedelta from datetime import datetime, timedelta
from enum import Enum
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional from typing import Any
import requests import requests
from croniter import croniter from croniter import croniter
@@ -41,6 +47,10 @@ BACKUP_TARGETS_FILE = "backup-targets"
# Used when backup-targets file not exists # Used when backup-targets file not exists
BACKUP_DEFAULT_DIR = "backups" BACKUP_DEFAULT_DIR = "backups"
# Утилита подсчёта размеров директорий (github.com/bootandy/dust).
# Ставится ролью eget в bin_prefix, который есть в PATH cron-задачи.
DUST_BIN = "dust"
# Retention policy applied by the `forget` phase on every run. # Retention policy applied by the `forget` phase on every run.
KEEP_DAILY = "90" KEEP_DAILY = "90"
KEEP_MONTHLY = "36" KEEP_MONTHLY = "36"
@@ -72,6 +82,7 @@ logger = logging.getLogger(__name__)
@dataclass @dataclass
class Config: class Config:
host_name: str host_name: str
roots: list[Path]
@dataclass @dataclass
@@ -87,9 +98,9 @@ class MaintenanceOptions:
class Schedule: class Schedule:
"""Расписание обслуживающих фаз: фаза -> cron-выражение.""" """Расписание обслуживающих фаз: фаза -> cron-выражение."""
cron: Dict[str, str] = field(default_factory=dict) cron: dict[str, str] = field(default_factory=dict)
def due_phases(self, now: datetime) -> List[str]: def due_phases(self, now: datetime) -> list[str]:
"""Фазы, которые нужно выполнить в этот прогон, в порядке PHASE_ORDER.""" """Фазы, которые нужно выполнить в этот прогон, в порядке PHASE_ORDER."""
phases = list(ALWAYS_PHASES) phases = list(ALWAYS_PHASES)
for phase in SCHEDULED_PHASES: for phase in SCHEDULED_PHASES:
@@ -114,14 +125,55 @@ class Schedule:
class Application: class Application:
path: Path path: Path
owner: str owner: str
backup_script: Optional[Path] backup_script: Path | None
backup_targets: List[Path] backup_targets: list[Path]
class AppStatus(Enum):
"""Что случилось с приложением в этот прогон."""
DONE = "done"
FAILED = "failed"
SKIPPED = "skipped"
APP_STATUS_ICONS = {
AppStatus.DONE: "",
AppStatus.FAILED: "",
AppStatus.SKIPPED: "",
}
@dataclass
class AppRunResult:
"""Строка приложения в уведомлении: статус бекапа и занятое место."""
name: str
status: AppStatus
size: int | None = None
@dataclass
class DiskUsage:
"""Занятое и свободное место на файловой системе."""
path: Path
total: int
free: int
@property
def used(self) -> int:
return self.total - self.free
@property
def used_percent(self) -> float:
return 100.0 * self.used / self.total if self.total else 0.0
@dataclass @dataclass
class BackupResult: class BackupResult:
success: bool success: bool
error: Optional[str] = None error: str | None = None
@dataclass @dataclass
@@ -129,7 +181,80 @@ class StorageRunResult:
name: str name: str
success: bool success: bool
duration: float duration: float
phases: List[str] phases: list[str]
def format_size(size: int) -> str:
"""Байты в человекочитаемый вид: 4.1 GiB, 512 MiB, 12 KiB."""
value = float(size)
for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
if value < 1024 or unit == "TiB":
precision = 0 if unit == "B" or value >= 100 else 1
return f"{value:.{precision}f} {unit}"
value /= 1024
return f"{value:.1f} TiB"
def measure_app_sizes(paths: list[Path]) -> dict[str, int]:
"""Размеры директорий приложений одним вызовом dust: путь -> байты.
dust с `-o b` печатает размеры строками вида "1052672B", а при нескольких
аргументах заворачивает их в корень "(total)" — разбираем оба случая.
"""
if not paths:
return {}
cmd = [DUST_BIN, "--output-json", "--output-format", "b", "--depth", "0"]
cmd += ["--no-progress", *(str(path) for path in paths)]
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
except (OSError, subprocess.TimeoutExpired) as exc:
logger.warning("Failed to run %s: %s", DUST_BIN, exc)
return {}
if result.returncode != 0:
logger.warning(
"%s exited with code %s: %s", DUST_BIN, result.returncode, result.stderr
)
return {}
try:
tree = json.loads(result.stdout)
except json.JSONDecodeError as exc:
logger.warning("Could not parse %s output: %s", DUST_BIN, exc)
return {}
sizes: dict[str, int] = {}
for node in [tree, *tree.get("children", [])]:
raw_size = str(node.get("size", "")).rstrip("B")
if not raw_size.isdigit():
continue
sizes[str(node.get("name", ""))] = int(raw_size)
return sizes
def collect_disk_usage(paths: list[Path]) -> list[DiskUsage]:
"""Занятое/свободное место по файловым системам, на которых лежат paths.
Пути с одной и той же файловой системы схлопываются: смысла показывать
/mnt/applications дважды нет.
"""
usages: list[DiskUsage] = []
seen_devices: set[int] = set()
for path in paths:
try:
device = path.stat().st_dev
if device in seen_devices:
continue
total, _used, free = shutil.disk_usage(path)
except OSError as exc:
logger.warning("Could not read disk usage for %s: %s", path, exc)
continue
seen_devices.add(device)
usages.append(DiskUsage(path=path, total=total, free=free))
return usages
def format_duration(seconds: float) -> str: def format_duration(seconds: float) -> str:
@@ -149,8 +274,8 @@ class Storage(ABC):
def run( def run(
self, self,
backup_dirs: List[str], backup_dirs: list[str],
phases: List[str], phases: list[str],
maintenance: MaintenanceOptions, maintenance: MaintenanceOptions,
) -> BackupResult: ) -> BackupResult:
"""Run the requested phases against this storage.""" """Run the requested phases against this storage."""
@@ -160,7 +285,7 @@ class Storage(ABC):
class ResticStorage(Storage): class ResticStorage(Storage):
TYPE_NAME = "restic" TYPE_NAME = "restic"
def __init__(self, name: str, params: Dict[str, Any]): def __init__(self, name: str, params: dict[str, Any]) -> None:
self.name = name self.name = name
self.restic_repository = str(params.get("restic_repository", "")) self.restic_repository = str(params.get("restic_repository", ""))
self.restic_password = str(params.get("restic_password", "")) self.restic_password = str(params.get("restic_password", ""))
@@ -170,7 +295,7 @@ class ResticStorage(Storage):
raise ValueError( raise ValueError(
f"'env' must be a table for storage backend ResticStorage: '{self.name}'" f"'env' must be a table for storage backend ResticStorage: '{self.name}'"
) )
self.env: Dict[str, str] = {str(k): str(v) for k, v in env_raw.items()} self.env: dict[str, str] = {str(k): str(v) for k, v in env_raw.items()}
if not self.restic_repository or not self.restic_password: if not self.restic_repository or not self.restic_password:
raise ValueError( raise ValueError(
@@ -179,8 +304,8 @@ class ResticStorage(Storage):
def run( def run(
self, self,
backup_dirs: List[str], backup_dirs: list[str],
phases: List[str], phases: list[str],
maintenance: MaintenanceOptions, maintenance: MaintenanceOptions,
) -> BackupResult: ) -> BackupResult:
try: try:
@@ -191,12 +316,12 @@ class ResticStorage(Storage):
def __build_steps( def __build_steps(
self, self,
backup_dirs: List[str], backup_dirs: list[str],
phases: List[str], phases: list[str],
maintenance: MaintenanceOptions, maintenance: MaintenanceOptions,
) -> List[tuple[str, List[str]]]: ) -> list[tuple[str, list[str]]]:
"""Собрать restic-команды для запрошенных фаз в порядке PHASE_ORDER.""" """Собрать restic-команды для запрошенных фаз в порядке PHASE_ORDER."""
steps: List[tuple[str, List[str]]] = [] steps: list[tuple[str, list[str]]] = []
for phase in PHASE_ORDER: for phase in PHASE_ORDER:
if phase not in phases: if phase not in phases:
@@ -261,8 +386,8 @@ class ResticStorage(Storage):
def __run_internal( def __run_internal(
self, self,
backup_dirs: List[str], backup_dirs: list[str],
phases: List[str], phases: list[str],
maintenance: MaintenanceOptions, maintenance: MaintenanceOptions,
) -> BackupResult: ) -> BackupResult:
logger.info("Starting restic run for storage '%s'", self.name) logger.info("Starting restic run for storage '%s'", self.name)
@@ -283,9 +408,7 @@ class ResticStorage(Storage):
return BackupResult(success=True) return BackupResult(success=True)
def __run_step( def __run_step(self, step: str, cmd: list[str], env: dict[str, str]) -> str | None:
self, step: str, cmd: List[str], env: Dict[str, str]
) -> Optional[str]:
"""Run a single restic command. Return None on success or error text.""" """Run a single restic command. Return None on success or error text."""
result = subprocess.run(cmd, env=env, capture_output=True, text=True) result = subprocess.run(cmd, env=env, capture_output=True, text=True)
@@ -306,7 +429,7 @@ class Notifier(ABC):
class AppriseNotifier(Notifier): class AppriseNotifier(Notifier):
TYPE_NAME = "apprise" TYPE_NAME = "apprise"
def __init__(self, name: str, params: Dict[str, Any]): def __init__(self, name: str, params: dict[str, Any]) -> None:
self.name = name self.name = name
self.api_url = str(params.get("api_url", "")).rstrip("/") self.api_url = str(params.get("api_url", "")).rstrip("/")
self.tag = str(params.get("tag", "")) self.tag = str(params.get("tag", ""))
@@ -329,18 +452,20 @@ class AppriseNotifier(Notifier):
logger.info("Apprise notification sent successfully") logger.info("Apprise notification sent successfully")
else: else:
logger.error( logger.error(
f"Failed to send Apprise notification: {response.status_code} - {response.text}" "Failed to send Apprise notification: %s - %s",
response.status_code,
response.text,
) )
class ApplicationFinder: class ApplicationFinder:
def __init__(self, roots: List[Path]): def __init__(self, roots: list[Path]) -> None:
self.roots = roots self.roots = roots
self.warnings: List[str] = [] self.warnings: list[str] = []
def find_applications(self) -> List[Application]: def find_applications(self) -> list[Application]:
"""Discover all applications with their backup scripts and targets.""" """Discover all applications with their backup scripts and targets."""
applications: List[Application] = [] applications: list[Application] = []
source_dirs = itertools.chain(*(root.iterdir() for root in self.roots)) source_dirs = itertools.chain(*(root.iterdir() for root in self.roots))
for app_dir in source_dirs: for app_dir in source_dirs:
@@ -361,28 +486,27 @@ class ApplicationFinder:
) )
) )
except (KeyError, OSError) as e: except (KeyError, OSError) as e:
logger.warning(f"Could not get owner for {app_dir}: {e}") logger.warning("Could not get owner for %s: %s", app_dir, e)
applications.sort(key=lambda app: app.path.name) applications.sort(key=lambda app: app.path.name)
return applications return applications
def _find_backup_script(self, app_dir: Path) -> Optional[Path]: def _find_backup_script(self, app_dir: Path) -> Path | None:
"""Find executable backup script in application directory.""" """Find executable backup script in application directory."""
for name in ("backup.sh", "backup"): for name in ("backup.sh", "backup"):
script_path = app_dir / name script_path = app_dir / name
if script_path.exists(): if script_path.exists():
if os.access(script_path, os.X_OK): if os.access(script_path, os.X_OK):
return script_path return script_path
else:
logger.warning( logger.warning(
f"Backup script {script_path} exists but is not executable" "Backup script %s exists but is not executable", script_path
) )
return None return None
def _find_backup_targets(self, app_dir: Path) -> List[Path]: def _find_backup_targets(self, app_dir: Path) -> list[Path]:
"""Resolve backup target directories for an application.""" """Resolve backup target directories for an application."""
targets_file = app_dir / BACKUP_TARGETS_FILE targets_file = app_dir / BACKUP_TARGETS_FILE
resolved_targets: List[Path] = [] resolved_targets: list[Path] = []
if targets_file.exists(): if targets_file.exists():
for target_line in self._parse_targets_file(targets_file): for target_line in self._parse_targets_file(targets_file):
@@ -410,9 +534,9 @@ class ApplicationFinder:
return resolved_targets return resolved_targets
def _parse_targets_file(self, targets_file: Path) -> List[str]: def _parse_targets_file(self, targets_file: Path) -> list[str]:
"""Parse backup-targets file, skipping comments and empty lines.""" """Parse backup-targets file, skipping comments and empty lines."""
targets: List[str] = [] targets: list[str] = []
try: try:
for raw_line in targets_file.read_text(encoding="utf-8").splitlines(): for raw_line in targets_file.read_text(encoding="utf-8").splitlines():
line = raw_line.strip() line = raw_line.strip()
@@ -430,75 +554,156 @@ class BackupManager:
def __init__( def __init__(
self, self,
config: Config, config: Config,
storages: List[Storage], storages: list[Storage],
notifiers: List[Notifier], notifiers: list[Notifier],
schedule: Schedule, schedule: Schedule,
maintenance: MaintenanceOptions, maintenance: MaintenanceOptions,
forced_phases: Optional[List[str]] = None, forced_phases: list[str] | None = None,
): ) -> None:
self.errors: List[str] = [] self.errors: list[str] = []
self.warnings: List[str] = [] self.warnings: list[str] = []
self.successful_backups: List[str] = [] self.app_results: list[AppRunResult] = []
self.disk_usages: list[DiskUsage] = []
self.config = config self.config = config
self.storages = storages self.storages = storages
self.notifiers = notifiers self.notifiers = notifiers
self.schedule = schedule self.schedule = schedule
self.maintenance = maintenance self.maintenance = maintenance
self.forced_phases = forced_phases self.forced_phases = forced_phases
self.active_phases: List[str] = [] self.active_phases: list[str] = []
self.archive_duration: float = 0.0 self.archive_duration: float = 0.0
self.storage_results: List[StorageRunResult] = [] self.storage_results: list[StorageRunResult] = []
def run_backup_process(self, applications: List[Application]) -> bool: def run_backup_process(self, applications: list[Application]) -> bool:
"""Main backup process""" """Main backup process"""
logger.info("Starting backup process") logger.info("Starting backup process")
logger.info(f"Found {len(applications)} application directories") logger.info("Found %d application directories", len(applications))
# Какие фазы выполняем в этот прогон: либо принудительно из CLI, либо по расписанию. self.active_phases = self._resolve_phases()
self._run_archive_phase(applications)
backup_dirs = self._collect_backup_dirs(applications)
overall_success = self._run_storages(backup_dirs)
self._collect_usage(applications)
self._send_notification(overall_success)
logger.info("Backup process completed")
if self.errors:
logger.error("Backup completed with %d errors", len(self.errors))
return False
if self.warnings:
logger.warning("Backup completed with %d warnings", len(self.warnings))
return True
logger.info("Backup completed successfully")
return True
def _resolve_phases(self) -> list[str]:
"""Какие фазы выполняем в этот прогон: принудительно из CLI или по расписанию."""
if self.forced_phases is not None: if self.forced_phases is not None:
self.active_phases = self.forced_phases logger.info("Phases (forced): %s", ", ".join(self.forced_phases))
logger.info("Phases (forced): %s", ", ".join(self.active_phases)) return self.forced_phases
else:
self.active_phases = self.schedule.due_phases(datetime.now())
logger.info("Phases (scheduled): %s", ", ".join(self.active_phases))
phases = self.schedule.due_phases(datetime.now())
logger.info("Phases (scheduled): %s", ", ".join(phases))
return phases
def _run_archive_phase(self, applications: list[Application]) -> None:
"""Прогнать скрипты дампов приложений и собрать список того, что уедет в restic.
Фаза нужна только вместе с restic backup: без неё дампы делать некому и незачем.
"""
archive_start = time.monotonic() archive_start = time.monotonic()
# Archive phase (per-app backup scripts) нужна только если будем делать restic backup.
if PHASE_BACKUP in self.active_phases: if PHASE_BACKUP in self.active_phases:
for app in applications: for app in applications:
app_dir = str(app.path) status = self._archive_app(app)
username = app.owner self.app_results.append(AppRunResult(name=app.path.name, status=status))
logger.info(f"Processing backup for app: {app_dir} (user {username})")
if app.backup_script is None:
warning_msg = (
f"No backup script found for app: {app_dir} (user {username})"
)
logger.warning(warning_msg)
self.warnings.append(warning_msg)
continue
self._run_app_backup(str(app.backup_script), app_dir, username)
else: else:
logger.info("Backup phase not active, skipping per-app archive scripts") logger.info("Backup phase not active, skipping per-app archive scripts")
self.app_results = [
AppRunResult(name=app.path.name, status=AppStatus.SKIPPED)
for app in applications
]
self.archive_duration = time.monotonic() - archive_start self.archive_duration = time.monotonic() - archive_start
logger.info( logger.info(
"Archive phase finished in %s", format_duration(self.archive_duration) "Archive phase finished in %s", format_duration(self.archive_duration)
) )
# Collect backup directories from applications def _archive_app(self, app: Application) -> AppStatus:
backup_dirs: List[str] = [] """Обработать одно приложение: сделать дамп, если он предусмотрен."""
app_dir = str(app.path)
username = app.owner
if app.backup_script is None:
if app.backup_targets:
# Приложение без дампа: restic забирает его данные как есть,
# отдельный шаг архивации ему не нужен.
logger.info(
"No backup script for app: %s (user %s), "
"data directories go to restic as is",
app_dir,
username,
)
return AppStatus.DONE
warning_msg = (
f"Nothing to back up for app: {app_dir} (user {username}): "
f"no backup script and no backup targets"
)
logger.warning(warning_msg)
self.warnings.append(warning_msg)
return AppStatus.SKIPPED
logger.info("Processing backup for app: %s (user %s)", app_dir, username)
if not self._run_app_backup(str(app.backup_script), app_dir, username):
return AppStatus.FAILED
# Дамп сделан, но в restic он попадёт только если есть цели бекапа;
# об их отсутствии уже предупредил ApplicationFinder.
return AppStatus.DONE if app.backup_targets else AppStatus.SKIPPED
def _collect_usage(self, applications: list[Application]) -> None:
"""Померить размеры приложений и свободное место на их файловых системах.
Считаем после архивации, чтобы свежие дампы попали в размер, и после
restic: цифры информационные, задерживать из-за них бекап незачем.
"""
usage_start = time.monotonic()
sizes = measure_app_sizes([app.path for app in applications])
by_name = {app.path.name: str(app.path) for app in applications}
for result in self.app_results:
result.size = sizes.get(by_name.get(result.name, ""))
# Корень системы плюс диски, на которых лежат приложения: на сервере это
# разные диски, и место кончается на них независимо.
self.disk_usages = collect_disk_usage([Path("/"), *self.config.roots])
logger.info(
"Usage stats collected in %s",
format_duration(time.monotonic() - usage_start),
)
@staticmethod
def _collect_backup_dirs(applications: list[Application]) -> list[str]:
"""Собрать цели бекапа всех приложений, сохраняя порядок и убирая дубли."""
backup_dirs: list[str] = []
for app in applications: for app in applications:
for target in app.backup_targets: for target in app.backup_targets:
target_str = str(target) target_str = str(target)
if target_str not in backup_dirs: if target_str not in backup_dirs:
backup_dirs.append(target_str) backup_dirs.append(target_str)
logger.info(f"Found backup directories: {backup_dirs}") logger.info("Found backup directories: %s", backup_dirs)
return backup_dirs
def _run_storages(self, backup_dirs: list[str]) -> bool:
"""Прогнать активные фазы по всем хранилищам.
Хранилища независимы: падение одного не отменяет попытку для остальных.
"""
overall_success = True overall_success = True
# Each storage is processed independently: a failure in one storage
# must not prevent the others from being attempted.
for storage in self.storages: for storage in self.storages:
storage_start = time.monotonic() storage_start = time.monotonic()
try: try:
@@ -531,28 +736,14 @@ class BackupManager:
error_msg += f": {backup_result.error}" error_msg += f": {backup_result.error}"
self.errors.append(error_msg) self.errors.append(error_msg)
# Determine overall success
overall_success = overall_success and backup_result.success overall_success = overall_success and backup_result.success
# Send notification return overall_success
self._send_notification(overall_success)
logger.info("Backup process completed")
if self.errors:
logger.error(f"Backup completed with {len(self.errors)} errors")
return False
elif self.warnings:
logger.warning(f"Backup completed with {len(self.warnings)} warnings")
return True
else:
logger.info("Backup completed successfully")
return True
def _run_app_backup(self, script_path: str, app_dir: str, username: str) -> bool: def _run_app_backup(self, script_path: str, app_dir: str, username: str) -> bool:
"""Run backup script as the specified user""" """Run backup script as the specified user"""
try: try:
logger.info(f"Running backup script {script_path} (user {username})") logger.info("Running backup script %s (user %s)", script_path, username)
# Use su to run the script as the user # Use su to run the script as the user
cmd = ["su", "--login", username, "--command", script_path] cmd = ["su", "--login", username, "--command", script_path]
@@ -566,10 +757,8 @@ class BackupManager:
) )
if result.returncode == 0: if result.returncode == 0:
logger.info(f"Backup script for {username} completed successfully") logger.info("Backup script for %s completed successfully", username)
self.successful_backups.append(username)
return True return True
else:
error_msg = f"Backup script {script_path} failed with return code {result.returncode}" error_msg = f"Backup script {script_path} failed with return code {result.returncode}"
if result.stderr: if result.stderr:
error_msg += f": {result.stderr}" error_msg += f": {result.stderr}"
@@ -588,26 +777,56 @@ class BackupManager:
self.errors.append(f"App {username}: {error_msg}") self.errors.append(f"App {username}: {error_msg}")
return False return False
def _render_apps(self) -> str:
"""Список приложений: значок статуса, имя и занятое место."""
if not self.app_results:
return ""
items = ""
for result in self.app_results:
# Размер отсутствует, только если dust не отработал: тогда просто имя.
size = f"{format_size(result.size)}" if result.size is not None else ""
items += f"<li>{APP_STATUS_ICONS[result.status]} {result.name}{size}</li>"
return f"<p>Приложения:</p><ul>{items}</ul>"
def _render_run_stats(self) -> str:
"""Фазы restic и затраченное время."""
phases_text = ", ".join(self.active_phases) if self.active_phases else ""
block = f"<p>🔧 Фазы restic: {phases_text}</p>"
block += f"<p>⏱ Время архивации: {format_duration(self.archive_duration)}</p>"
if self.storage_results:
items = "".join(
f"<li>{'' if r.success else ''} {r.name}: {format_duration(r.duration)}</li>"
for r in self.storage_results
)
block += f"<p>⏱ Время записи в хранилища:</p><ul>{items}</ul>"
return block
def _render_disks(self) -> str:
"""Свободное место на дисках сервера."""
if not self.disk_usages:
return ""
items = "".join(
f"<li>{u.path}: свободно {format_size(u.free)} из {format_size(u.total)}"
f" (занято {u.used_percent:.0f}%)</li>"
for u in self.disk_usages
)
return f"<p>💾 Свободное место:</p><ul>{items}</ul>"
def _send_notification(self, success: bool) -> None: def _send_notification(self, success: bool) -> None:
"""Send notification to Notifiers""" """Send notification to Notifiers"""
host = self.config.host_name host = self.config.host_name
phases_text = ", ".join(self.active_phases) if self.active_phases else ""
if success and not self.errors: if success and not self.errors:
title = f"{host}: бекап успешно завершен" title = f"{host}: бекап успешно завершен"
message = f"<p><b>{host}</b>: бекап успешно завершен!</p>" message = f"<p><b>{host}</b>: бекап успешно завершен!</p>"
if self.successful_backups:
items = "".join(f"<li>{b}</li>" for b in self.successful_backups)
message += f"<p>Успешные бекапы:</p><ul>{items}</ul>"
else: else:
title = f"{host}: бекап завершен с ошибками ({len(self.errors)})" title = f"{host}: бекап завершен с ошибками ({len(self.errors)})"
message = f"<p><b>{host}</b>: бекап завершен с ошибками!</p>" message = f"<p><b>{host}</b>: бекап завершен с ошибками!</p>"
if self.successful_backups: message += self._render_apps()
items = "".join(f"<li>{b}</li>" for b in self.successful_backups)
message += f"<p>✅ Успешные бекапы:</p><ul>{items}</ul>"
if not (success and not self.errors):
if self.warnings: if self.warnings:
items = "".join(f"<li>{w}</li>" for w in self.warnings) items = "".join(f"<li>{w}</li>" for w in self.warnings)
message += f"<p>⚠️ Предупреждения:</p><ul>{items}</ul>" message += f"<p>⚠️ Предупреждения:</p><ul>{items}</ul>"
@@ -616,23 +835,17 @@ class BackupManager:
items = "".join(f"<li>{e}</li>" for e in self.errors) items = "".join(f"<li>{e}</li>" for e in self.errors)
message += f"<p>❌ Ошибки:</p><ul>{items}</ul>" message += f"<p>❌ Ошибки:</p><ul>{items}</ul>"
message += f"<p>🔧 Фазы restic: {phases_text}</p>" message += self._render_run_stats()
message += f"<p>⏱ Время архивации: {format_duration(self.archive_duration)}</p>" message += self._render_disks()
if self.storage_results:
items = "".join(
f"<li>{'' if r.success else ''} {r.name}: {format_duration(r.duration)}</li>"
for r in self.storage_results
)
message += f"<p>⏱ Время записи в хранилища:</p><ul>{items}</ul>"
for notificator in self.notifiers: for notificator in self.notifiers:
try: try:
notificator.send(title, message) notificator.send(title, message)
except Exception as e: except Exception as e:
logger.error(f"Failed to send notification: {str(e)}") logger.error("Failed to send notification: %s", e)
def parse_phases(raw: str) -> List[str]: def parse_phases(raw: str) -> list[str]:
"""Разобрать CLI-список фаз, вернуть их в порядке PHASE_ORDER.""" """Разобрать CLI-список фаз, вернуть их в порядке PHASE_ORDER."""
requested = {p.strip() for p in raw.split(",") if p.strip()} requested = {p.strip() for p in raw.split(",") if p.strip()}
unknown = requested - set(PHASE_ORDER) unknown = requested - set(PHASE_ORDER)
@@ -644,50 +857,40 @@ def parse_phases(raw: str) -> List[str]:
return [p for p in PHASE_ORDER if p in requested] return [p for p in PHASE_ORDER if p in requested]
def initialize( def build_storages(raw_config: dict[str, Any]) -> list[Storage]:
config_path: Path, """Собрать хранилища из секции [storage] конфига."""
forced_phases: Optional[List[str]] = None,
) -> tuple[ApplicationFinder, BackupManager]:
try:
with config_path.open("rb") as config_file:
raw_config = tomllib.load(config_file)
except OSError as e:
logger.error(f"Failed to read config file {config_path}: {e}")
raise
host_name = str(raw_config.get("host_name", "unknown"))
roots_raw = raw_config.get("roots") or []
if not isinstance(roots_raw, list) or not roots_raw:
raise ValueError("roots must be a non-empty list of paths in config.toml")
roots = [Path(root) for root in roots_raw]
storage_raw = raw_config.get("storage") or {} storage_raw = raw_config.get("storage") or {}
storages: List[Storage] = [] storages: list[Storage] = []
for name, params in storage_raw.items(): for name, params in storage_raw.items():
if not isinstance(params, dict): if not isinstance(params, dict):
raise ValueError(f"Storage config for {name} must be a table") raise ValueError(f"Storage config for {name} must be a table")
storage_type = params.get("type", "") if params.get("type", "") == ResticStorage.TYPE_NAME:
if storage_type == ResticStorage.TYPE_NAME:
storages.append(ResticStorage(name, params)) storages.append(ResticStorage(name, params))
if not storages: if not storages:
raise ValueError("At least one storage backend must be configured") raise ValueError("At least one storage backend must be configured")
return storages
def build_notifiers(raw_config: dict[str, Any]) -> list[Notifier]:
"""Собрать нотификаторы из секции [notifier] конфига."""
notifications_raw = raw_config.get("notifier") or {} notifications_raw = raw_config.get("notifier") or {}
notifiers: List[Notifier] = [] notifiers: list[Notifier] = []
for name, params in notifications_raw.items(): for name, params in notifications_raw.items():
if not isinstance(params, dict): if not isinstance(params, dict):
raise ValueError(f"Notificator config for {name} must be a table") raise ValueError(f"Notificator config for {name} must be a table")
notifier_type = params.get("type", "") if params.get("type", "") == AppriseNotifier.TYPE_NAME:
if notifier_type == AppriseNotifier.TYPE_NAME:
notifiers.append(AppriseNotifier(name, params)) notifiers.append(AppriseNotifier(name, params))
if not notifiers: if not notifiers:
raise ValueError("At least one notification backend must be configured") raise ValueError("At least one notification backend must be configured")
return notifiers
def build_schedule(raw_config: dict[str, Any]) -> Schedule:
"""Собрать расписание обслуживающих фаз из секции [schedule] конфига."""
schedule_raw = raw_config.get("schedule") or {} schedule_raw = raw_config.get("schedule") or {}
if not isinstance(schedule_raw, dict): if not isinstance(schedule_raw, dict):
raise ValueError("'schedule' must be a table in config.toml") raise ValueError("'schedule' must be a table in config.toml")
schedule = Schedule( return Schedule(
cron={ cron={
phase: str(schedule_raw[phase]) phase: str(schedule_raw[phase])
for phase in SCHEDULED_PHASES for phase in SCHEDULED_PHASES
@@ -695,11 +898,14 @@ def initialize(
} }
) )
def build_maintenance(raw_config: dict[str, Any]) -> MaintenanceOptions:
"""Собрать параметры обслуживания из секции [maintenance] конфига."""
maintenance_raw = raw_config.get("maintenance") or {} maintenance_raw = raw_config.get("maintenance") or {}
if not isinstance(maintenance_raw, dict): if not isinstance(maintenance_raw, dict):
raise ValueError("'maintenance' must be a table in config.toml") raise ValueError("'maintenance' must be a table in config.toml")
defaults = MaintenanceOptions() defaults = MaintenanceOptions()
maintenance = MaintenanceOptions( return MaintenanceOptions(
verify_subset=str(maintenance_raw.get("verify_subset", defaults.verify_subset)), verify_subset=str(maintenance_raw.get("verify_subset", defaults.verify_subset)),
prune_max_unused=str( prune_max_unused=str(
maintenance_raw.get("prune_max_unused", defaults.prune_max_unused) maintenance_raw.get("prune_max_unused", defaults.prune_max_unused)
@@ -709,7 +915,31 @@ def initialize(
), ),
) )
config = Config(host_name=host_name)
def initialize(
config_path: Path,
forced_phases: list[str] | None = None,
) -> tuple[ApplicationFinder, BackupManager]:
try:
with config_path.open("rb") as config_file:
raw_config = tomllib.load(config_file)
except OSError as e:
logger.error("Failed to read config file %s: %s", config_path, e)
raise
host_name = str(raw_config.get("host_name", "unknown"))
roots_raw = raw_config.get("roots") or []
if not isinstance(roots_raw, list) or not roots_raw:
raise ValueError("roots must be a non-empty list of paths in config.toml")
roots = [Path(root) for root in roots_raw]
storages = build_storages(raw_config)
notifiers = build_notifiers(raw_config)
schedule = build_schedule(raw_config)
maintenance = build_maintenance(raw_config)
config = Config(host_name=host_name, roots=roots)
app_finder = ApplicationFinder(roots) app_finder = ApplicationFinder(roots)
backup_manager = BackupManager( backup_manager = BackupManager(
config=config, config=config,
@@ -752,7 +982,7 @@ def main() -> None:
logger.info("Backup process interrupted by user") logger.info("Backup process interrupted by user")
sys.exit(130) sys.exit(130)
except Exception as e: except Exception as e:
logger.error(f"Unexpected error in backup process: {str(e)}") logger.error("Unexpected error in backup process: %s", e)
sys.exit(1) sys.exit(1)
+1 -1
View File
@@ -44,4 +44,4 @@ RCLONE_CONFIG = "{{ rclone_config_file }}"
[notifier.apprise] [notifier.apprise]
type = "apprise" type = "apprise"
api_url = "{{ apprise_external_url }}" api_url = "{{ apprise_external_url }}"
tag = "server" tag = "backups"
+12 -20
View File
@@ -16,16 +16,21 @@
thinking-mode gating, корректные reasoning-поля для v4-моделей issue #3139): thinking-mode gating, корректные reasoning-поля для v4-моделей issue #3139):
не нужны ни custom_provider_config, ни base_url (дефолт api.deepseek.com). не нужны ни custom_provider_config, ни base_url (дефолт api.deepseek.com).
Роутинг декларативно через governance.routing_rules (требует включённого Роутинг правилами больше не нужен: клиенты шлют имя модели с префиксом
config_store). tududi ходит на drop-in эндпоинт /openai и шлёт ЗАХАРДКОЖЕННОЕ провайдера (tududi deepseek/deepseek-v4-pro на drop-in эндпоинт /openai),
имя модели gpt-4o-mini «голым». Правило ловит его по cel и подменяет на Bifrost резолвит провайдера сам. Раньше здесь жило правило, подменявшее
реальную модель deepseek/deepseek-v4-pro. Фолбэка нет единственный провайдер. захардкоженное у tududi gpt-4o-mini; с tududi 1.3.1 модель задаётся переменной.
Чтобы сменить бэкенд-модель, правим ТОЛЬКО этот файл, конфиг tududi не трогаем.
routing_rules оставлен ПУСТЫМ МАССИВОМ, а не удалён: при source_of_truth=config.json
авторитетно перезаписываются только присутствующие секции, а отсутствующие
оставляют строки в config.db нетронутыми удали ключ, и старое правило
продолжило бы жить в БД (config.go, isGovernanceSectionPresent).
Эти Jinja-комментарии в готовый config.json не попадают (на выходе чистый JSON). #} Эти Jinja-комментарии в готовый config.json не попадают (на выходе чистый JSON). #}
{ {
"$schema": "https://www.getbifrost.ai/schema", "$schema": "https://www.getbifrost.ai/schema",
"encryption_key": {{ bifrost_encryption_key | to_json }}, "encryption_key": {{ bifrost_encryption_key | to_json }},
"source_of_truth": "config.json",
"client": { "client": {
"enable_logging": true "enable_logging": true
}, },
@@ -40,8 +45,7 @@
"config_store": { "config_store": {
"enabled": true, "enabled": true,
"type": "sqlite", "type": "sqlite",
"config": { "path": "/app/store/config.db" }, "config": { "path": "/app/store/config.db" }
"source_of_truth": "config.json"
}, },
"logs_store": { "logs_store": {
"enabled": true, "enabled": true,
@@ -49,18 +53,6 @@
"config": { "path": "/app/store/logs.db" } "config": { "path": "/app/store/logs.db" }
}, },
"governance": { "governance": {
"routing_rules": [ "routing_rules": []
{
"id": "tududi-gpt4omini-to-deepseek-pro",
"name": "Tududi gpt-4o-mini -> DeepSeek v4 Pro",
"enabled": true,
"cel_expression": "model == 'gpt-4o-mini'",
"targets": [
{ "provider": "deepseek", "model": "deepseek-v4-pro", "weight": 1 }
],
"scope": "global",
"priority": 0
}
]
} }
} }
+5 -1
View File
@@ -3,6 +3,10 @@
# Клиенты ходят в него по имени bifrost_app:8080 в общей web_proxy_network и про # Клиенты ходят в него по имени bifrost_app:8080 в общей web_proxy_network и про
# провайдера/модель ничего не знают — роутинг решает config.json. # провайдера/модель ничего не знают — роутинг решает config.json.
# See versions: https://hub.docker.com/r/maximhq/bifrost/tags # See versions: https://hub.docker.com/r/maximhq/bifrost/tags
# Тег образа = GitHub-релиз transports/vX.Y.Z (там же changelog и миграции БД);
# теги 2.1.x в том же списке — это helm-chart, не образ. Конфиг после апдейта
# валидировать по transports/config.schema.json из нужного тега: Bifrost на Go
# молча игнорирует ключ, положенный не в ту секцию, в логах этого не видно.
# #
# В контейнере Bifrost слушает 8080 (дефолт). Наружу host-порт НЕ пробрасываем: # В контейнере Bifrost слушает 8080 (дефолт). Наружу host-порт НЕ пробрасываем:
# веб-UI (Dashboard + Logs) и API доступны только через Caddy # веб-UI (Dashboard + Logs) и API доступны только через Caddy
@@ -15,7 +19,7 @@
services: services:
bifrost_app: bifrost_app:
image: maximhq/bifrost:v1.6.3 # проверь актуальный стабильный тег в releases image: maximhq/bifrost:v1.6.11 # проверь актуальный стабильный тег в releases
container_name: bifrost_app container_name: bifrost_app
restart: unless-stopped restart: unless-stopped
user: "{{ owner_create_result.uid }}:{{ owner_create_result.group }}" user: "{{ owner_create_result.uid }}:{{ owner_create_result.group }}"
+75 -26
View File
@@ -5,21 +5,38 @@
grace_period 15s grace_period 15s
# Admin API слушает только loopback контейнера: на :2019 его видела бы вся # Admin API слушает только loopback контейнера: на :2019 его видела бы вся
# web_proxy_network, и любое приложение могло бы переписать маршрутизацию
# или снять forward_auth. Метрики вынесены в отдельный site-блок ниже, и
# порт у него другой: сайт на :2019 перехватывал бы IPv6-петлю (admin висит
# только на IPv4), и `caddy reload` молча ничего не применял бы.
admin localhost:2019
# Enable metrics in Prometheus format # Enable metrics in Prometheus format
# https://caddyserver.com/docs/metrics # https://caddyserver.com/docs/metrics
metrics metrics
} }
# ------------------------------------------------------------------- # -------------------------------------------------------------------
# Metrics
# -------------------------------------------------------------------
# Prometheus-метрики для netdata (files/netdata/go.d/prometheus.conf).
# Наружу порт не публикуется — доступен только внутри web_proxy_network.
http://:2020 {
metrics /metrics
}
# -------------------------------------------------------------------
# Snippets # Snippets
# ------------------------------------------------------------------- # -------------------------------------------------------------------
# Общие настройки всех сайтов: access-лог и HSTS. # Общие настройки всех сайтов: access-лог и HSTS.
# Mode 644 lets read-only consumers (goaccess and ad-hoc host-side tail) #
# read the file; lumberjack would otherwise default to 0600. # Лог общий для всех сайтов, его разбирает GoAccess. Mode 644 нужен, чтобы
(access_log) { # файл читали потребители на хосте (goaccess и ad-hoc tail): lumberjack иначе
log { # ставит 0600.
(common) {
log {
output file /var/log/caddy/access.log { output file /var/log/caddy/access.log {
mode 644 mode 644
roll_size 100mib roll_size 100mib
@@ -29,6 +46,12 @@
format json format json
} }
# HSTS: браузер, однажды увидевший заголовок, целый год ходит на это имя только
# по https, минуя редирект с http, — закрывает downgrade и SSL-strip.
# Без includeSubDomains: политика по каждому имени своя, а под vakhrushev.me
# есть поддомены не с этого сервера (например photo.vakhrushev.me).
header Strict-Transport-Security "max-age=31536000"
}
# ------------------------------------------------------------------- # -------------------------------------------------------------------
# Applications # Applications
@@ -37,7 +60,7 @@
vakhrushev.me { vakhrushev.me {
tls anwinged@ya.ru tls anwinged@ya.ru
import common import common
# Matrix federation delegation: tells other servers/clients that the # Matrix federation delegation: tells other servers/clients that the
# homeserver for vakhrushev.me lives at matrix.vakhrushev.me. # homeserver for vakhrushev.me lives at matrix.vakhrushev.me.
# https://spec.matrix.org/latest/server-server-api/#server-discovery # https://spec.matrix.org/latest/server-server-api/#server-discovery
@@ -65,7 +88,7 @@ vakhrushev.me {
start.vakhrushev.me { start.vakhrushev.me {
tls anwinged@ya.ru tls anwinged@ya.ru
import common import common
forward_auth authelia_app:9091 { forward_auth authelia_app:9091 {
uri /api/authz/forward-auth uri /api/authz/forward-auth
copy_headers Remote-User Remote-Groups Remote-Email Remote-Name copy_headers Remote-User Remote-Groups Remote-Email Remote-Name
@@ -79,7 +102,7 @@ start.vakhrushev.me {
matrix.vakhrushev.me { matrix.vakhrushev.me {
tls anwinged@ya.ru tls anwinged@ya.ru
import common import common
reverse_proxy { reverse_proxy {
to tuwunel_app:6167 to tuwunel_app:6167
} }
@@ -88,14 +111,14 @@ matrix.vakhrushev.me {
auth.vakhrushev.me { auth.vakhrushev.me {
tls anwinged@ya.ru tls anwinged@ya.ru
import common import common
reverse_proxy authelia_app:9091 reverse_proxy authelia_app:9091
} }
status.vakhrushev.me { status.vakhrushev.me {
tls anwinged@ya.ru tls anwinged@ya.ru
import common import common
forward_auth authelia_app:9091 { forward_auth authelia_app:9091 {
uri /api/authz/forward-auth uri /api/authz/forward-auth
copy_headers Remote-User Remote-Groups Remote-Email Remote-Name copy_headers Remote-User Remote-Groups Remote-Email Remote-Name
@@ -107,7 +130,7 @@ status.vakhrushev.me, :29999 {
git.vakhrushev.me { git.vakhrushev.me {
tls anwinged@ya.ru tls anwinged@ya.ru
import common import common
reverse_proxy { reverse_proxy {
to gitea_app:3000 to gitea_app:3000
} }
@@ -116,7 +139,7 @@ git.vakhrushev.me {
outline.vakhrushev.me { outline.vakhrushev.me {
tls anwinged@ya.ru tls anwinged@ya.ru
import common import common
reverse_proxy { reverse_proxy {
to outline_app:3000 to outline_app:3000
} }
@@ -125,7 +148,7 @@ outline.vakhrushev.me {
gramps.vakhrushev.me { gramps.vakhrushev.me {
tls anwinged@ya.ru tls anwinged@ya.ru
import common import common
reverse_proxy { reverse_proxy {
to gramps_app:5000 to gramps_app:5000
} }
@@ -134,16 +157,25 @@ gramps.vakhrushev.me {
miniflux.vakhrushev.me { miniflux.vakhrushev.me {
tls anwinged@ya.ru tls anwinged@ya.ru
import common import common
# /metrics наружу не отдаём. METRICS_ALLOWED_NETWORKS тут не помогает: за # /metrics наружу не отдаём. METRICS_ALLOWED_NETWORKS тут не помогает: за
# прокси Miniflux видит приватный IP контейнера Caddy, а не адрес клиента.
# Netdata скрейпит miniflux_app:8080/metrics напрямую по докер-сети, мимо Caddy.
handle /metrics {
respond 404
}
handle {
reverse_proxy {
to miniflux_app:8080 to miniflux_app:8080
} }
} }
} }
wakapi.vakhrushev.me { wakapi.vakhrushev.me {
tls anwinged@ya.ru tls anwinged@ya.ru
import common import common
reverse_proxy { reverse_proxy {
to wakapi_app:3000 to wakapi_app:3000
} }
@@ -152,7 +184,7 @@ wakapi.vakhrushev.me {
wanderer.vakhrushev.me { wanderer.vakhrushev.me {
tls anwinged@ya.ru tls anwinged@ya.ru
import common import common
reverse_proxy { reverse_proxy {
to wanderer_web:3000 to wanderer_web:3000
} }
@@ -161,7 +193,7 @@ wanderer.vakhrushev.me {
memos.vakhrushev.me { memos.vakhrushev.me {
tls anwinged@ya.ru tls anwinged@ya.ru
import common import common
reverse_proxy { reverse_proxy {
to memos_app:5230 to memos_app:5230
} }
@@ -172,7 +204,7 @@ memos.vakhrushev.me {
tududi.vakhrushev.me { tududi.vakhrushev.me {
tls anwinged@ya.ru tls anwinged@ya.ru
import common import common
reverse_proxy { reverse_proxy {
to tududi_app:3002 to tududi_app:3002
} }
@@ -181,7 +213,7 @@ tududi.vakhrushev.me {
remembos.vakhrushev.me { remembos.vakhrushev.me {
tls anwinged@ya.ru tls anwinged@ya.ru
import common import common
forward_auth authelia_app:9091 { forward_auth authelia_app:9091 {
uri /api/authz/forward-auth uri /api/authz/forward-auth
copy_headers Remote-User Remote-Groups Remote-Email Remote-Name copy_headers Remote-User Remote-Groups Remote-Email Remote-Name
@@ -195,7 +227,7 @@ remembos.vakhrushev.me {
calibre.vakhrushev.me { calibre.vakhrushev.me {
tls anwinged@ya.ru tls anwinged@ya.ru
import common import common
reverse_proxy { reverse_proxy {
to calibre_web_app:8083 to calibre_web_app:8083
} }
@@ -204,7 +236,7 @@ calibre.vakhrushev.me {
wanderbase.vakhrushev.me { wanderbase.vakhrushev.me {
tls anwinged@ya.ru tls anwinged@ya.ru
import common import common
forward_auth authelia_app:9091 { forward_auth authelia_app:9091 {
uri /api/authz/forward-auth uri /api/authz/forward-auth
copy_headers Remote-User Remote-Groups Remote-Email Remote-Name copy_headers Remote-User Remote-Groups Remote-Email Remote-Name
@@ -218,7 +250,7 @@ wanderbase.vakhrushev.me {
rssbridge.vakhrushev.me { rssbridge.vakhrushev.me {
tls anwinged@ya.ru tls anwinged@ya.ru
import common import common
forward_auth authelia_app:9091 { forward_auth authelia_app:9091 {
uri /api/authz/forward-auth uri /api/authz/forward-auth
copy_headers Remote-User Remote-Groups Remote-Email Remote-Name copy_headers Remote-User Remote-Groups Remote-Email Remote-Name
@@ -232,7 +264,7 @@ rssbridge.vakhrushev.me {
dozzle.vakhrushev.me { dozzle.vakhrushev.me {
tls anwinged@ya.ru tls anwinged@ya.ru
import common import common
forward_auth authelia_app:9091 { forward_auth authelia_app:9091 {
uri /api/authz/forward-auth uri /api/authz/forward-auth
copy_headers Remote-User Remote-Groups Remote-Email Remote-Name Remote-Filter copy_headers Remote-User Remote-Groups Remote-Email Remote-Name Remote-Filter
@@ -242,9 +274,26 @@ dozzle.vakhrushev.me {
} }
# GoatCounter — веб-аналитика посещений. Один инстанс обслуживает несколько # GoatCounter — веб-аналитика посещений. Один инстанс обслуживает несколько
# сайтов и выбирает нужный сам, по заголовку Host, поэтому здесь один блок на
# все имена, а не блок на сайт. Имена рендерятся из goatcounter_sites
# (group_vars/all/main.yml) — того же списка, по которому плейбук goatcounter
# заводит сами сайты, чтобы конфиг прокси и база не разъезжались.
#
# Без forward_auth: эндпоинты /count и /count.js обязаны быть публичными, их
# дёргает трекер с сайта. Дашборд закрыт собственным логином goatcounter.
{{ goatcounter_sites | join(', ') }} {
tls anwinged@ya.ru
import common
reverse_proxy {
to goatcounter_app:8080
}
}
goaccess.vakhrushev.me {
tls anwinged@ya.ru tls anwinged@ya.ru
import common import common
forward_auth authelia_app:9091 { forward_auth authelia_app:9091 {
uri /api/authz/forward-auth uri /api/authz/forward-auth
copy_headers Remote-User Remote-Groups Remote-Email Remote-Name copy_headers Remote-User Remote-Groups Remote-Email Remote-Name
@@ -264,7 +313,7 @@ goaccess.vakhrushev.me {
bifrost.vakhrushev.me { bifrost.vakhrushev.me {
tls anwinged@ya.ru tls anwinged@ya.ru
import common import common
forward_auth authelia_app:9091 { forward_auth authelia_app:9091 {
uri /api/authz/forward-auth uri /api/authz/forward-auth
copy_headers Remote-User Remote-Groups Remote-Email Remote-Name copy_headers Remote-User Remote-Groups Remote-Email Remote-Name
+1 -3
View File
@@ -1,14 +1,12 @@
services: services:
caddyproxy: caddyproxy:
image: caddy:2.11.3 image: caddy:2.11.4
restart: unless-stopped restart: unless-stopped
container_name: "caddyproxy" container_name: "caddyproxy"
ports: ports:
- "80:80" - "80:80"
- "443:443" - "443:443"
- "443:443/udp" - "443:443/udp"
cap_add:
- NET_ADMIN
volumes: volumes:
- "{{ caddy_file_dir }}:/etc/caddy" - "{{ caddy_file_dir }}:/etc/caddy"
- "{{ data_dir }}:/data" - "{{ data_dir }}:/data"
+1 -1
View File
@@ -1,7 +1,7 @@
services: services:
calibre_web_app: calibre_web_app:
image: lscr.io/linuxserver/calibre-web:0.6.26 image: lscr.io/linuxserver/calibre-web:0.6.27
container_name: calibre_web_app container_name: calibre_web_app
restart: unless-stopped restart: unless-stopped
networks: networks:
@@ -85,6 +85,11 @@
description: Веб-аналитика логов Caddy description: Веб-аналитика логов Caddy
icon: goaccess.png icon: goaccess.png
siteMonitor: http://goaccess_app:8080 siteMonitor: http://goaccess_app:8080
- GoatCounter:
href: https://gc-main.vakhrushev.me
description: Аналитика посещений сайтов
icon: mdi-chart-line
siteMonitor: http://goatcounter_app:8080
- Bifrost: - Bifrost:
href: https://bifrost.vakhrushev.me href: https://bifrost.vakhrushev.me
description: LLM-шлюз (DeepSeek) description: LLM-шлюз (DeepSeek)
+3 -2
View File
@@ -7,7 +7,8 @@
services: services:
dashboard_app: dashboard_app:
image: "{{ homepage_image }}" # See versions: https://github.com/gethomepage/homepage/releases
image: ghcr.io/gethomepage/homepage:v2.1.2
container_name: dashboard_app container_name: dashboard_app
restart: unless-stopped restart: unless-stopped
user: "{{ app_owner_uid }}:{{ app_owner_gid }}" user: "{{ app_owner_uid }}:{{ app_owner_gid }}"
@@ -18,7 +19,7 @@ services:
- "/etc/localtime:/etc/localtime:ro" - "/etc/localtime:/etc/localtime:ro"
- "/etc/timezone:/etc/timezone:ro" - "/etc/timezone:/etc/timezone:ro"
environment: environment:
# homepage 1.x требует явный whitelist Host за реверс-прокси, иначе отдаёт # homepage требует явный whitelist Host за реверс-прокси, иначе отдаёт
# «Host validation failed». Пускаем только адрес из Caddy. # «Host validation failed». Пускаем только адрес из Caddy.
- HOMEPAGE_ALLOWED_HOSTS={{ homepage_domain }} - HOMEPAGE_ALLOWED_HOSTS={{ homepage_domain }}
+1 -1
View File
@@ -1,6 +1,6 @@
services: services:
dozzle_app: dozzle_app:
image: amir20/dozzle:v10.6.2 image: amir20/dozzle:v10.7.3
container_name: dozzle_app container_name: dozzle_app
restart: unless-stopped restart: unless-stopped
volumes: volumes:
+4 -1
View File
@@ -1,6 +1,6 @@
services: services:
gitea_app: gitea_app:
image: gitea/gitea:1.26.4 image: gitea/gitea:1.27.2
restart: unless-stopped restart: unless-stopped
container_name: gitea_app container_name: gitea_app
ports: ports:
@@ -17,6 +17,9 @@ services:
- "USER_GID={{ owner_create_result.group }}" - "USER_GID={{ owner_create_result.group }}"
- "GITEA__server__SSH_PORT=2222" - "GITEA__server__SSH_PORT=2222"
# Ветка по умолчанию в новых репозиториях (у Gitea по умолчанию main)
- "GITEA__repository__DEFAULT_BRANCH=master"
# Mailer # Mailer
- "GITEA__mailer__ENABLED=true" - "GITEA__mailer__ENABLED=true"
- "GITEA__mailer__PROTOCOL=smtp+starttls" - "GITEA__mailer__PROTOCOL=smtp+starttls"
+1 -1
View File
@@ -1,4 +1,4 @@
FROM allinurl/goaccess:1.10.2 FROM allinurl/goaccess:1.11
RUN apk add --no-cache jq RUN apk add --no-cache jq
+2 -2
View File
@@ -1,7 +1,7 @@
services: services:
goaccess_processor: goaccess_processor:
build: . build: .
image: local/goaccess-jq:1.10.2 image: local/goaccess-jq:1.11
container_name: goaccess_processor container_name: goaccess_processor
restart: unless-stopped restart: unless-stopped
init: true init: true
@@ -25,7 +25,7 @@ services:
- "web_proxy_network" - "web_proxy_network"
goaccess_app: goaccess_app:
image: caddy:2.11.3 image: caddy:2.11.4
container_name: goaccess_app container_name: goaccess_app
restart: unless-stopped restart: unless-stopped
user: "{{ app_owner_uid }}:{{ app_owner_gid }}" user: "{{ app_owner_uid }}:{{ app_owner_gid }}"
@@ -0,0 +1,45 @@
# GoatCounter — веб-аналитика (github.com/arp242/goatcounter).
# See versions: https://hub.docker.com/r/arp242/goatcounter/tags
#
# Образ ходит от собственного пользователя goatcounter (uid 1000 из alpine),
# поэтому подменяем его на системного владельца смонтированного каталога.
# Бинарник собран с osusergo/netgo и записи в /etc/passwd не требует.
#
# CMD образа — `serve -automigrate`, его не переопределяем: миграции должны
# накатываться сами при обновлении версии. Остальные флаги идут через
# GOATCOUNTER_<FLAG> (дефис в имени флага -> подчёркивание). Неизвестная
# переменная с этим префиксом валит старт, так что новые добавлять только
# сверяясь с `goatcounter help serve`.
#
# Рабочая директория в образе — /home/goatcounter, БД по умолчанию лежит в
# ./goatcounter-data/db.sqlite3, то есть внутри смонтированного data.
services:
goatcounter_app:
image: arp242/goatcounter:2.7.0
container_name: goatcounter_app
restart: unless-stopped
user: "{{ app_owner_uid }}:{{ app_owner_gid }}"
networks:
- "web_proxy_network"
volumes:
- "{{ data_dir }}:/home/goatcounter/goatcounter-data"
environment:
# TLS терминирует Caddy. `http` — не поднимать собственный TLS и не
# ходить за ACME-сертификатом; https goatcounter определяет по
# X-Forwarded-Proto, IP клиента — по X-Forwarded-For (их шлёт Caddy).
- GOATCOUNTER_TLS=http
- GOATCOUNTER_LISTEN=:8080
# Почта (Yandex Cloud Postbox, те же секреты, что у outline/wakapi):
# сброс пароля и еженедельные отчёты. STARTTLS — схема smtp://, а не
# smtps://. Экранированные логин/пароль приходят из плейбука.
- GOATCOUNTER_SMTP=smtp://{{ postbox_user_enc }}:{{ postbox_pass_enc }}@{{ postbox_host }}:{{ postbox_port }}
# Без явного -email-from goatcounter зовёт user.Current() и падает,
# потому что подменённого uid нет в /etc/passwd образа.
- GOATCOUNTER_EMAIL_FROM=goatcounter@vakhrushev.me
networks:
web_proxy_network:
external: true
+1 -1
View File
@@ -2,7 +2,7 @@
services: services:
gramps_app: &gramps_app gramps_app: &gramps_app
image: ghcr.io/gramps-project/grampsweb:26.6.0 image: ghcr.io/gramps-project/grampsweb:26.8.0
container_name: gramps_app container_name: gramps_app
depends_on: depends_on:
- gramps_redis - gramps_redis
+6 -1
View File
@@ -1,10 +1,15 @@
# Образ собирается локально и доставляется на сервер ролью app_image
# (см. playbook-homepage.yml): тег — случайный BUILD_ID каждого прогона,
# в реестре образа нет, поэтому pull_policy: never.
services: services:
homepage_app: homepage_app:
# noinspection ComposeUnknownValues # noinspection ComposeUnknownValues
image: "{{ registry_homepage_nginx_image }}" image: "{{ homepage_image_name }}:{{ app_image_tag }}"
container_name: homepage_app container_name: homepage_app
restart: unless-stopped restart: unless-stopped
pull_policy: never
networks: networks:
- "web_proxy_network" - "web_proxy_network"
+7 -5
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import os
import argparse import argparse
import os
from pathlib import Path
def main() -> None: def main() -> None:
@@ -18,12 +19,13 @@ def main() -> None:
if args.keep < 0: if args.keep < 0:
parser.error("--keep value cannot be negative") parser.error("--keep value cannot be negative")
if not os.path.isdir(args.directory): directory = Path(args.directory)
if not directory.is_dir():
parser.error(f"Directory not found: {args.directory}") parser.error(f"Directory not found: {args.directory}")
# Get list of files (exclude subdirectories) # Get list of files (exclude subdirectories)
files = [] files = []
with os.scandir(args.directory) as entries: with os.scandir(directory) as entries:
for entry in entries: for entry in entries:
if entry.is_file(): if entry.is_file():
files.append(entry.name) files.append(entry.name)
@@ -36,9 +38,9 @@ def main() -> None:
# Delete files and print results # Delete files and print results
for filename in to_delete: for filename in to_delete:
filepath = os.path.join(args.directory, filename) filepath = directory / filename
try: try:
os.remove(filepath) filepath.unlink()
print(f"Deleted: {filename}") print(f"Deleted: {filename}")
except Exception as e: except Exception as e:
print(f"Error deleting {filename}: {str(e)}") print(f"Error deleting {filename}: {str(e)}")
+1 -1
View File
@@ -3,7 +3,7 @@
services: services:
memos_app: memos_app:
image: neosmemo/memos:0.29.1 image: neosmemo/memos:0.30.0
container_name: memos_app container_name: memos_app
restart: unless-stopped restart: unless-stopped
user: "{{ owner_create_result.uid }}:{{ owner_create_result.group }}" user: "{{ owner_create_result.uid }}:{{ owner_create_result.group }}"
+7 -1
View File
@@ -3,7 +3,7 @@
services: services:
miniflux_app: miniflux_app:
image: miniflux/miniflux:2.2.10 image: miniflux/miniflux:2.3.3
container_name: miniflux_app container_name: miniflux_app
user: "{{ owner_create_result.uid }}:{{ owner_create_result.group }}" user: "{{ owner_create_result.uid }}:{{ owner_create_result.group }}"
depends_on: depends_on:
@@ -22,6 +22,9 @@ services:
- ADMIN_USERNAME_FILE=/secrets/miniflux_admin_user - ADMIN_USERNAME_FILE=/secrets/miniflux_admin_user
- ADMIN_PASSWORD_FILE=/secrets/miniflux_admin_password - ADMIN_PASSWORD_FILE=/secrets/miniflux_admin_password
- BASE_URL=https://miniflux.vakhrushev.me - BASE_URL=https://miniflux.vakhrushev.me
# Allow fetching feeds from private network (e.g. rssbridge_app in docker network).
# Since 2.2.18 the fetcher refuses private/loopback hosts by default (SSRF protection).
- FETCHER_ALLOW_PRIVATE_NETWORKS=1
- DISABLE_LOCAL_AUTH=1 - DISABLE_LOCAL_AUTH=1
- OAUTH2_OIDC_DISCOVERY_ENDPOINT=https://auth.vakhrushev.me - OAUTH2_OIDC_DISCOVERY_ENDPOINT=https://auth.vakhrushev.me
- OAUTH2_CLIENT_ID_FILE=/secrets/miniflux_oidc_client_id - OAUTH2_CLIENT_ID_FILE=/secrets/miniflux_oidc_client_id
@@ -30,6 +33,9 @@ services:
- OAUTH2_PROVIDER=oidc - OAUTH2_PROVIDER=oidc
- OAUTH2_REDIRECT_URL=https://miniflux.vakhrushev.me/oauth2/oidc/callback - OAUTH2_REDIRECT_URL=https://miniflux.vakhrushev.me/oauth2/oidc/callback
- OAUTH2_USER_CREATION=1 - OAUTH2_USER_CREATION=1
# Метрики скрейпит netdata по докер-сети (miniflux_app:8080/metrics).
# Сузить METRICS_ALLOWED_NETWORKS нельзя: netdata и Caddy сидят в одной
# web_proxy_network, по IP их не различить. Наружу /metrics закрыт в Caddy.
- METRICS_COLLECTOR=1 - METRICS_COLLECTOR=1
- METRICS_ALLOWED_NETWORKS=0.0.0.0/0 - METRICS_ALLOWED_NETWORKS=0.0.0.0/0
+2 -2
View File
@@ -1,7 +1,7 @@
services: services:
netdata: netdata:
image: netdata/netdata:v2.10.3 image: netdata/netdata:v2.11.0
container_name: netdata container_name: netdata
restart: unless-stopped restart: unless-stopped
cap_add: cap_add:
@@ -33,7 +33,7 @@ services:
- "/var/run:/host/var/run:ro" - "/var/run:/host/var/run:ro"
- "/var/run/docker.sock:/var/run/docker.sock:ro" - "/var/run/docker.sock:/var/run/docker.sock:ro"
environment: environment:
PGID: "{{ netdata_docker_group_output.stdout | default(999) }}" PGID: "{{ netdata_docker_gid }}"
NETDATA_EXTRA_DEB_PACKAGES: "fail2ban" NETDATA_EXTRA_DEB_PACKAGES: "fail2ban"
networks: networks:
+12
View File
@@ -1,9 +1,21 @@
update_every: 60 update_every: 60
# max_db_indexes задан внутри каждой джобы намеренно: на верхнем уровне
# файла разбирается только confgroup.Default (update_every,
# autodetection_retry, priority), остальные ключи yaml проглотит молча.
#
# Метрики индексов давали 205 индексов × 2 графика на две базы и никакой
# пользы на личном сервере. Лимит означает «не собирать метрики индексов для
# баз, где индексов больше N», так что 10 отключает их для обеих баз.
# Метрики таблиц (seq scans, bloat, hot updates) оставляем: стоковый лимит
# max_db_tables = 50 обе базы проходят.
jobs: jobs:
- name: outline_db - name: outline_db
dsn: 'postgresql://netdata:{{ netdata_postgres_password }}@outline_postgres:5432/outline' dsn: 'postgresql://netdata:{{ netdata_postgres_password }}@outline_postgres:5432/outline'
max_db_indexes: 10
- name: miniflux_db - name: miniflux_db
dsn: 'postgresql://netdata:{{ netdata_postgres_password }}@miniflux_postgres:5432/miniflux' dsn: 'postgresql://netdata:{{ netdata_postgres_password }}@miniflux_postgres:5432/miniflux'
max_db_indexes: 10
+1 -1
View File
@@ -3,7 +3,7 @@ update_every: 15
jobs: jobs:
- name: caddyproxy - name: caddyproxy
url: http://caddyproxy:2019/metrics url: http://caddyproxy:2020/metrics
selector: selector:
allow: allow:
- "caddy_http_*" - "caddy_http_*"
+58 -3
View File
@@ -34,6 +34,13 @@
#| datatype: duration (seconds), default value: 1s #| datatype: duration (seconds), default value: 1s
update every = 10s update every = 10s
# Третий tier (2 года с шагом 10 часов) на личном сервере не нужен, а
# per-metric структуры каждого tier'а лежат в памяти для всех ~12k метрик.
# Остаётся 14 дней с шагом 10s и 3 месяца с шагом 10m.
#| >>> [db].storage tiers <<<
#| datatype: number, default value: 3
storage tiers = 2
# enable replication = yes # enable replication = yes
# replication period = 1d # replication period = 1d
# replication step = 1h # replication step = 1h
@@ -106,7 +113,13 @@
# proxy = env # proxy = env
[ml] [ml]
# enabled = auto # Anomaly detection никто не смотрит, а тренировка k-means по каждому
# измерению каждые 3 часа — основной расход CPU демона (замер: 52 MB
# памяти и заметная доля от 18% ядра). Модели в ml.db тоже перестанут расти.
#| >>> [ml].enabled <<<
#| datatype: yes, no or auto, default value: auto
enabled = no
# training window = 6h # training window = 6h
# min training window = 15m # min training window = 15m
# max training vectors = 1440 # max training vectors = 1440
@@ -221,10 +234,34 @@
#| datatype: yes or no, default value: yes #| datatype: yes or no, default value: yes
otel = no otel = no
# Появился в 2.11: приёмник NetFlow/IPFIX/sFlow, стоковый netflow.yaml
# идёт с enabled: true и слушателями на UDP 2055/6343. Экспортировать
# потоки некому, а свой четырёхуровневый журнал и MMDB-базы плагин
# держит в памяти — на нашей памяти это лишний процесс.
#| >>> [plugins].netflow <<<
#| datatype: yes or no, default value: yes
netflow = no
#| >>> [plugins].statsd <<< #| >>> [plugins].statsd <<<
#| datatype: yes or no, default value: yes #| datatype: yes or no, default value: yes
statsd = no statsd = no
# Периодически обходит все сокеты хоста ради карты соединений, которой мы
# не пользуемся: ~5 MiB и постоянный расход CPU.
#| >>> [plugins].network-viewer <<<
#| datatype: yes or no, default value: yes
network-viewer = no
# Даёт extfrag и zswap (33 метрики) — не смотрим.
#| >>> [plugins].debugfs <<<
#| datatype: yes or no, default value: yes
debugfs = no
# Latency-пробы диска, требуют явной настройки, у нас не настроены.
#| >>> [plugins].ioping <<<
#| datatype: yes or no, default value: yes
ioping = no
# idlejitter = yes # idlejitter = yes
# netdata pulse = yes # netdata pulse = yes
# profile = no # profile = no
@@ -354,7 +391,13 @@
# command options = # command options =
[plugin:apps] [plugin:apps]
# update every = 10s # 86 групп процессов × 24 контекста = 4039 метрик, четверть всех. Метрики
# оставляем (нужен разрез по хостовым процессам вне контейнеров), но обход
# /proc по всем процессам делаем втрое реже — это и есть основная цена.
#| >>> [plugin:apps].update every <<<
#| datatype: duration (seconds), default value: 10s
update every = 30s
# command options = # command options =
[plugin:systemd-journal] [plugin:systemd-journal]
@@ -375,7 +418,19 @@
[plugin:proc:/proc/net/dev] [plugin:proc:/proc/net/dev]
# compressed packets for all interfaces = no # compressed packets for all interfaces = no
# disable by default interfaces matching = lo fireqos* *-ifb fwpr* fwbr* fwln* ifb4*
# К стоковому списку добавлены veth*, br-* и docker0: это 50 интерфейсов
# × 9 контекстов = 462 графика, из них 43 — безымянные veth докера.
#
# Внимание, побочный эффект: проверка имени в proc_net_dev.c идёт до
# netdev_rename_this_device(), поэтому отфильтрованный veth уже не может
# быть переименован в контейнерный чарт. У нас смаплен ровно один
# (cgroup_caddyproxy.net_eth0), его мы теряем; внешний трафик виден в
# system.net по eth0. Если понадобится разрез по контейнерам — убрать
# veth* из списка и разбираться, почему маппинг не сработал для остальных.
#| >>> [plugin:proc:/proc/net/dev].disable by default interfaces matching <<<
#| datatype: simple pattern, default value: lo fireqos* *-ifb fwpr* fwbr* fwln* ifb4*
disable by default interfaces matching = lo fireqos* *-ifb fwpr* fwbr* fwln* ifb4* veth* br-* docker0
[plugin:proc:/proc/stat] [plugin:proc:/proc/stat]
# cpu utilization = yes # cpu utilization = yes
+4 -2
View File
@@ -3,7 +3,7 @@ services:
# See sample https://github.com/outline/outline/blob/main/.env.sample # See sample https://github.com/outline/outline/blob/main/.env.sample
outline_app: outline_app:
image: outlinewiki/outline:1.8.1 image: outlinewiki/outline:1.9.2
container_name: outline_app container_name: outline_app
user: "{{ owner_create_result.uid }}:{{ owner_create_result.group }}" user: "{{ owner_create_result.uid }}:{{ owner_create_result.group }}"
restart: unless-stopped restart: unless-stopped
@@ -35,7 +35,9 @@ services:
OIDC_USERINFO_URI: 'https://auth.vakhrushev.me/api/oidc/userinfo' OIDC_USERINFO_URI: 'https://auth.vakhrushev.me/api/oidc/userinfo'
OIDC_LOGOUT_URI: 'https://auth.vakhrushev.me/logout' OIDC_LOGOUT_URI: 'https://auth.vakhrushev.me/logout'
OIDC_USERNAME_CLAIM: 'email' OIDC_USERNAME_CLAIM: 'email'
OIDC_SCOPES: 'openid profile email' # offline_access — чтобы Authelia выдала refresh token. Без него Outline не может продлить
# access token, ValidateSSOAccessTask признаёт вход невалидным и сбрасывает все сессии.
OIDC_SCOPES: 'openid profile email offline_access'
OIDC_DISPLAY_NAME: 'Authelia' OIDC_DISPLAY_NAME: 'Authelia'
SMTP_HOST: '{{ postbox_host }}' SMTP_HOST: '{{ postbox_host }}'
+2
View File
@@ -86,6 +86,8 @@ tier7 = 8
[telegram] [telegram]
# Включить Telegram-бот для ежедневной отправки воспоминаний. # Включить Telegram-бот для ежедневной отправки воспоминаний.
# Если api.telegram.org недоступен, приложение всё равно стартует: веб-часть
# работает, а бот повторяет авторизацию в фоне, пока не появится связь.
enabled = true enabled = true
# Токен бота, полученный от @BotFather. # Токен бота, полученный от @BotFather.
+6 -1
View File
@@ -1,9 +1,14 @@
# Образ собирается локально и доставляется на сервер ролью app_image
# (см. playbook-remembos.yml): тег — случайный BUILD_ID каждого прогона,
# в реестре образа нет, поэтому pull_policy: never.
services: services:
remembos_app: remembos_app:
image: "{{ yc_container_registry_repository }}/remembos:v0.2.0" image: "{{ app_name }}:{{ app_image_tag }}"
container_name: remembos_app container_name: remembos_app
restart: unless-stopped restart: unless-stopped
pull_policy: never
user: "{{ owner_create_result.uid }}:{{ owner_create_result.group }}" user: "{{ owner_create_result.uid }}:{{ owner_create_result.group }}"
environment: environment:
- PUID={{ owner_create_result.uid }} - PUID={{ owner_create_result.uid }}
@@ -1,11 +1,16 @@
# Образ собирается локально и доставляется на сервер ролью app_image
# (см. playbook-transcriber.yml): тег — случайный BUILD_ID каждого прогона,
# в реестре образа нет, поэтому pull_policy: never.
services: services:
transcriber_app: transcriber_app:
# noinspection ComposeUnknownValues # noinspection ComposeUnknownValues
image: "{{ registry_transcriber_image }}" image: "{{ app_name }}:{{ app_image_tag }}"
container_name: transcriber_app container_name: transcriber_app
user: "{{ owner_create_result.uid }}:{{ owner_create_result.group }}" user: "{{ owner_create_result.uid }}:{{ owner_create_result.group }}"
restart: unless-stopped restart: unless-stopped
pull_policy: never
volumes: volumes:
- "{{ config_file }}:/config/config.toml:ro" - "{{ config_file }}:/config/config.toml:ro"
- "{{ data_dir }}:/data" - "{{ data_dir }}:/data"
+18 -7
View File
@@ -12,7 +12,7 @@
services: services:
tududi_app: tududi_app:
image: chrisvel/tududi:1.1.1 image: chrisvel/tududi:1.4.1
container_name: tududi_app container_name: tududi_app
restart: unless-stopped restart: unless-stopped
networks: networks:
@@ -31,7 +31,9 @@ services:
- BASE_URL=https://tududi.vakhrushev.me - BASE_URL=https://tududi.vakhrushev.me
- TUDUDI_ALLOWED_ORIGINS=https://tududi.vakhrushev.me - TUDUDI_ALLOWED_ORIGINS=https://tududi.vakhrushev.me
- TUDUDI_TRUST_PROXY=true # Ровно один хоп (Caddy). `true` доверяет всей цепочке X-Forwarded-For и
# позволяет подделать IP клиента; tududi с 1.3.x сам приводит его к 1 и ругается.
- TUDUDI_TRUST_PROXY=1
- TUDUDI_SESSION_SECRET={{ tududi_session_secret }} - TUDUDI_SESSION_SECRET={{ tududi_session_secret }}
# Чистый OIDC: сид-админ не создаётся (TUDUDI_USER_EMAIL/PASSWORD не заданы), # Чистый OIDC: сид-админ не создаётся (TUDUDI_USER_EMAIL/PASSWORD не заданы),
@@ -63,12 +65,21 @@ services:
- EMAIL_FROM_ADDRESS=tududi@vakhrushev.me - EMAIL_FROM_ADDRESS=tududi@vakhrushev.me
- EMAIL_FROM_NAME=Tududi - EMAIL_FROM_NAME=Tududi
# MCP Server
- FF_ENABLE_MCP=true
# AI-фичи через Bifrost (drop-in OpenAI-эндпоинт в общей сети — НЕ localhost, # AI-фичи через Bifrost (drop-in OpenAI-эндпоинт в общей сети — НЕ localhost,
# это сам контейнер). Модель у tududi захардкожена как gpt-4o-mini; Bifrost # это сам контейнер). С 1.3.1 модель задаётся переменной, а не захардкожена,
# по governance-правилу роутит её на deepseek-v4-pro. openai-node сам # поэтому шлём deepseek/deepseek-v4-pro сразу: Bifrost резолвит провайдера по
# подхватывает OPENAI_BASE_URL. # префиксу, и подменять модель governance-правилом больше не нужно.
- OPENAI_API_KEY={{ tududi_openai_api_key }} # Пара LLM_* имеет приоритет над устаревшей OPENAI_*, значения те же.
- OPENAI_BASE_URL=http://bifrost_app:8080/openai # С 1.4.1 запросы уходят с response_format=json_schema и strict=true; если
# DeepSeek за Bifrost ответит 400 со словом response_format, tududi сам
# повторит запрос без него — на другую формулировку ошибки fallback уже не
# сработает. Лимиты токенов (LLM_MAX_TOKENS_*) оставлены дефолтными.
- LLM_API_KEY={{ tududi_openai_api_key }}
- LLM_BASE_URL=http://bifrost_app:8080/openai
- LLM_MODEL=deepseek/deepseek-v4-pro
networks: networks:
web_proxy_network: web_proxy_network:
+6 -3
View File
@@ -4,12 +4,16 @@
services: services:
tuwunel_app: tuwunel_app:
image: jevolk/tuwunel:v1.6.1 image: jevolk/tuwunel:v1.9.0
container_name: tuwunel_app container_name: tuwunel_app
restart: unless-stopped restart: unless-stopped
user: "{{ owner_create_result.uid }}:{{ owner_create_result.group }}" user: "{{ owner_create_result.uid }}:{{ owner_create_result.group }}"
networks: networks:
- "web_proxy_network" web_proxy_network:
# Алиас без подчёркивания: по нему в homeserver ходят соседние
# контейнеры (apprise шлёт уведомления в matrix-комнату).
aliases:
- "tuwunel"
volumes: volumes:
- "{{ data_dir }}:/var/lib/tuwunel" - "{{ data_dir }}:/var/lib/tuwunel"
environment: environment:
@@ -21,7 +25,6 @@ services:
TUWUNEL_ALLOW_REGISTRATION: "false" TUWUNEL_ALLOW_REGISTRATION: "false"
TUWUNEL_ALLOW_FEDERATION: "true" TUWUNEL_ALLOW_FEDERATION: "true"
TUWUNEL_ALLOW_CHECK_FOR_UPDATES: "false"
TUWUNEL_TRUSTED_SERVERS: '["matrix.org"]' TUWUNEL_TRUSTED_SERVERS: '["matrix.org"]'
# Well-known delegation values returned to clients/servers that query tuwunel directly. # Well-known delegation values returned to clients/servers that query tuwunel directly.
+2 -2
View File
@@ -1,9 +1,9 @@
# See versions: https://github.com/gramps-project/gramps-web/pkgs/container/grampsweb # See versions: https://github.com/muety/wakapi/releases
services: services:
wakapi_app: wakapi_app:
image: ghcr.io/muety/wakapi:2.17.4 image: ghcr.io/muety/wakapi:2.17.6
container_name: wakapi_app container_name: wakapi_app
restart: unless-stopped restart: unless-stopped
user: '{{ owner_create_result.uid }}:{{ owner_create_result.group }}' user: '{{ owner_create_result.uid }}:{{ owner_create_result.group }}'
+2 -2
View File
@@ -27,7 +27,7 @@ services:
wanderer_db: wanderer_db:
container_name: wanderer_db container_name: wanderer_db
image: "flomp/wanderer-db:{{ wanderer_version }}" image: flomp/wanderer-db:v0.18.3
user: "{{ owner_create_result.uid }}:{{ owner_create_result.group }}" user: "{{ owner_create_result.uid }}:{{ owner_create_result.group }}"
depends_on: depends_on:
wanderer_search: wanderer_search:
@@ -53,7 +53,7 @@ services:
wanderer_web: wanderer_web:
container_name: wanderer_web container_name: wanderer_web
image: "flomp/wanderer-web:{{ wanderer_version }}" image: flomp/wanderer-web:v0.18.3
user: "{{ owner_create_result.uid }}:{{ owner_create_result.group }}" user: "{{ owner_create_result.uid }}:{{ owner_create_result.group }}"
depends_on: depends_on:
wanderer_search: wanderer_search:
+37
View File
@@ -0,0 +1,37 @@
---
host_name: "rivendell"
primary_user: "major"
primary_user_uid: 1000
primary_user_gid: 1001
# Directory for all user binaries and scripts
bin_prefix: "/usr/local/bin"
# Root directory for application data. Override in inventory if host
# uses a different path (e.g. external disk mounted elsewhere).
application_dir: "/srv/applications"
apprise_external_port: 8000
apprise_external_url: "http://127.0.0.1:{{ apprise_external_port }}"
# Shared HTTP access log written by caddyproxy and consumed by analytics
# tools (goaccess and so on). Lives under the system log path so it is
# decoupled from any individual application's data directory.
caddy_logs_dir: "/var/log/caddy"
# Сайты (арендаторы) внутри одного инстанса goatcounter. Домен здесь — не
# отслеживаемый сайт, а ключ маршрутизации: goatcounter выбирает сайт по
# заголовку Host, другого способа у него нет. Список общий, потому что его
# читают два плейбука: goatcounter заводит по нему сайты, caddyproxy рендерит
# из него имена в Caddyfile.
#
# Первый в списке — родительский: ему принадлежит единственный пользователь,
# остальные заводятся с -link на него и делят с ним логин (goatcounter ищет
# пользователя по parent). Порядок менять нельзя: он определяет, кто родитель.
#
# Косметическое поле «Your site» (link_domain) — то, на какой домен ведут
# ссылки с путей в дашборде — CLI не умеет, оно ставится разово руками в
# настройках сайта. В комментариях ниже — чему оно должно соответствовать.
goatcounter_sites:
- "gc-main.vakhrushev.me" # vakhrushev.me
- "gc-photo.vakhrushev.me" # photo.vakhrushev.me
+246
View File
@@ -0,0 +1,246 @@
$ANSIBLE_VAULT;1.1;AES256
62666365313535383334366166313961303933373163323236626565303038396662363239376438
3738323565643363323865663630303330306530303263350a393336646463663563666362643362
61313337363936313363316635376163646633383032613265363936396536386437306139396461
3963656162316562620a393737383636343965656433346466316333613331303435323732363239
39313834373536396237333264303930393762383333633363333265363039373061353334613530
33633161373631363061383564633465633962383835633237623938343865303363633461313830
36383865663932666562333733653935626166663066363233303962643364343739663463396364
34353034656130343739336532393334306261366331353030306161636437363464336536386638
62653362633363393838366564313165346262653432663563366362616364396633323563643239
35333232396239383233656231333264663938373364313730623364393436626361653736613939
64393833353765346633363334656631343064656333383664373039393966346332393134303030
36373436343766323038303438386436376666393239643636366330336336333761356436376234
33383266663030643931626662383636333637616539313366326539383139616334363939626161
34346263643031363236313334623162343236636665383262393666306366383064343965373639
34323062656633386661343332306664633361396233656238613436636530323839653838376563
33336664643365363930366636656138616236626639663564613132653465636462633364646166
36393966326365373530663839653737656438646662373730333666376137653337303834383032
33363636376435623064646566323065383466623138323938303630353330663132636431333830
30313434333262616138343634303438386236326330636161336462363333386561643462666439
35343837653934613338313537336232656564663337666262656361383736363939383433623938
32633965633931643836346362356334666230663734643130656566353937636331303633323934
62656634643263333566343739666634663061386137373539383736613864656230373566666162
32616363633534313933613533373265623366303766626232623966623531373930613831643034
64306236353164643138666564616464383638636263326531633866363732653664313364386661
30633536623336353532363261623938353232396336343662643965353839333761376463363631
66353862646131343961363363303432336431633761313633386235383435336563383333613830
62333864373362663538326363656531643733356232643632396631323636376164653333316137
39626336333139323733653535663132333030323938303166396336356136653231366332613039
30346237353532623065303033326534663034333938663365303264323061613964313139316538
32633537346165616136623431653766383535393230303064383433626634386331356434323561
31646137623339373662626631356337616666303131633138366433356163373766616531383138
33366361323935363163363731636631643135636332316439303335616661373039346433356138
34613061646138626637316262623632303339653633346161353836623330313432396333323464
30346232646661643839663835373439323031623462386432623332316535623764343039333039
62313031663731306539303634666461376133643138363235383531366239643331313238623666
62656631333030626337333765646339643561396537333462643166616165636261323663333963
32656336353837323231643561306134323065396131343965373534656530376237616134343034
61313430376531333261323530346430303163313864333033663536386561633939383030666430
30633864316363383631386636656331323234356438643132633637623637646536316532373639
38313535663930666335333239623935393232633432343561663938343335356565626266353630
35666134383431653163356230663038336563366433623562333333313733383536353635613835
35366539306437623336623230343666366438333334613765393930313234383365623466393934
32633931363164626464396534333431666630316365323265376161313138313232646665323532
39353563393235353632323930326537643162386430383461306136306339306535363565633662
35336434343938353538363563373363393464623633313638346236633662623837633263633166
64666630346235613664356433653436616166643762653431376666306364623237363363613333
31316533363966633362366266613863353136353662313636393234303361396531333063623863
30343534643835613462353739613730633331643837383836326236636235623965356333343961
32396135393564653537613731316133633665653061386664343233396537383332643163356639
36653134303361656438666635396339323332346133623439613031613764666665303335613738
64303537616531643237633835613937663739313466346463323037636432373235363038393461
66363162323562646264323133306538633430303538356231326436313432323931623135306262
30666132383131343262326531303931343539646130356332333964613938363033623731633463
36363635366165663064653934623235323930336538643634623233383535323930386664313530
65383264623736373261343331353339313361383539653262373338316163353063663038656465
35356133353465303137373039363535336232326233353937643465323539343263363739663531
66666566373835336433353761336338336533353463386230306665616638393864646264373563
33313339646237626264366263613036373931666366326663313964633738393164633534623839
39613132396661393761343230633135323732613836383661353665663831623766616639343331
62623862343737643930373432323563663264366234646533353835353264333333623734313435
62316666306437643835386235303139616534303932363263373863653633613464363431356132
32653661373365623364316663393262303538633039366138316630643031626565316335613436
36356137653139303334616631396634376262396133633161306233336162643632383635333333
65373633666637343666366463323765656633303732323631393064353739376430303934333232
63393831386363343233363732326234643039396637383132336162326336363033393436383931
37323763326461643837393139613165346136316663646464343338613061323936303030356466
39636138376632653063643963306334396339666665323234336135396561393166643236663733
32316364623831626134306163393239303564313466636663393166336662613739323864346637
38393064313362663935666632386339393561663735656639373633623933306635363330333736
62633966653138633231346331393533633333623835666463666330333835316631666238396334
33343232396135383834383564636235663634346336373536663539653861633862653332633962
36343136633532323565316431396630313837363138343833316363366165383337343930626361
66633535643839396463343264313336663430326137356465663166353833636134356434613839
39313439323431633966636533336530346138353935663433333661353634316632646333363434
33326165653033663965333934363633353235363931626463383565393331366566663666633733
32643863653661633137313263646630373465343434666234663236393832313761383535613363
65346335626535383335643361653531366139363637333838376562326138386361623562616165
37333730663134666466666130396662616138663962366630323330616333323539623532613733
62373531653564636662316334646338346337616433333533346539363066366338333233616337
33666130323330356364326633623165366465326331383361323630363330633833363536373930
64393464383533383335623263613136336632363132373663326263303162636331666164646234
32316133313438616563376230636537636334346637356339336533333264643461666439643238
66376461643136336665643366643363366365373636613537636462653737303131643661336364
34323261313632663064346263336463363932646261613232643030383038613137303532343766
36663366396338306534633062616436333133316438666261396136323535613436356337613339
33626565663137666265316533306231643435376465333566643366633638346631663435306534
37373836366438336365326265653433306334313630373737343630363534313131386332633134
65373862643431313031626236356565626133633663613564613238313962323934633437383930
65393765313934393438663338343239353732316130366466323638656439613234643036356265
36646631313362663833343934363930373536333531626161623038396331373065633965643735
37666162343930383133306139306131356232636530306234616464633736636538346431613066
31396630653338623733663262663335623466613430316663316435653233653837623131396462
31633531356239326538663737313437303735363565316263323131383362306235363835333830
64333438633837356633633435643830633938396637326662666137333934613531316538626633
62346431626662663538303431336234613164383536313363303932643062316538343332386165
62653937663135353638383363333634383031383362663039646133666634356661313537303566
32316361613261313830363532336238616265393661343133373231633964323535656530336136
38393163626133336235346162633165396333633339316462306433656262623736376332336330
63363733363263636336633432366633393533373566653836663165626162363735363963316532
66376238303538386537363233376431393833303263383733303261386237623136613435613530
34383738646330336636363336346264323132393962666334613134313635393165656236623234
35333535353562656463376135353562386532366235363865343038316561663933373339356336
35393830623430323365613863393932633535376436663666356562386461336239366665333436
32626534373830386134666233346333393064343933323830336462666136386538306463313531
65303263366530356138363162333833376631626631316435663631333438363833366234646131
33633236326533643139343766366131313834366663336361623563376465646338623537343934
35653665363665343264333237313663663431386530333230643832643537373464393836356365
31306465623539653361323233613136373464643334366466616561356336363161306566353033
39313735653062643361313538353464393566303435303839643061326631346434316537346561
35326536316233613035366532316132396531646561633834346530386337636532313233326433
31383364313331623433323364356566373433323034373531653736653866636539383639383163
61663362303435363539343331373931313065343662353964366566656537633664383239343430
62623865623665616638623937343361376133306564383461336136313035333731313837393762
34393936373865653761363139393530386135626538353031333736646235366165623535323338
33313964643732633032643037323064323461313930623836623438366261633033666439396535
31326131303738383237643566306561393635383831303433323963646133346534396663616338
33343139663162636134346637333330333637643063373330376134373533663864663338653264
31313530623933653934313530663139313439373662613130636135666630346261623933393833
30306166303938336439333833373434346139366162623736663662353931626639663139336462
39353336653163316136656238643031626563326238353064356430313533376536376635373262
62343534643661326435663031653634663130323035303063656163303436386131643435383733
30653061623439376262376338366166353836353535343939646134393335623630383866306539
30343862636131393863643831656338346334313930356164393135303166643265656138366266
64646636306339656662616239356666633330666237653166393663653565343533316337316237
36663930323730653236653235626437366338333263333135633634346434383530386331333166
38346563393934303830633932323564343166363936313035623034666436303831643265646433
36616362633230666132313937626136653530623865663037383239313939393739656663373166
37393233653533646664623865333865623862333263383635643238343538333137383461623565
65663036356666323137666536323435376565653234376530393166393939666663373361343530
36363466323162383930316637326333393937333232363537643065383231396236323936643364
64653061323033363135353436333036326636306639653139363064386366653562383539356131
31366432656634323730336665353132323862636339313036643234396339646165313764303734
34303737353466306537386633623261393537613735313032643033383434363233393061386538
35313834396533633735313237316433643835323465306431336331306638383165306234303366
64626239636439643538323734343966366535616437383632363631363731636463653133313266
62653036653761333166643432653563613264616362316463326538653536383763363237653238
37303564386266653239333636303034633538383232623630366232626638373664343336336462
38393038633737376562626131343131653236376432653230343833393366366566363334343263
39323062663565373132343131303530353464626133643731666239316363373437313335626232
39393931333033643265646535663935363831306163303966623164303264626161303434646538
37656232396336336635643335303165323361366562303137333831343832653763323937373061
35326263356263363636383861356339623966323564366430663632316664373536613462623838
33356462363038623633393337303161393536646665663339653234336137393139336132353235
37613138333065363731623535346138643833343663656162376139376638303231653537326131
31376334383564623331623733613365643161393165376563313235663930323037343161343866
37383336303138393064663265363939343535376433326433353165356564656132366565613839
65643365343038316139653665396334613131396238663162666365393339353539613634383935
64333535386661333363383437653864636434323832303932666364376333663633326434383064
31333464323331303939353732353763643562383538356363613739353135326630613334323037
35363335346464373064363231393435306534323431616365623765646362373738653738326232
30323563326466373766363935636535626632623138343537333038333964396262323831353038
34383665346430396361306437613339366437626266626231626239363362613065396137343962
36333463313130353165373365316563623330356638356233383333653661663462383161303136
36633038386563376335376236646537316563633739393235663930656131613030666133363430
62386263373338653161376636623061386234356632626636366232313639353138636332616630
61353435353239633630323163336133353462356263316130626638656238653264663361323135
37633038373433313863393965633661613231396536613835653361373837356232333833633236
32313439633632363465366337303739303137323839366261653836343332373230643264666535
35313865613632636437323462316639323531326432633334333432323266386435366366333838
38633830313731326638306634323565366265343130333339373032643832346166613737663430
62336434316264376361353830633562313065653433393363373734666134323263653537653830
62643838333065376234646166666332376462373536373234666163636562333865636134313766
39376237626137316366306366393038616631346161316263343237666430323966396331353065
30363437356262643034643330303534333331323464623662616534653932646130653466653332
62363030343833316133666164383037323936336232356266326261383964396364633233643737
32663635613939363265323936336263663465646666333263626139636162373462373064613362
65636266616665633033626337363136303066343232353537346635633431336266333535353131
66316266643335313033386432373962343332326238356430323435363238373334653036393839
65643337663034326232646464383934323661326432373061376662633765613861613466653937
30363065316561666235383332623364353938303734323537643064343333393864313339656161
36663264363363353430663930653965613761353630373034336166633864386234653831343636
32383934633666646235366534303130306436636665326338626566323362616664383162333837
39636539313737653933386261326262633963396364626430633631653735303934353436633532
63373866326666323565663435613530616130323035613438313634393264353266333838333131
33626236366633656365656534643538623431323635313133323630333365653132313932653863
32396139306437353839303933663432376434633462376335386264373934316339616663646130
34363130333061626433663636363032366237346136633966356139663462393836623562626532
35633930306639326639383632363561306164336535303139316162306431313236633036333964
33353063653561343764653235613330613331656338343337623431326564333761633731636531
38616539356239663861363237333733386635353532333039636631663639316434623633323634
64663139356536643864373836303335316464636163303464376264633630633738313064663630
30626363636265383064373764376538626235316138303831306262643932316332323733356535
39306564393236653530393864346261333434663232393765626265626166343864623931636133
37636532333762306339666466633939376432326230306437386337666630393530336163306335
62353037303265656534646262623836633030326636356566363362353161353764636639663538
65373831613431373665383565623361313235333036376239313039616433626539626432373363
37396232626663636165363863346533396132376531306139616664363261323339333239373639
30383136383232323837396665373235363433613864373437623834633164613266383165653966
35336337656361633435363531346134303435633262623465383938626639383332363736356630
32316637613233303531386562386636636361323163633732633732356630636165663266363532
66626433633938663262323464653335396662373634653466653964343065336139313539646536
39663134333530343537323764646566323635303361323032613633626536646236616465343365
39646336626632633839626461303633656633613939393139346639343935333966383564393263
32623639366134636563306463643230656337303666393634386432386332333137316534333361
34646631663137653139343131343236323930373036366263666162306230376664313331636238
31666465343633323931343063376530363838613934623537643338643236323237386163633131
32663263613265646662393339346463353166393630306665343534373238666235663538366637
34306664313462366462643732376365343464393833633035353863663933393566633065663531
64386231363931323363653665613732313438323630373430613634636437666565363734643730
61383265383938643336333666643434356166643831383765343736373436653430376561666338
36373033643665343431303464663838373766306466636534396634323232623434653736316531
32383365396330666333396364663561336663333965323866316131356331363633383437643230
33653337386534316537613830393337336630373436343130623535343938616133376138663236
39363133663735303037396631333062656566393461643337663161646361303739343161653731
38383636623238323261383038326433303532353039383035393666366265653334346630663839
65343336666462366432383764646137623336636662633731386534636463656364383465626531
30663631663932303161366332663964643662626631313732333764353763363764623234323030
36356330313261626134326363346133393638383834623830636165316631613432666261613032
31393231333837623266363130633331646531636136343163376166653261343464623133323635
61333636313962383961393831366262373837316531363166313964316538363634386430343739
64343363373165663934643833613064353264373539666139343734393663643735393736393065
37393262366564616561663532373137336262393439393031383439333336636535323834393138
63613734326238613762616366653533636130326631383631336433343635656230343266343365
35613033356130656632303339643236643330646339333037363830646431313032366363643336
36306332376436356639323738306638333866373532363632373861613863633133323862393036
61653637323635636163386139353038373039623937353533623336653565633730336330353936
61343431633635346339626536376464306663656262333531373438333363666237333832316238
38613336646263633932636233393734626338336433326232326164613436363232616334383230
31616333646361346638613065343065393736313735646234663333633534636331356663323463
35383261396563623363353432356431303539363666653261326363646464373865666531636131
61386634383337656539643338353066373932313134613266373366366630656236313664366164
64633163386431366232383835633235323130376462313832353736343262663737353739303934
62323436326631616134343831376561633965373561303039386266313935336164386636316139
37653238666631306161396537343462376638653263343134633234366236633966373438383639
35356532363534363133353264346661663437656162353739656265373130363931356239366339
38356538616137353737323132366130396335363262646635383763356534306465343463646632
64613431666435333330623035646463666532323131356538343034373936333762303931616463
35346231393338353065326430313032653966666362653438623333323466303431313135323733
39306466666532383933346164323931366337313937623730313162623261373666613363323233
63313161316266323561343363353038326439343661613439336132636361316563373766643164
31646536643439643536303864386530633439646135396336383332646266613461623837656431
30356266356664666436323764316363636330633636646630633536626266343262383731633436
62313964646436366130313730623332613530386538363533633263623866663735386531353861
61363361663739333637353436336435306162643638383134653338656665326531356264343137
61303262373531646634313239393361323835376233633964613937313966643132653264663265
30386334353335343237353734303132643534316130376437386663363937366432303866346231
32616639383462363333373237343663383332343364373465646633383034383034376534396162
61643739323330353130333361396335363634373131653835363036333563613239383966343663
35306362353435663762656461666539393139616264306666393066333764663039623764323937
63643539386161353237343230333832326137666539613539346366353239623265626562386562
39623361376430633461336562613232393631623733326164643637343632633538666437626235
34393638363839653136646562336131376335303661373339326661353935623164316232653034
37306134323561616631313838336364343463303631343765393130623163346365636664333061
33356133626535383765636463326138646266363061326430393934343330356136323664326630
66356161356332383739353531366432383966366137653866336666633631636532
+7 -2
View File
@@ -14,13 +14,18 @@ pre-commit:
run: "uv run ruff format {staged_files}" run: "uv run ruff format {staged_files}"
stage_fixed: true stage_fixed: true
- name: "fix python"
glob: "**/*.py"
run: "uv run ruff check --fix {staged_files}"
stage_fixed: true
- name: "check python" - name: "check python"
glob: "**/*.py" glob: "**/*.py"
run: "uv run ruff check {staged_files}" run: "uv run ruff check {staged_files}"
- name: "mypy" - name: "pyrefly"
glob: "**/*.py" glob: "**/*.py"
run: "uv run mypy {staged_files}" run: "uv run pyrefly check {staged_files}"
- name: "yamllint" - name: "yamllint"
glob: "**/*.{yml,yaml}" glob: "**/*.{yml,yaml}"
+3
View File
@@ -70,3 +70,6 @@
- name: "Configure goaccess" - name: "Configure goaccess"
ansible.builtin.import_playbook: playbook-goaccess.yml ansible.builtin.import_playbook: playbook-goaccess.yml
- name: "Configure goatcounter"
ansible.builtin.import_playbook: playbook-goatcounter.yml
+3
View File
@@ -2,6 +2,9 @@
- name: 'Configure system' - name: 'Configure system'
ansible.builtin.import_playbook: playbook-system.yml ansible.builtin.import_playbook: playbook-system.yml
- name: 'Configure firewall'
ansible.builtin.import_playbook: playbook-ufw.yml
- name: 'Configure docker' - name: 'Configure docker'
ansible.builtin.import_playbook: playbook-docker.yml ansible.builtin.import_playbook: playbook-docker.yml
+32 -7
View File
@@ -2,10 +2,6 @@
- name: "Configure apprise application" - name: "Configure apprise application"
hosts: all hosts: all
vars_files:
- vars/secrets.yml
- vars/vars.yml
vars: vars:
app_name: "apprise" app_name: "apprise"
app_user: "{{ app_name }}" app_user: "{{ app_name }}"
@@ -15,6 +11,15 @@
config_dir: "{{ (base_dir, 'config') | path_join }}" config_dir: "{{ (base_dir, 'config') | path_join }}"
tasks: tasks:
# Apprise молча не доставит уведомление, если id комнаты записан без
# префикса: значение без '!' и '#' плагин matrix трактует как алиас.
- name: "Check matrix room id format"
ansible.builtin.assert:
that:
- notifications_matrix_room is match('^[!#]')
fail_msg: "notifications_matrix_room: нужен '!room_id:server' или '#alias:server'"
quiet: true
- name: "Create user and environment" - name: "Create user and environment"
ansible.builtin.import_role: ansible.builtin.import_role:
name: owner name: owner
@@ -22,7 +27,6 @@
owner_name: "{{ app_user }}" owner_name: "{{ app_user }}"
owner_uid: "{{ app_owner_uid }}" owner_uid: "{{ app_owner_uid }}"
owner_gid: "{{ app_owner_gid }}" owner_gid: "{{ app_owner_gid }}"
owner_extra_groups: ["docker"]
- name: "Create application internal directories" - name: "Create application internal directories"
ansible.builtin.file: ansible.builtin.file:
@@ -35,13 +39,23 @@
- "{{ base_dir }}" - "{{ base_dir }}"
- "{{ config_dir }}" - "{{ config_dir }}"
# Бандл назывался server, пока источник уведомлений был один. Файл остался
# бы на сервере с прежними секретами, поэтому удаляем его явно.
- name: "Remove renamed apprise config"
ansible.builtin.file:
path: "{{ config_dir }}/server.cfg"
state: "absent"
- name: "Copy apprise config" - name: "Copy apprise config"
ansible.builtin.template: ansible.builtin.template:
src: "./files/{{ app_name }}/server.template.cfg" src: "./files/{{ app_name }}/backups.template.cfg"
dest: "{{ config_dir }}/server.cfg" dest: "{{ config_dir }}/backups.cfg"
owner: "{{ app_user }}" owner: "{{ app_user }}"
group: "{{ app_user }}" group: "{{ app_user }}"
mode: "0640" mode: "0640"
# Файл содержит секреты из vault — без no_log их печатает --diff.
no_log: true
notify: "Restart application"
- name: "Copy docker compose file" - name: "Copy docker compose file"
ansible.builtin.template: ansible.builtin.template:
@@ -58,3 +72,14 @@
remove_orphans: true remove_orphans: true
tags: tags:
- run-app - 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
+15 -4
View File
@@ -3,8 +3,6 @@
hosts: all hosts: all
vars_files: vars_files:
- vars/secrets.yml
- vars/vars.yml
- files/authelia/secrets.yml - files/authelia/secrets.yml
vars: vars:
@@ -26,7 +24,6 @@
owner_name: "{{ app_user }}" owner_name: "{{ app_user }}"
owner_uid: "{{ app_owner_uid }}" owner_uid: "{{ app_owner_uid }}"
owner_gid: "{{ app_owner_gid }}" owner_gid: "{{ app_owner_gid }}"
owner_extra_groups: ["docker"]
- name: "Create internal application directories" - name: "Create internal application directories"
ansible.builtin.file: ansible.builtin.file:
@@ -48,6 +45,10 @@
owner: "{{ app_user }}" owner: "{{ app_user }}"
group: "{{ app_user }}" group: "{{ app_user }}"
mode: "0600" mode: "0600"
# Vault-файл: ansible расшифровывает его при копировании, и без no_log
# --diff покажет содержимое открытым текстом.
no_log: true
notify: "Restart application"
- name: "Copy configuration file" - name: "Copy configuration file"
ansible.builtin.template: ansible.builtin.template:
@@ -56,6 +57,9 @@
owner: "{{ app_user }}" owner: "{{ app_user }}"
group: "{{ app_user }}" group: "{{ app_user }}"
mode: "0600" mode: "0600"
# Файл содержит секреты из vault — без no_log их печатает --diff.
no_log: true
notify: "Restart application"
- name: "Copy gobackup config" - name: "Copy gobackup config"
ansible.builtin.template: ansible.builtin.template:
@@ -89,9 +93,16 @@
tags: tags:
- run-app - run-app
- name: "Restart application with docker compose" handlers:
# Приложение читает конфиги только при старте, а `state: present` не
# пересоздаёт контейнер, если docker-compose.yml не изменился, — поэтому
# правку конфига применяет только явный рестарт.
- name: "Restart application"
community.docker.docker_compose_v2: community.docker.docker_compose_v2:
project_src: "{{ base_dir }}" project_src: "{{ base_dir }}"
# Только сам authelia: рестарт redis сбросил бы сессии всех сервисов.
services:
- "authelia_app"
state: "restarted" state: "restarted"
tags: tags:
- run-app - run-app
+6 -4
View File
@@ -2,10 +2,6 @@
- name: "Configure restic and backup schedule" - name: "Configure restic and backup schedule"
hosts: all hosts: all
vars_files:
- vars/secrets.yml
- vars/vars.yml
vars: vars:
backup_config_dir: "/etc/backup" backup_config_dir: "/etc/backup"
backup_config_file: "{{ (backup_config_dir, 'config.toml') | path_join }}" backup_config_file: "{{ (backup_config_dir, 'config.toml') | path_join }}"
@@ -40,6 +36,8 @@
owner: root owner: root
group: root group: root
mode: "0640" mode: "0640"
# Файл содержит секреты из vault — без no_log их печатает --diff.
no_log: true
- name: "Create backup config file" - name: "Create backup config file"
ansible.builtin.template: ansible.builtin.template:
@@ -48,6 +46,8 @@
owner: root owner: root
group: root group: root
mode: "0640" mode: "0640"
# Файл содержит секреты из vault — без no_log их печатает --diff.
no_log: true
- name: "Allow user to run the backup script without a password" - name: "Allow user to run the backup script without a password"
ansible.builtin.lineinfile: ansible.builtin.lineinfile:
@@ -64,6 +64,8 @@
owner: root owner: root
group: root group: root
mode: "0700" mode: "0700"
# Файл содержит секреты из vault — без no_log их печатает --diff.
no_log: true
- name: "Copy backup all script" - name: "Copy backup all script"
ansible.builtin.copy: ansible.builtin.copy:
+8 -14
View File
@@ -2,10 +2,6 @@
- name: "Configure bifrost application" - name: "Configure bifrost application"
hosts: all hosts: all
vars_files:
- vars/secrets.yml
- vars/vars.yml
vars: vars:
app_name: "bifrost" app_name: "bifrost"
app_user: "{{ app_name }}" app_user: "{{ app_name }}"
@@ -25,7 +21,6 @@
owner_name: "{{ app_user }}" owner_name: "{{ app_user }}"
owner_uid: "{{ app_owner_uid }}" owner_uid: "{{ app_owner_uid }}"
owner_gid: "{{ app_owner_gid }}" owner_gid: "{{ app_owner_gid }}"
owner_extra_groups: ["docker"]
- name: "Create application internal directories" - name: "Create application internal directories"
ansible.builtin.file: ansible.builtin.file:
@@ -46,7 +41,9 @@
owner: "{{ app_user }}" owner: "{{ app_user }}"
group: "{{ app_user }}" group: "{{ app_user }}"
mode: "0600" mode: "0600"
register: config_file_result # Файл содержит секреты из vault — без no_log их печатает --diff.
no_log: true
notify: "Restart application"
- name: "Copy docker compose file" - name: "Copy docker compose file"
ansible.builtin.template: ansible.builtin.template:
@@ -55,7 +52,6 @@
owner: "{{ app_user }}" owner: "{{ app_user }}"
group: "{{ app_user }}" group: "{{ app_user }}"
mode: "0640" mode: "0640"
register: docker_compose_file_result
- name: "Run application with docker compose" - name: "Run application with docker compose"
community.docker.docker_compose_v2: community.docker.docker_compose_v2:
@@ -65,15 +61,13 @@
tags: tags:
- run-app - run-app
# Bifrost читает config.json только при старте. Если изменился лишь конфиг handlers:
# (а compose — нет), `state: present` не пересоздаёт контейнер, и правки # Приложение читает конфиги только при старте, а `state: present` не
# (ротация ключа, смена модели/правил) не подхватятся — нужен явный рестарт. # пересоздаёт контейнер, если docker-compose.yml не изменился,поэтому
- name: "Restart docker compose services if config changed but not docker-compose.yml" # правку конфига применяет только явный рестарт.
- name: "Restart application"
community.docker.docker_compose_v2: community.docker.docker_compose_v2:
project_src: "{{ base_dir }}" project_src: "{{ base_dir }}"
state: "restarted" state: "restarted"
when:
- config_file_result.changed
- not docker_compose_file_result.changed
tags: tags:
- run-app - run-app
+11 -16
View File
@@ -2,10 +2,6 @@
- name: "Configure caddy reverse proxy service" - name: "Configure caddy reverse proxy service"
hosts: all hosts: all
vars_files:
- vars/secrets.yml
- vars/vars.yml
vars: vars:
app_name: "caddyproxy" app_name: "caddyproxy"
app_user: "{{ app_name }}" app_user: "{{ app_name }}"
@@ -27,7 +23,6 @@
owner_name: "{{ app_user }}" owner_name: "{{ app_user }}"
owner_uid: "{{ app_owner_uid }}" owner_uid: "{{ app_owner_uid }}"
owner_gid: "{{ app_owner_gid }}" owner_gid: "{{ app_owner_gid }}"
owner_extra_groups: ["docker"]
- name: "Create internal application directories" - name: "Create internal application directories"
ansible.builtin.file: ansible.builtin.file:
@@ -78,6 +73,7 @@
owner: "{{ app_user }}" owner: "{{ app_user }}"
group: "{{ app_user }}" group: "{{ app_user }}"
mode: "0640" mode: "0640"
notify: "Reload caddy configuration"
- name: "Copy docker compose file" - name: "Copy docker compose file"
ansible.builtin.template: ansible.builtin.template:
@@ -95,17 +91,16 @@
tags: tags:
- run-app - run-app
# - name: "Reload caddy" handlers:
# community.docker.docker_compose_v2_exec: # `state: present` не пересоздаёт контейнер, если docker-compose.yml не
# project_src: '{{ base_dir }}' # изменился, — правку Caddyfile надо применять отдельно. Через прокси идёт
# service: "{{ service_name }}" # весь трафик, поэтому не рестарт, а `caddy reload`: конфиг применяется без
# command: caddy reload --config /etc/caddy/Caddyfile # разрыва соединений. Конфиг указываем явно — без --config caddy берёт файл
# tags: # из своего рабочего каталога и молча перезагружает не то.
# - run-app - name: "Reload caddy configuration"
community.docker.docker_compose_v2_exec:
- name: "Restart application with docker compose"
community.docker.docker_compose_v2:
project_src: "{{ base_dir }}" project_src: "{{ base_dir }}"
state: "restarted" service: "{{ service_name }}"
command: "caddy reload --config /etc/caddy/Caddyfile"
tags: tags:
- run-app - run-app
+6 -13
View File
@@ -2,10 +2,6 @@
- name: "Configure calibre application" - name: "Configure calibre application"
hosts: all hosts: all
vars_files:
- vars/secrets.yml
- vars/vars.yml
vars: vars:
app_name: "calibre" app_name: "calibre"
app_user: "{{ app_name }}" app_user: "{{ app_name }}"
@@ -23,7 +19,6 @@
owner_name: "{{ app_user }}" owner_name: "{{ app_user }}"
owner_uid: "{{ app_owner_uid }}" owner_uid: "{{ app_owner_uid }}"
owner_gid: "{{ app_owner_gid }}" owner_gid: "{{ app_owner_gid }}"
owner_extra_groups: ["docker"]
- name: "Create application internal directories" - name: "Create application internal directories"
ansible.builtin.file: ansible.builtin.file:
@@ -38,16 +33,14 @@
- "{{ config_dir }}" - "{{ config_dir }}"
- name: "Create backup targets file" - name: "Create backup targets file"
ansible.builtin.lineinfile: ansible.builtin.copy:
path: "{{ base_dir }}/backup-targets" dest: "{{ base_dir }}/backup-targets"
line: "{{ item }}" content: |
create: true {{ books_dir }}
{{ config_dir }}
owner: "{{ app_user }}" owner: "{{ app_user }}"
group: "{{ app_user }}" group: "{{ app_user }}"
mode: "0750" mode: "0640"
loop:
- "{{ books_dir }}"
- "{{ config_dir }}"
- name: "Copy docker compose file" - name: "Copy docker compose file"
ansible.builtin.template: ansible.builtin.template:
+13 -8
View File
@@ -2,10 +2,6 @@
- name: "Configure dashboard start page" - name: "Configure dashboard start page"
hosts: all hosts: all
vars_files:
- vars/secrets.yml
- vars/vars.yml
vars: vars:
app_name: "dashboard" app_name: "dashboard"
app_user: "{{ app_name }}" app_user: "{{ app_name }}"
@@ -14,11 +10,9 @@
base_dir: "{{ (application_dir, app_name) | path_join }}" base_dir: "{{ (application_dir, app_name) | path_join }}"
config_dir: "{{ (base_dir, 'config') | path_join }}" config_dir: "{{ (base_dir, 'config') | path_join }}"
# gethomepage.dev — проверь актуальный тег перед обновлением.
homepage_image: "ghcr.io/gethomepage/homepage:v1.13.2"
# Внутренний порт контейнера; наружу не публикуется, ходим только через Caddy. # Внутренний порт контейнера; наружу не публикуется, ходим только через Caddy.
homepage_port: 3000 homepage_port: 3000
# Домен, на котором Caddy отдаёт дашборд. homepage 1.x требует явный whitelist # Домен, на котором Caddy отдаёт дашборд. homepage требует явный whitelist
# Host за реверс-прокси, иначе «Host validation failed». # Host за реверс-прокси, иначе «Host validation failed».
homepage_domain: "start.vakhrushev.me" homepage_domain: "start.vakhrushev.me"
@@ -36,7 +30,6 @@
owner_name: "{{ app_user }}" owner_name: "{{ app_user }}"
owner_uid: "{{ app_owner_uid }}" owner_uid: "{{ app_owner_uid }}"
owner_gid: "{{ app_owner_gid }}" owner_gid: "{{ app_owner_gid }}"
owner_extra_groups: ["docker"]
- name: "Create application internal directories" - name: "Create application internal directories"
ansible.builtin.file: ansible.builtin.file:
@@ -57,6 +50,7 @@
group: "{{ app_user }}" group: "{{ app_user }}"
mode: "0640" mode: "0640"
loop: "{{ config_files }}" loop: "{{ config_files }}"
notify: "Restart application"
- name: "Copy docker compose file" - name: "Copy docker compose file"
ansible.builtin.template: ansible.builtin.template:
@@ -74,3 +68,14 @@
pull: "always" pull: "always"
tags: tags:
- run-app - 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
-4
View File
@@ -2,10 +2,6 @@
- name: "Configure docker parameters" - name: "Configure docker parameters"
hosts: all hosts: all
vars_files:
- vars/secrets.yml
- vars/vars.yml
tasks: tasks:
# - name: "Install python docker lib from pip" # - name: "Install python docker lib from pip"
# ansible.builtin.pip: # ansible.builtin.pip:
-5
View File
@@ -2,10 +2,6 @@
- name: "Configure dozzle application" - name: "Configure dozzle application"
hosts: all hosts: all
vars_files:
- vars/secrets.yml
- vars/vars.yml
vars: vars:
app_name: "dozzle" app_name: "dozzle"
app_user: "{{ app_name }}" app_user: "{{ app_name }}"
@@ -21,7 +17,6 @@
owner_name: "{{ app_user }}" owner_name: "{{ app_user }}"
owner_uid: "{{ app_owner_uid }}" owner_uid: "{{ app_owner_uid }}"
owner_gid: "{{ app_owner_gid }}" owner_gid: "{{ app_owner_gid }}"
owner_extra_groups: ["docker"]
- name: "Create internal application directories" - name: "Create internal application directories"
ansible.builtin.file: ansible.builtin.file:
+14 -70
View File
@@ -1,78 +1,22 @@
--- ---
- name: "Install eget" - name: "Install eget and pinned binaries"
hosts: all hosts: all
vars_files: # Версии инструментов: https://github.com/<repo>/releases
- vars/secrets.yml
- vars/vars.yml
# See: https://github.com/zyedidia/eget/releases
vars:
eget_install_dir: "{{ bin_prefix }}"
eget_bin_path: '{{ (eget_install_dir, "eget") | path_join }}'
tasks: tasks:
- name: "Install eget" - name: "Install eget and tools"
ansible.builtin.import_role: ansible.builtin.import_role:
name: eget name: eget
vars: vars:
eget_version: "1.3.4" eget_install_path: '{{ (bin_prefix, "eget") | path_join }}'
eget_install_path: "{{ eget_bin_path }}" eget_packages_to: "{{ bin_prefix }}"
eget_packages:
- name: "Install rclone" - {repo: "rclone/rclone", tag: "v1.75.0", asset: "zip"}
ansible.builtin.command: - {repo: "restic/restic", tag: "v0.19.1"}
cmd: > - {repo: "creativeprojects/resticprofile", tag: "v0.33.1", asset: "^no_self_update"}
{{ eget_bin_path }} rclone/rclone --quiet --upgrade-only --to {{ eget_install_dir }} --asset zip - {repo: "aristocratos/btop", tag: "v1.4.7"}
--tag v1.73.4 - {repo: "gobackup/gobackup", tag: "v3.1.1"}
changed_when: false - {repo: "go-task/task", tag: "v3.52.0", asset: "tar.gz"}
- {repo: "bootandy/dust", tag: "v1.2.4", asset: "gnu"}
- name: "Install restic" - {repo: "zellij-org/zellij", tag: "v0.44.3", asset: "no-web"}
ansible.builtin.command:
cmd: >
{{ eget_bin_path }} restic/restic --quiet --upgrade-only --to {{ eget_install_dir }}
--tag v0.18.1
changed_when: false
- name: "Install resticprofile"
ansible.builtin.command:
cmd: >
{{ eget_bin_path }} creativeprojects/resticprofile --quiet --upgrade-only --to {{ eget_install_dir }}
--asset '^no_self_update'
--tag v0.32.0
changed_when: false
- name: "Install btop"
ansible.builtin.command:
cmd: >
{{ eget_bin_path }} aristocratos/btop --quiet --upgrade-only --to {{ eget_install_dir }}
--tag v1.4.6
changed_when: false
- name: "Install gobackup"
ansible.builtin.command:
cmd: >
{{ eget_bin_path }} gobackup/gobackup --quiet --upgrade-only --to {{ eget_install_dir }}
--tag v2.17.0
changed_when: false
- name: "Install task"
ansible.builtin.command:
cmd: >
{{ eget_bin_path }} go-task/task --quiet --upgrade-only --to {{ eget_install_dir }} --asset tar.gz
--tag v3.48.0
changed_when: false
- name: 'Install dust'
ansible.builtin.command:
cmd: >
{{ eget_bin_path }} bootandy/dust --quiet --upgrade-only --to {{ bin_prefix }} --asset gnu
--tag v1.2.4
changed_when: false
- name: 'Install zellij'
ansible.builtin.command:
cmd: >
{{ eget_bin_path }} zellij-org/zellij --quiet --upgrade-only --to {{ bin_prefix }} --asset no-web
--tag v0.43.1
changed_when: false
+7 -4
View File
@@ -2,10 +2,6 @@
- name: "Configure gitea application" - name: "Configure gitea application"
hosts: all hosts: all
vars_files:
- vars/secrets.yml
- vars/vars.yml
vars: vars:
app_name: "gitea" app_name: "gitea"
app_user: "{{ app_name }}" app_user: "{{ app_name }}"
@@ -23,6 +19,10 @@
owner_name: "{{ app_user }}" owner_name: "{{ app_user }}"
owner_uid: "{{ app_owner_uid }}" owner_uid: "{{ app_owner_uid }}"
owner_gid: "{{ app_owner_gid }}" owner_gid: "{{ app_owner_gid }}"
# Группа docker = root на хосте, поэтому её не выдаём никому, кроме
# приложений, чей backup.sh делает docker compose exec: оркестратор
# запускает такие скрипты через su от имени этого пользователя.
# См. docs/backlog/backup-dump-without-docker-group.md.
owner_extra_groups: ["docker"] owner_extra_groups: ["docker"]
- name: "Create internal application directories" - name: "Create internal application directories"
@@ -52,6 +52,9 @@
owner: "{{ app_user }}" owner: "{{ app_user }}"
group: "{{ app_user }}" group: "{{ app_user }}"
mode: "0640" mode: "0640"
# Секреты стоят прямыми значениями в environment: — без no_log их
# печатает --diff. Снять, когда они переедут в файлы.
no_log: true
- name: "Run application with docker compose" - name: "Run application with docker compose"
community.docker.docker_compose_v2: community.docker.docker_compose_v2:
+3 -5
View File
@@ -2,10 +2,6 @@
- name: "Configure goaccess application" - name: "Configure goaccess application"
hosts: all hosts: all
vars_files:
- vars/secrets.yml
- vars/vars.yml
vars: vars:
app_name: "goaccess" app_name: "goaccess"
app_user: "{{ app_name }}" app_user: "{{ app_name }}"
@@ -24,7 +20,6 @@
owner_name: "{{ app_user }}" owner_name: "{{ app_user }}"
owner_uid: "{{ app_owner_uid }}" owner_uid: "{{ app_owner_uid }}"
owner_gid: "{{ app_owner_gid }}" owner_gid: "{{ app_owner_gid }}"
owner_extra_groups: ["docker"]
- name: "Create internal application directories" - name: "Create internal application directories"
ansible.builtin.file: ansible.builtin.file:
@@ -87,6 +82,9 @@
- { name: "Dockerfile", mode: "0640" } - { name: "Dockerfile", mode: "0640" }
- { name: "entrypoint.sh", mode: "0750" } - { name: "entrypoint.sh", mode: "0750" }
# Handler рестарта здесь не нужен: конфиг в контейнер не монтируется, а
# правку Dockerfile/entrypoint.sh применяет `build: always` — пересобранный
# образ получает новый id, и compose пересоздаёт по нему контейнер (проверено).
- name: "Run application with docker compose" - name: "Run application with docker compose"
community.docker.docker_compose_v2: community.docker.docker_compose_v2:
project_src: "{{ base_dir }}" project_src: "{{ base_dir }}"
+163
View File
@@ -0,0 +1,163 @@
---
- name: "Configure goatcounter application"
hosts: all
# goatcounter_sites приходит из group_vars/all/main.yml (его же читает
# caddyproxy), goatcounter_admin_email/password — из vault-файла secrets.yml.
vars:
app_name: "goatcounter"
# Родительский сайт: владелец пользователя, к нему линкуются остальные.
goatcounter_parent_vhost: "{{ goatcounter_sites[0] }}"
app_user: "{{ app_name }}"
app_owner_uid: 1110
app_owner_gid: 1110
base_dir: "{{ (application_dir, app_name) | path_join }}"
data_dir: "{{ (base_dir, 'data') | path_join }}"
# Логин и пароль Postbox goatcounter принимает только внутри SMTP-URL, в
# userinfo, — значит их надо процентно экранировать. Фильтр urlencode
# оставляет `/` как есть (safe='/'), поэтому дожимаем его вручную.
postbox_user_enc: "{{ postbox_user | urlencode | replace('/', '%2F') }}"
postbox_pass_enc: "{{ postbox_pass | urlencode | replace('/', '%2F') }}"
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 }}"
- name: "Create application internal directories"
ansible.builtin.file:
path: "{{ item }}"
state: "directory"
owner: "{{ app_user }}"
group: "{{ app_user }}"
mode: "0750"
loop:
- "{{ base_dir }}"
- "{{ data_dir }}"
# В data лежит только SQLite-база; бэкапим её restic'ом напрямую, без
# промежуточного дампа — как у tududi. Записи редкие (пачка просмотров раз
# в 10 секунд), ночной прогон почти наверняка застаёт базу в покое, а
# retention страхует от редкого несогласованного снимка.
- name: "Create backup targets file"
ansible.builtin.copy:
dest: "{{ base_dir }}/backup-targets"
content: |
{{ data_dir }}
owner: "{{ app_user }}"
group: "{{ app_user }}"
mode: "0640"
- 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"
# SMTP-пароль стоит прямым значением в environment: — без no_log его
# печатает --diff. Файловых секретов goatcounter не умеет.
no_log: true
- name: "Run application with docker compose"
community.docker.docker_compose_v2:
project_src: "{{ base_dir }}"
state: "present"
remove_orphans: true
tags:
- run-app
# Сайты заводятся по разу, в том же прогоне, что поднимает контейнер: пока
# сайта нет в базе, goatcounter отдаёт на его домене открытый мастер
# регистрации, и это окно надо закрыть сразу.
#
# `db show site` различает три состояния по rc и stderr: rc=0 — сайт есть;
# rc=1 + "nothing found" — база готова, сайта нет; rc=1 + "doesn't exist" —
# serve ещё не накатил миграции. Последнее и есть гонка со стартом
# контейнера, поэтому ждём в until, пока ответ не станет осмысленным.
- name: "Check which goatcounter sites exist"
community.docker.docker_container_exec:
container: "{{ app_name }}_app"
argv:
- "goatcounter"
- "db"
- "show"
- "site"
- "-find"
- "{{ item }}"
loop: "{{ goatcounter_sites }}"
register: "goatcounter_sites_state"
changed_when: false
failed_when: false
until: "goatcounter_sites_state.rc == 0 or 'nothing found' in goatcounter_sites_state.stderr"
retries: 10
delay: 3
tags:
- run-app
# Пароль нужен только когда заводим родительский сайт: дочерние идут с
# -link и своих пользователей не получают.
- name: "Check credentials for the first goatcounter user are set"
ansible.builtin.assert:
that:
- "goatcounter_admin_email | default('') is match('[^@]+@[^@]+')"
- "goatcounter_admin_password | default('') | length >= 8"
fail_msg: >-
Сайт {{ goatcounter_parent_vhost }} ещё не заведён, а
goatcounter_admin_email / goatcounter_admin_password не заданы.
Пропишите их в group_vars/all/secrets.yml и повторите прогон.
when: "goatcounter_sites_state.results[0].rc != 0"
tags:
- run-app
# Вход дальше — своей парой email/пароль на https://{{ goatcounter_parent_vhost }};
# Authelia тут не участвует, эндпоинт /count обязан быть публичным.
- name: "Create the parent goatcounter site and its user"
community.docker.docker_container_exec:
container: "{{ app_name }}_app"
argv:
- "goatcounter"
- "db"
- "create"
- "site"
- "-vhost"
- "{{ goatcounter_parent_vhost }}"
- "-user.email"
- "{{ goatcounter_admin_email }}"
- "-password"
- "{{ goatcounter_admin_password }}"
when: "goatcounter_sites_state.results[0].rc != 0"
changed_when: true
# Пароль стоит прямым значением в argv — без no_log его печатает --diff.
no_log: true
tags:
- run-app
# -link отдаёт дочернему сайту пользователей родителя и ставит его в
# переключалку сайтов в шапке. Данные при этом раздельные: всё, что видно
# в дашборде, скоупится по site_id.
- name: "Create linked goatcounter sites"
community.docker.docker_container_exec:
container: "{{ app_name }}_app"
argv:
- "goatcounter"
- "db"
- "create"
- "site"
- "-vhost"
- "{{ item.item }}"
- "-link"
- "{{ goatcounter_parent_vhost }}"
loop: "{{ goatcounter_sites_state.results[1:] }}"
loop_control:
label: "{{ item.item }}"
when: "item.rc != 0"
changed_when: true
tags:
- run-app
+10 -14
View File
@@ -2,10 +2,6 @@
- name: "Configure gramps application" - name: "Configure gramps application"
hosts: all hosts: all
vars_files:
- vars/secrets.yml
- vars/vars.yml
vars: vars:
app_name: "gramps" app_name: "gramps"
app_user: "{{ app_name }}" app_user: "{{ app_name }}"
@@ -26,7 +22,6 @@
owner_name: "{{ app_user }}" owner_name: "{{ app_user }}"
owner_uid: "{{ app_owner_uid }}" owner_uid: "{{ app_owner_uid }}"
owner_gid: "{{ app_owner_gid }}" owner_gid: "{{ app_owner_gid }}"
owner_extra_groups: ["docker"]
- name: "Create application internal directories" - name: "Create application internal directories"
ansible.builtin.file: ansible.builtin.file:
@@ -59,17 +54,15 @@
mode: "0750" mode: "0750"
- name: "Create backup targets file" - name: "Create backup targets file"
ansible.builtin.lineinfile: ansible.builtin.copy:
path: "{{ base_dir }}/backup-targets" dest: "{{ base_dir }}/backup-targets"
line: "{{ item }}" content: |
create: true {{ data_dir }}
{{ media_dir }}
{{ backups_dir }}
owner: "{{ app_user }}" owner: "{{ app_user }}"
group: "{{ app_user }}" group: "{{ app_user }}"
mode: "0750" mode: "0640"
loop:
- "{{ data_dir }}"
- "{{ media_dir }}"
- "{{ backups_dir }}"
- name: "Copy rename script" - name: "Copy rename script"
ansible.builtin.copy: ansible.builtin.copy:
@@ -86,6 +79,9 @@
owner: "{{ app_user }}" owner: "{{ app_user }}"
group: "{{ app_user }}" group: "{{ app_user }}"
mode: "0640" mode: "0640"
# Секреты стоят прямыми значениями в environment: — без no_log их
# печатает --diff. Снять, когда они переедут в файлы.
no_log: true
- name: "Run application with docker compose" - name: "Run application with docker compose"
community.docker.docker_compose_v2: community.docker.docker_compose_v2:
-20
View File
@@ -1,20 +0,0 @@
---
- name: "Upload local homepage images to registry"
hosts: all
gather_facts: false
vars_files:
- vars/secrets.yml
- vars/vars.yml
- vars/homepage.yml
tasks:
- name: "Push web service image to remote registry"
community.docker.docker_image:
state: present
source: local
name: "{{ homepage_nginx_image }}"
repository: "{{ registry_homepage_nginx_image }}"
push: true
delegate_to: 127.0.0.1
+10 -10
View File
@@ -3,11 +3,19 @@
hosts: all hosts: all
vars_files: vars_files:
- vars/secrets.yml
- vars/vars.yml
- vars/homepage.yml - vars/homepage.yml
tasks: tasks:
# Образ собирается на control-хосте самим репозиторием homepage (контракт —
# `task image`: сборка сайта astro + nginx-образ) и едет на сервер через
# docker save/load, без реестра. Роль выставляет факт app_image_tag.
- name: "Build and deliver application image"
ansible.builtin.import_role:
name: "app_image"
vars:
app_image_name: "{{ homepage_image_name }}"
app_image_src_dir: '{{ (playbook_dir, "..", "homepage") | path_join }}'
- name: "Create user and environment" - name: "Create user and environment"
ansible.builtin.import_role: ansible.builtin.import_role:
name: owner name: owner
@@ -15,7 +23,6 @@
owner_name: "{{ app_user }}" owner_name: "{{ app_user }}"
owner_uid: "{{ app_owner_uid }}" owner_uid: "{{ app_owner_uid }}"
owner_gid: "{{ app_owner_gid }}" owner_gid: "{{ app_owner_gid }}"
owner_extra_groups: ["docker"]
- name: "Create application internal directories" - name: "Create application internal directories"
ansible.builtin.file: ansible.builtin.file:
@@ -27,12 +34,6 @@
loop: loop:
- "{{ base_dir }}" - "{{ base_dir }}"
- name: "Login to Yandex Container Registry"
community.docker.docker_login:
registry_url: "{{ yc_container_registry }}"
username: "oauth"
password: "{{ yc_oauth_token }}"
- name: "Copy docker compose file" - name: "Copy docker compose file"
ansible.builtin.template: ansible.builtin.template:
src: "./files/{{ app_name }}/docker-compose.template.yml" src: "./files/{{ app_name }}/docker-compose.template.yml"
@@ -46,6 +47,5 @@
project_src: "{{ base_dir }}" project_src: "{{ base_dir }}"
state: "present" state: "present"
remove_orphans: true remove_orphans: true
pull: "always"
tags: tags:
- run-app - run-app
+6 -14
View File
@@ -2,10 +2,6 @@
- name: "Configure memos application" - name: "Configure memos application"
hosts: all hosts: all
vars_files:
- vars/secrets.yml
- vars/vars.yml
vars: vars:
app_name: "memos" app_name: "memos"
app_user: "{{ app_name }}" app_user: "{{ app_name }}"
@@ -24,7 +20,6 @@
owner_name: "{{ app_user }}" owner_name: "{{ app_user }}"
owner_uid: "{{ app_owner_uid }}" owner_uid: "{{ app_owner_uid }}"
owner_gid: "{{ app_owner_gid }}" owner_gid: "{{ app_owner_gid }}"
owner_extra_groups: ["docker"]
- name: "Create application internal directories" - name: "Create application internal directories"
ansible.builtin.file: ansible.builtin.file:
@@ -55,16 +50,14 @@
mode: "0750" mode: "0750"
- name: "Create backup targets file" - name: "Create backup targets file"
ansible.builtin.lineinfile: ansible.builtin.copy:
path: "{{ base_dir }}/backup-targets" dest: "{{ base_dir }}/backup-targets"
line: "{{ item }}" content: |
create: true {{ data_dir }}
{{ backups_dir }}
owner: "{{ app_user }}" owner: "{{ app_user }}"
group: "{{ app_user }}" group: "{{ app_user }}"
mode: "0750" mode: "0640"
loop:
- "{{ data_dir }}"
- "{{ backups_dir }}"
- name: "Copy docker compose file" - name: "Copy docker compose file"
ansible.builtin.template: ansible.builtin.template:
@@ -73,7 +66,6 @@
owner: "{{ app_user }}" owner: "{{ app_user }}"
group: "{{ app_user }}" group: "{{ app_user }}"
mode: "0640" mode: "0640"
register: docker_compose_file_result
- name: "Run application with docker compose" - name: "Run application with docker compose"
community.docker.docker_compose_v2: community.docker.docker_compose_v2:
+23 -5
View File
@@ -2,10 +2,6 @@
- name: "Configure miniflux application" - name: "Configure miniflux application"
hosts: all hosts: all
vars_files:
- vars/secrets.yml
- vars/vars.yml
vars: vars:
app_name: "miniflux" app_name: "miniflux"
app_user: "{{ app_name }}" app_user: "{{ app_name }}"
@@ -25,6 +21,10 @@
owner_name: "{{ app_user }}" owner_name: "{{ app_user }}"
owner_uid: "{{ app_owner_uid }}" owner_uid: "{{ app_owner_uid }}"
owner_gid: "{{ app_owner_gid }}" owner_gid: "{{ app_owner_gid }}"
# Группа docker = root на хосте, поэтому её не выдаём никому, кроме
# приложений, чей backup.sh делает docker compose exec: оркестратор
# запускает такие скрипты через su от имени этого пользователя.
# См. docs/backlog/backup-dump-without-docker-group.md.
owner_extra_groups: ["docker"] owner_extra_groups: ["docker"]
- name: "Create internal directories" - name: "Create internal directories"
@@ -41,9 +41,13 @@
- "{{ postgres_data_dir }}" - "{{ postgres_data_dir }}"
- "{{ postgres_backups_dir }}" - "{{ postgres_backups_dir }}"
# notify на import_role наследуется всеми задачами роли: рестарт нужен,
# если изменился любой файл секретов — miniflux читает их через *_FILE
# только при старте.
- name: "Copy secrets" - name: "Copy secrets"
ansible.builtin.import_role: ansible.builtin.import_role:
name: secrets name: secrets
notify: "Restart application"
vars: vars:
secrets_dest: "{{ secrets_dir }}" secrets_dest: "{{ secrets_dir }}"
secrets_user: "{{ app_user }}" secrets_user: "{{ app_user }}"
@@ -76,7 +80,21 @@
community.docker.docker_compose_v2: community.docker.docker_compose_v2:
project_src: "{{ base_dir }}" project_src: "{{ base_dir }}"
state: "present" state: "present"
recreate: "always"
remove_orphans: true remove_orphans: true
tags: tags:
- run-app - run-app
handlers:
# Приложение читает секреты только при старте, а `state: present` не
# пересоздаёт контейнер, если docker-compose.yml не изменился, — поэтому
# правку применяет только явный рестарт.
- name: "Restart application"
community.docker.docker_compose_v2:
project_src: "{{ base_dir }}"
# Только сам miniflux: базу дёргать незачем, пароль postgres читается
# из тех же секретов лишь при инициализации кластера.
services:
- "miniflux_app"
state: "restarted"
tags:
- run-app
+21 -14
View File
@@ -2,10 +2,6 @@
- name: "Install Netdata monitoring service" - name: "Install Netdata monitoring service"
hosts: all hosts: all
vars_files:
- vars/secrets.yml
- vars/vars.yml
vars: vars:
app_name: "netdata" app_name: "netdata"
app_user: "{{ app_name }}" app_user: "{{ app_name }}"
@@ -25,7 +21,6 @@
owner_name: "{{ app_user }}" owner_name: "{{ app_user }}"
owner_uid: "{{ app_owner_uid }}" owner_uid: "{{ app_owner_uid }}"
owner_gid: "{{ app_owner_gid }}" owner_gid: "{{ app_owner_gid }}"
owner_extra_groups: ["docker"]
- name: "Create internal application directories" - name: "Create internal application directories"
ansible.builtin.file: ansible.builtin.file:
@@ -48,6 +43,7 @@
owner: "{{ app_user }}" owner: "{{ app_user }}"
group: "{{ app_user }}" group: "{{ app_user }}"
mode: "0640" mode: "0640"
notify: "Restart application"
- name: "Find all go.d plugin config files" - name: "Find all go.d plugin config files"
ansible.builtin.find: ansible.builtin.find:
@@ -64,6 +60,7 @@
group: "{{ app_user }}" group: "{{ app_user }}"
mode: "0640" mode: "0640"
loop: "{{ go_d_source_files.files }}" loop: "{{ go_d_source_files.files }}"
notify: "Restart application"
- name: "Find existing go.d config files on server" - name: "Find existing go.d config files on server"
ansible.builtin.find: ansible.builtin.find:
@@ -77,6 +74,7 @@
state: absent state: absent
loop: "{{ go_d_existing_files.files }}" loop: "{{ go_d_existing_files.files }}"
when: (item.path | basename) not in (go_d_source_files.files | map(attribute='path') | map('basename') | list) when: (item.path | basename) not in (go_d_source_files.files | map(attribute='path') | map('basename') | list)
notify: "Restart application"
- name: "Find all health.d config files" - name: "Find all health.d config files"
ansible.builtin.find: ansible.builtin.find:
@@ -93,6 +91,7 @@
group: "{{ app_user }}" group: "{{ app_user }}"
mode: "0640" mode: "0640"
loop: "{{ health_d_source_files.files }}" loop: "{{ health_d_source_files.files }}"
notify: "Restart application"
- name: "Find existing health.d config files on server" - name: "Find existing health.d config files on server"
ansible.builtin.find: ansible.builtin.find:
@@ -106,6 +105,7 @@
state: absent state: absent
loop: "{{ health_d_existing_files.files }}" loop: "{{ health_d_existing_files.files }}"
when: (item.path | basename) not in (health_d_source_files.files | map(attribute='path') | map('basename') | list) when: (item.path | basename) not in (health_d_source_files.files | map(attribute='path') | map('basename') | list)
notify: "Restart application"
- name: "Copy health alarm notify config" - name: "Copy health alarm notify config"
ansible.builtin.template: ansible.builtin.template:
@@ -114,15 +114,18 @@
owner: "{{ app_user }}" owner: "{{ app_user }}"
group: "{{ app_user }}" group: "{{ app_user }}"
mode: "0640" mode: "0640"
notify: "Restart application"
- name: "Grab docker group id." - name: "Grab docker group info"
ansible.builtin.shell: ansible.builtin.getent:
cmd: | database: "group"
set -o pipefail key: "docker"
grep docker /etc/group | cut -d ':' -f 3
executable: /bin/bash # getent_group отдаёт поля строки /etc/group без имени группы,
register: netdata_docker_group_output # то есть [пароль, gid, участники] — gid лежит под индексом 1.
changed_when: netdata_docker_group_output.rc != 0 - name: "Store docker group id"
ansible.builtin.set_fact:
netdata_docker_gid: "{{ ansible_facts.getent_group['docker'][1] }}"
- name: "Copy docker compose file" - name: "Copy docker compose file"
ansible.builtin.template: ansible.builtin.template:
@@ -140,7 +143,11 @@
tags: tags:
- run-app - run-app
- name: "Restart application with docker compose" handlers:
# Приложение читает конфиги только при старте, а `state: present` не
# пересоздаёт контейнер, если docker-compose.yml не изменился, — поэтому
# правку конфига применяет только явный рестарт.
- name: "Restart application"
community.docker.docker_compose_v2: community.docker.docker_compose_v2:
project_src: "{{ base_dir }}" project_src: "{{ base_dir }}"
state: "restarted" state: "restarted"
+13 -12
View File
@@ -2,10 +2,6 @@
- name: "Configure outline application" - name: "Configure outline application"
hosts: all hosts: all
vars_files:
- vars/secrets.yml
- vars/vars.yml
vars: vars:
app_name: "outline" app_name: "outline"
app_user: "{{ app_name }}" app_user: "{{ app_name }}"
@@ -29,6 +25,10 @@
owner_name: "{{ app_user }}" owner_name: "{{ app_user }}"
owner_uid: "{{ app_owner_uid }}" owner_uid: "{{ app_owner_uid }}"
owner_gid: "{{ app_owner_gid }}" owner_gid: "{{ app_owner_gid }}"
# Группа docker = root на хосте, поэтому её не выдаём никому, кроме
# приложений, чей backup.sh делает docker compose exec: оркестратор
# запускает такие скрипты через su от имени этого пользователя.
# См. docs/backlog/backup-dump-without-docker-group.md.
owner_extra_groups: ["docker"] owner_extra_groups: ["docker"]
- name: "Create internal directories" - name: "Create internal directories"
@@ -55,16 +55,14 @@
mode: "0750" mode: "0750"
- name: "Create backup targets file" - name: "Create backup targets file"
ansible.builtin.lineinfile: ansible.builtin.copy:
path: "{{ base_dir }}/backup-targets" dest: "{{ base_dir }}/backup-targets"
line: "{{ item }}" content: |
create: true {{ media_dir }}
{{ backups_dir }}
owner: "{{ app_user }}" owner: "{{ app_user }}"
group: "{{ app_user }}" group: "{{ app_user }}"
mode: "0750" mode: "0640"
loop:
- "{{ media_dir }}"
- "{{ backups_dir }}"
- name: "Copy docker compose file" - name: "Copy docker compose file"
ansible.builtin.template: ansible.builtin.template:
@@ -73,6 +71,9 @@
owner: "{{ app_user }}" owner: "{{ app_user }}"
group: "{{ app_user }}" group: "{{ app_user }}"
mode: "0640" mode: "0640"
# Секреты стоят прямыми значениями в environment: — без no_log их
# печатает --diff. Снять, когда они переедут в файлы.
no_log: true
- name: "Run application with docker compose" - name: "Run application with docker compose"
community.docker.docker_compose_v2: community.docker.docker_compose_v2:
+18 -17
View File
@@ -2,10 +2,6 @@
- name: "Configure remembos application" - name: "Configure remembos application"
hosts: all hosts: all
vars_files:
- vars/secrets.yml
- vars/vars.yml
vars: vars:
app_name: "remembos" app_name: "remembos"
app_user: "{{ app_name }}" app_user: "{{ app_name }}"
@@ -17,6 +13,16 @@
config_file: "{{ (config_dir, 'config.toml') | path_join }}" config_file: "{{ (config_dir, 'config.toml') | path_join }}"
tasks: tasks:
# Образ собирается на control-хосте самим репозиторием remembos (контракт —
# `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, "..", "remembos") | path_join }}'
- name: "Create user and environment" - name: "Create user and environment"
ansible.builtin.import_role: ansible.builtin.import_role:
name: owner name: owner
@@ -24,7 +30,6 @@
owner_name: "{{ app_user }}" owner_name: "{{ app_user }}"
owner_uid: "{{ app_owner_uid }}" owner_uid: "{{ app_owner_uid }}"
owner_gid: "{{ app_owner_gid }}" owner_gid: "{{ app_owner_gid }}"
owner_extra_groups: ["docker"]
- name: "Create application internal directories" - name: "Create application internal directories"
ansible.builtin.file: ansible.builtin.file:
@@ -45,7 +50,9 @@
owner: "{{ app_user }}" owner: "{{ app_user }}"
group: "{{ app_user }}" group: "{{ app_user }}"
mode: "0640" mode: "0640"
register: config_file_result # Файл содержит секреты из vault — без no_log их печатает --diff.
no_log: true
notify: "Restart application"
- name: "Copy docker compose file" - name: "Copy docker compose file"
ansible.builtin.template: ansible.builtin.template:
@@ -54,13 +61,6 @@
owner: "{{ app_user }}" owner: "{{ app_user }}"
group: "{{ app_user }}" group: "{{ app_user }}"
mode: "0640" mode: "0640"
register: docker_compose_file_result
- name: 'Login to Yandex Container Registry'
community.docker.docker_login:
registry_url: '{{ yc_container_registry }}'
username: 'oauth'
password: '{{ yc_oauth_token }}'
- name: "Run application with docker compose" - name: "Run application with docker compose"
community.docker.docker_compose_v2: community.docker.docker_compose_v2:
@@ -70,12 +70,13 @@
tags: tags:
- run-app - run-app
- name: "Restart docker compose services if config changed but not docker-compose.yml" handlers:
# Приложение читает конфиги только при старте, а `state: present` не
# пересоздаёт контейнер, если docker-compose.yml не изменился, — поэтому
# правку конфига применяет только явный рестарт.
- name: "Restart application"
community.docker.docker_compose_v2: community.docker.docker_compose_v2:
project_src: "{{ base_dir }}" project_src: "{{ base_dir }}"
state: "restarted" state: "restarted"
when:
- config_file_result.changed
- not docker_compose_file_result.changed
tags: tags:
- run-app - run-app
+1 -5
View File
@@ -1,11 +1,7 @@
--- ---
- name: "Update and upgrade system packages" - name: "Remove user and application"
hosts: all hosts: all
vars_files:
- vars/secrets.yml
- vars/vars.yml
vars: vars:
user_name: "<put-name-here>" user_name: "<put-name-here>"
-5
View File
@@ -2,10 +2,6 @@
- name: "Configure rssbridge application" - name: "Configure rssbridge application"
hosts: all hosts: all
vars_files:
- vars/secrets.yml
- vars/vars.yml
vars: vars:
app_name: "rssbridge" app_name: "rssbridge"
app_user: "{{ app_name }}" app_user: "{{ app_name }}"
@@ -21,7 +17,6 @@
owner_name: "{{ app_user }}" owner_name: "{{ app_user }}"
owner_uid: "{{ app_owner_uid }}" owner_uid: "{{ app_owner_uid }}"
owner_gid: "{{ app_owner_gid }}" owner_gid: "{{ app_owner_gid }}"
owner_extra_groups: ["docker"]
- name: "Create internal application directories" - name: "Create internal application directories"
ansible.builtin.file: ansible.builtin.file:
-4
View File
@@ -2,10 +2,6 @@
- name: "Configure base system parameters" - name: "Configure base system parameters"
hosts: all hosts: all
vars_files:
- vars/secrets.yml
- vars/vars.yml
vars: vars:
apt_packages: apt_packages:
- acl - acl

Some files were not shown because too many files have changed in this diff Show More