Compare commits

...
30 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
69 changed files with 1346 additions and 762 deletions
+14 -4
View File
@@ -15,7 +15,7 @@ Ansible-проект для автоматизации личного серве
- `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`.
@@ -71,6 +71,7 @@ uv run ansible-galaxy install --role-file requirements.yml
- `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` — транскрибация (образ собирается локально ролью `app_image`). - `playbook-transcriber.yml` — транскрибация (образ собирается локально ролью `app_image`).
@@ -99,6 +100,8 @@ uv run ansible-galaxy install --role-file requirements.yml
- Суффиксы шаблонов: `.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`). - Большинство приложений определяют переменные inline в плейбуке. Отдельные файлы переменных только у homepage и transcriber (`vars/homepage.yml`, `vars/transcriber.yml`).
- `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`. - Общие переменные из `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`.
@@ -106,10 +109,13 @@ uv run ansible-galaxy install --role-file requirements.yml
## Линтинг и 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-файлах.
@@ -146,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); если его нет, прогон продолжается
без размеров.
+1 -7
View File
@@ -17,24 +17,20 @@ _(sec-ревью)_ — три независимых security-ревью на м
## Высокий ## Высокий
- [Закрыть admin API Caddy (localhost вместо всей сети)](caddy-admin-api-localhost.md) — `admin :2019` слушает всю docker-сеть; любой контейнер может переписать маршрутизацию и снять forward_auth _(sec-ревью)_
- [Убрать группу `docker` у сервисных пользователей](service-users-docker-group.md) — `docker` = root на хосте; ~все app-аккаунты фактически root, изоляция «юзер на сервис» обнуляется _(sec-ревью)_
- [Изолировать/запаролить сессионный redis Authelia](authelia-redis-isolation.md) — стор сессий SSO в общей сети без пароля; запись в него = hijack SSO всех сервисов _(sec-ревью)_ - [Изолировать/запаролить сессионный redis Authelia](authelia-redis-isolation.md) — стор сессий SSO в общей сети без пароля; запись в него = hijack SSO всех сервисов _(sec-ревью)_
- [Алерты на проблемные контейнеры](container-alerts.md) — wakapi крутился в restart-loop несколько дней незамеченным; healthcheck в compose + алерты Netdata - [Алерты на проблемные контейнеры](container-alerts.md) — wakapi крутился в restart-loop несколько дней незамеченным; healthcheck в compose + алерты Netdata
- [Никаких секретов в выводе плейбуков](no-secrets-in-playbook-output.md) — `inv pl` всегда с `--diff`, и каждая задача, рендерящая файл с секретом, печатает его в терминал; `no_log` стоит ровно в одном месте из четырнадцати - [Никаких секретов в выводе плейбуков](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-ревью)_ - [Захардить мониторинг-стек (netdata / dozzle / goaccess)](harden-monitoring-stack.md) — netdata/dozzle: лишние привилегии + docker.sock + доверие Remote-User в общей сети; socket-proxy + monitoring_network _(sec-ревью)_
- [Ограничить эндпоинт `/metrics` Miniflux](miniflux-metrics-restrict.md) — `METRICS_ALLOWED_NETWORKS=0.0.0.0/0` + прокси без ограничения пути отдаёт метрики публично _(sec-ревью)_
- [Закрыть calibre-web forward-auth и проверить дефолтные креды](calibre-forward-auth.md) — домен без forward_auth, защита на встроенной авторизации с дефолтом `admin/admin123` _(sec-ревью)_ - [Закрыть calibre-web forward-auth и проверить дефолтные креды](calibre-forward-auth.md) — домен без forward_auth, защита на встроенной авторизации с дефолтом `admin/admin123` _(sec-ревью)_
- [Понизить log level Authelia с debug до info](authelia-log-level.md) — `debug` в проде утекает детали аутентификации и OIDC через `docker logs`/dozzle _(sec-ревью)_
- [Включить автообновления безопасности](security-autoupdates.md) — патчи openssh/libssl только вручную; окна в недели между CVE и фиксом _(sec-ревью)_ - [Включить автообновления безопасности](security-autoupdates.md) — патчи openssh/libssl только вручную; окна в недели между CVE и фиксом _(sec-ревью)_
- [Запретить SSH-вход под root](ssh-disable-root-login.md) — `PermitRootLogin yes` пускает root по ключу; наименьшие привилегии + defense-in-depth (пароль уже off) _(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% - [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 - [Синхронизация общих Ansible-ролей между репозиториями](shared-roles-sync.md) — `owner`/`eget`/`secrets` дублируются между репозиториями и дрейфуют; канон в ansible-shared + rsync-таски invoke
- [Уведомления о размере приложений и заполнении диска](disk-usage-alerts.md) — алерты на заполнение диска и аномальный рост data-томов; сервер ресурсно ограничен _(tududi)_ - [Уведомления о размере приложений и заполнении диска](disk-usage-alerts.md) — алерты на заполнение диска и аномальный рост data-томов; сервер ресурсно ограничен _(tududi)_
- [Handlers рестарта для оставшихся приложений](handlers-remaining-apps.md) — apprise/dashboard/transcriber/miniflux монтируют конфиг в контейнер, но рестарта нет вовсе; правка конфига не подхватывается
- [Composable-роль `backup`](ansible-backup-role.md) — бэкап — самый чистый шов для extraction (одинаков у всех, различается только список targets) _(ансибл-ревью)_ - [Composable-роль `backup`](ansible-backup-role.md) — бэкап — самый чистый шов для extraction (одинаков у всех, различается только список targets) _(ансибл-ревью)_
- [Вывести из оборота секреты с широкими правами](narrow-secret-scopes.md) — `yc_oauth_token` (весь аккаунт Yandex Cloud) уже выведен; осталось проставить радиус остальным переменным vault и сузить широкие - [Вывести из оборота секреты с широкими правами](narrow-secret-scopes.md) — `yc_oauth_token` (весь аккаунт Yandex Cloud) уже выведен; осталось проставить радиус остальным переменным vault и сузить широкие
- [outline: секреты из файлов](secrets-file-outline.md) — умеет generic `<NAME>_FILE` для всех пяти секретов; единственное приложение, где выносится вообще всё, включая пароль БД - [outline: секреты из файлов](secrets-file-outline.md) — умеет generic `<NAME>_FILE` для всех пяти секретов; единственное приложение, где выносится вообще всё, включая пароль БД
@@ -44,10 +40,8 @@ _(sec-ревью)_ — три независимых security-ревью на м
## Низкий ## Низкий
- [Мелкий хардненинг Caddy](caddy-hardening-nits.md) — HSTS-заголовок, убрать лишний `NET_ADMIN`, убрать leftover-листенер `:29999` _(sec-ревью)_
- [Мелкий хардненинг контейнеров](container-hardening-nits.md) — `no-new-privileges`, non-root `user:`, homepage off `:latest`, соглашение о `127.0.0.1:` портах _(sec-ревью)_ - [Мелкий хардненинг контейнеров](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-ревью)_ - [Унифицировать ACL техпанелей Authelia](authelia-acl-unify.md) — rssbridge (SSRF) и dashboard за `one_factor` без subject; свести к `group:admins` + `two_factor` _(sec-ревью)_
- [Причесать роль `owner` под конвенции](ansible-owner-role-cleanup.md) — `assert` вместо `fail`+`when`, `loop` вместо `with_*`, добавить `meta`/README _(ансибл-ревью)_
- [Инвентарь: `host_vars`, именованные группы, точечный `become`](ansible-inventory-hostvars.md) — хост-специфику в `host_vars/server.yml`, хост в именованную группу, глобальный root → точечный become _(ансибл-ревью)_ - [Инвентарь: `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` _(ансибл-ревью)_ - [Фоновая зачистка стиля и конфигурации Ansible](ansible-style-nits.md) — sudoers.d, профиль ansible-lint, `ansible.cfg`, кавычки, `cache_valid_time` _(ансибл-ревью)_
- [Logrotate для логов бэкапов](backup-logs-logrotate.md) — логи `backup-all.py`/restic копятся без ротации _(tududi)_ - [Logrotate для логов бэкапов](backup-logs-logrotate.md) — логи `backup-all.py`/restic копятся без ротации _(tududi)_
@@ -1,20 +0,0 @@
# Причесать роль `owner` под конвенции
**Приоритет:** низкий
Роль `owner` разошлась по стилю с `eget`/`secrets`:
- **`roles/owner/tasks/main.yml:2-10`** — валидация аргументов через `fail` +
`when`, причём две задачи с **идентичным именем**. `eget` для того же делает
`assert` (`roles/eget/tasks/main.yml:15`). Привести к одному стилю — `assert`
либо декларативный `meta/argument_specs.yml`.
- **`roles/owner/tasks/main.yml:32,53`** — устаревшие `with_items`/`with_dict`;
конвенция — `loop` (`loop: "{{ owner_ssh_keys }}"`,
`loop: "{{ owner_env_dict | dict2items }}"`).
- У `owner` нет `meta/main.yml` и README, тогда как у `eget` и `secrets` есть.
- Имена задач с точкой на конце (`"Prepare env variables."`) — ansible-lint в
строгом профиле это ловит.
Контекст: [docs/drafts/ansible-review.md](../drafts/ansible-review.md) §5.
Связано: roles/owner, roles/eget (образец), roles/secrets.
-13
View File
@@ -1,13 +0,0 @@
# Понизить log level Authelia с debug до info
**Приоритет:** средний
`files/authelia/configuration.template.yml``log.level: "debug"`. Debug-логи
Authelia содержат детали запросов аутентификации, имена пользователей, заголовки и
внутренние подробности OIDC-потоков — они не нужны в обычной эксплуатации и
расширяют поверхность утечки через `docker logs`/dozzle (который сам открыт
админам). Поставить `info` (или `warn`), debug включать точечно при отладке.
Из sec-ревью (fable, 2026-07-13).
Связано: files/authelia/configuration.template.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.
-16
View File
@@ -1,16 +0,0 @@
# Закрыть admin API Caddy (localhost вместо всей сети)
**Приоритет:** высокий
`files/caddyproxy/Caddyfile.template:8``admin :2019`: admin API слушает на всех
интерфейсах контейнера, а в `web_proxy_network` сидят все приложения. Любой
скомпрометированный контейнер (rssbridge, tududi, calibre, ...) может POST-ом в
`http://caddyproxy:2019/config/` переписать маршрутизацию, снять `forward_auth`,
перехватить трафик или сертификаты. Порт реально нужен только netdata для метрик
(`files/netdata/go.d/prometheus.conf`). Фикс: вернуть `admin localhost:2019`, а
метрики отдать отдельным site-блоком `:2019 { metrics }` — netdata продолжит
скрейпить, admin API станет недоступен из сети.
Из sec-ревью (fable, 2026-07-13).
Связано: files/caddyproxy/Caddyfile.template, files/netdata/go.d/prometheus.conf.
-19
View File
@@ -1,19 +0,0 @@
# Мелкий хардненинг Caddy
**Приоритет:** низкий
- **HSTS**: заголовок `Strict-Transport-Security` не выставляется нигде в
`files/caddyproxy/Caddyfile.template`. Одна строка `header
Strict-Transport-Security "max-age=31536000"` в общем сниппете (рядом с
`access_log`) закрывает downgrade/SSL-strip для всех поддоменов разом.
- **Лишний `NET_ADMIN`** (`files/caddyproxy/docker-compose.template.yml`) —
reverse-proxy эта capability не нужна (требуется лишь для тюнинга UDP-буферов
HTTP/3, работает и без неё с warning). Убрать у edge-контейнера, торчащего в
интернет.
- **Leftover-листенер `:29999`** в `Caddyfile.template` (site `status.vakhrushev.me,
:29999`) — наследие прямого доступа к netdata; убрать, оставив только доменный
site-блок.
Из sec-ревью (fable, 2026-07-13).
Связано: files/caddyproxy/Caddyfile.template, files/caddyproxy/docker-compose.template.yml.
-30
View File
@@ -1,30 +0,0 @@
# Handlers рестарта для оставшихся приложений
**Приоритет:** средний
Паттерн `handlers` + `notify` внедрён в caddyproxy, netdata, authelia, bifrost,
remembos (см. задачу `ansible-handlers-restart`, закрыта). Остались плейбуки, где
конфиг bind-моунтится в контейнер, но рестарта нет **вообще** — правка конфига без
изменения docker-compose.yml не подхватывается до следующего пересоздания
контейнера:
- `playbook-apprise.yml``server.cfg``config_dir:/config`.
- `playbook-dashboard.yml``config/*.yaml``config_dir:/app/config`.
- `playbook-transcriber.yml``config.toml` bind-моунтится **отдельным файлом**
(`{{ config_file }}:/config/config.toml:ro`). Худший случай: `template`
перезаписывает файл с новым inode, и контейнер продолжает видеть старый до
рестарта.
- `playbook-miniflux.yml` — файлы секретов роли `secrets``secrets_dir:/secrets:ro`,
читаются через `*_FILE` при старте.
Заодно проверить `playbook-goaccess.yml`: конфиг там не монтируется, но
`Dockerfile`/`entrypoint.sh` меняются без изменения compose-файла — надо
убедиться, что `build: always` действительно пересоздаёт контейнер.
Отдельный кандидат: caddy сейчас перезапускается целиком, хотя умеет
`caddy reload` — применение Caddyfile без разрыва соединений на реверс-прокси,
через который идёт весь трафик. В `playbook-caddyproxy.yml` для этого уже лежит
закомментированный блок с `docker_compose_v2_exec`.
Связано: playbook-apprise.yml, playbook-dashboard.yml, playbook-transcriber.yml,
playbook-miniflux.yml, playbook-goaccess.yml, playbook-caddyproxy.yml.
-16
View File
@@ -1,16 +0,0 @@
# Ограничить эндпоинт `/metrics` Miniflux
**Приоритет:** средний
В `files/miniflux/docker-compose.template.yml` заданы `METRICS_COLLECTOR=1` и
`METRICS_ALLOWED_NETWORKS=0.0.0.0/0`, а Caddy проксирует весь
`miniflux.vakhrushev.me` без ограничения пути. За прокси Miniflux видит приватный
IP контейнера Caddy (попадает в `0.0.0.0/0`), поэтому
`https://miniflux.vakhrushev.me/metrics` отдаётся публично без аутентификации —
утечка эксплуатационной статистики. Сузить `METRICS_ALLOWED_NETWORKS` до подсети
`monitoring_network` (netdata скрейпит по внутреннему имени) или заблокировать
путь `/metrics` в Caddy.
Из sec-ревью (fable, 2026-07-13).
Связано: files/miniflux/docker-compose.template.yml, files/caddyproxy/Caddyfile.template.
@@ -25,7 +25,7 @@ miniflux деплоится молча, а остальные — нет.
расшифрованным расшифрованным
- **transcriber** — `config.secrets.toml`, тоже vault-файл копией - **transcriber** — `config.secrets.toml`, тоже vault-файл копией
- **bifrost** — `config.template.json`: ключ DeepSeek, ключ шифрования - **bifrost** — `config.template.json`: ключ DeepSeek, ключ шифрования
- **apprise** — `server.template.cfg`: токен бота Telegram, SMTP - **apprise** — `backups.template.cfg`: SMTP, пароль matrix-бота
- **remembos** — `config.template.toml`: токены memos и Telegram - **remembos** — `config.template.toml`: токены memos и Telegram
- **outline, tududi, wakapi, wanderer, gramps, gitea** — секреты в - **outline, tududi, wakapi, wanderer, gramps, gitea** — секреты в
`environment:` docker-compose, см. серию задач `secrets-file-*` `environment:` docker-compose, см. серию задач `secrets-file-*`
@@ -1,20 +0,0 @@
# Убрать группу `docker` у сервисных пользователей
**Приоритет:** высокий
Все приложения заводят системного пользователя с `owner_extra_groups: ["docker"]`
(во всех `playbook-*.yml`), но docker.sock реально монтируют только netdata и
dozzle. Членство в группе `docker` эквивалентно root на хосте (`docker run -v
/:/host`), поэтому выделенные «непривилегированные» аккаунты (authelia, outline,
gitea и т.д.) фактически все имеют root — изоляция «пользователь на сервис», ради
которой их и заводили, обнуляется. Контейнерами рулит Ansible от root (become),
сервисам группа не нужна. Убрать `docker` из `owner_extra_groups` у всех, кроме
тех, кому он действительно нужен для самостоятельного управления своим compose (и
тем — лучше через socket-proxy). За `primary_user` группу оставить для ручных
операций (`inv ssh`).
Из sec-ревью (fable, 2026-07-13). Усиливает риск из серии `secrets-file-*`
(секреты в `environment:` контейнеров): членство в группе `docker` — как раз тот
доступ, которым такие переменные и вычитываются.
Связано: все playbook-*.yml (owner_extra_groups), roles/owner, playbook-docker.yml.
+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.5.1 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 }}
+1 -1
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"
+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"
+1 -1
View File
@@ -19,7 +19,7 @@
services: services:
bifrost_app: bifrost_app:
image: maximhq/bifrost:v1.6.8 # проверь актуальный стабильный тег в 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
@@ -7,8 +7,6 @@ services:
- "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"
@@ -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.14 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.27.1 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"
@@ -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.7.1 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
+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)}")
@@ -33,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
+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_*"
+5 -1
View File
@@ -12,7 +12,7 @@
services: services:
tududi_app: tududi_app:
image: chrisvel/tududi:1.3.1 image: chrisvel/tududi:1.4.1
container_name: tududi_app container_name: tududi_app
restart: unless-stopped restart: unless-stopped
networks: networks:
@@ -73,6 +73,10 @@ services:
# поэтому шлём deepseek/deepseek-v4-pro сразу: Bifrost резолвит провайдера по # поэтому шлём deepseek/deepseek-v4-pro сразу: Bifrost резолвит провайдера по
# префиксу, и подменять модель governance-правилом больше не нужно. # префиксу, и подменять модель governance-правилом больше не нужно.
# Пара LLM_* имеет приоритет над устаревшей OPENAI_*, значения те же. # Пара LLM_* имеет приоритет над устаревшей 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_API_KEY={{ tududi_openai_api_key }}
- LLM_BASE_URL=http://bifrost_app:8080/openai - LLM_BASE_URL=http://bifrost_app:8080/openai
- LLM_MODEL=deepseek/deepseek-v4-pro - LLM_MODEL=deepseek/deepseek-v4-pro
+6 -3
View File
@@ -4,12 +4,16 @@
services: services:
tuwunel_app: tuwunel_app:
image: jevolk/tuwunel:v1.8.3 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.5 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:
+17
View File
@@ -18,3 +18,20 @@ apprise_external_url: "http://127.0.0.1:{{ apprise_external_port }}"
# tools (goaccess and so on). Lives under the system log path so it is # tools (goaccess and so on). Lives under the system log path so it is
# decoupled from any individual application's data directory. # decoupled from any individual application's data directory.
caddy_logs_dir: "/var/log/caddy" 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
+245 -231
View File
@@ -1,232 +1,246 @@
$ANSIBLE_VAULT;1.1;AES256 $ANSIBLE_VAULT;1.1;AES256
34623131346139653664373036613532666565303039363565373735636363386366313233303238 62666365313535383334366166313961303933373163323236626565303038396662363239376438
6630613730376536656663636234326332353236363439370a333166386530346666303531633666 3738323565643363323865663630303330306530303263350a393336646463663563666362643362
38323538623838623237636339613138383936383232346566333532383863623738333136663364 61313337363936313363316635376163646633383032613265363936396536386437306139396461
3162386333383463330a643265386164613439643231313765623331376531306466313062363966 3963656162316562620a393737383636343965656433346466316333613331303435323732363239
65616337386232326433623763373464363135353761623430613036623761323965303333623139 39313834373536396237333264303930393762383333633363333265363039373061353334613530
65633262623261656131393333646361393733323366343833386330663461663435373561626636 33633161373631363061383564633465633962383835633237623938343865303363633461313830
31303437336431633630623835623633316538333339393866646661313762663134353364373234 36383865663932666562333733653935626166663066363233303962643364343739663463396364
37326263396362343137626537333936366135663438373034326165376537663233356466356438 34353034656130343739336532393334306261366331353030306161636437363464336536386638
35653637373864303864626433336634366132653262643731396636323963626363643666666635 62653362633363393838366564313165346262653432663563366362616364396633323563643239
37393339353331666635626531343835346632363634366437663564376339386230386533323732 35333232396239383233656231333264663938373364313730623364393436626361653736613939
63623232613137663266386161326333306530336634313666616437623931653232323431326562 64393833353765346633363334656631343064656333383664373039393966346332393134303030
34323565653761636165303132313964373031626237626232623031656430356535613831373834 36373436343766323038303438386436376666393239643636366330336336333761356436376234
65356263303364316465356138623737626339333530393661313638333931336331396336613437 33383266663030643931626662383636333637616539313366326539383139616334363939626161
65323132666132363063333435383032663330653965373334643461303466353235313130363633 34346263643031363236313334623162343236636665383262393666306366383064343965373639
31633134373732306535333433613839356362396261623364396339363630626636396632656233 34323062656633386661343332306664633361396233656238613436636530323839653838376563
62333737633332383736626363366463663166306265646365373637333465393530386665363364 33336664643365363930366636656138616236626639663564613132653465636462633364646166
37313064653434393761383066623638303162376263343130306439623166666366326639616336 36393966326365373530663839653737656438646662373730333666376137653337303834383032
65386364633865626566373636393537653134333733666531616266636638653836383732383461 33363636376435623064646566323065383466623138323938303630353330663132636431333830
65363132346563643439666431323934306330336233393363666164353839633534333132333130 30313434333262616138343634303438386236326330636161336462363333386561643462666439
34303861653662636534373565306537366235663431333532653465643866363335383762333538 35343837653934613338313537336232656564663337666262656361383736363939383433623938
62373332363265396639626137646334363933366633326462313336336265313739326131343165 32633965633931643836346362356334666230663734643130656566353937636331303633323934
39336333376137393665316435363433613661393063306638306330313333633430333034373038 62656634643263333566343739666634663061386137373539383736613864656230373566666162
62626265333433326665316634613562616631643161643930363632666262616431323536363066 32616363633534313933613533373265623366303766626232623966623531373930613831643034
64353932623332626261326564396538663866623638326432303436356263643162336166636137 64306236353164643138666564616464383638636263326531633866363732653664313364386661
39383432346365626663373932663134646566323061636235633336386236663639633663326265 30633536623336353532363261623938353232396336343662643965353839333761376463363631
34353031343465313231336162396232303436366363646131613936303333376463363230636638 66353862646131343961363363303432336431633761313633386235383435336563383333613830
30356137313035643330343536316138306633323635363066356333623365376430373231366439 62333864373362663538326363656531643733356232643632396631323636376164653333316137
63633237633866656262313233633565666433626563393730376465623033353738383662323438 39626336333139323733653535663132333030323938303166396336356136653231366332613039
39356265383161373933636133393534333934373664313962626637366466636132396563613435 30346237353532623065303033326534663034333938663365303264323061613964313139316538
32653964663433646332616161616436363636363236353262613861643939376239636139626162 32633537346165616136623431653766383535393230303064383433626634386331356434323561
31313638343765323736353961626238666536623330353537396464363935303439653139306265 31646137623339373662626631356337616666303131633138366433356163373766616531383138
30323531626133393735633833326562333230323735623465326566393832303234646536626534 33366361323935363163363731636631643135636332316439303335616661373039346433356138
30386263636537613366343431363262393433663064613631613665353139316639366334366133 34613061646138626637316262623632303339653633346161353836623330313432396333323464
39353539306662636165346466303132633231666135313734653665303165356533653739396234 30346232646661643839663835373439323031623462386432623332316535623764343039333039
35356338326631376631376136643930616164396466303733653266613766346334613338623133 62313031663731306539303634666461376133643138363235383531366239643331313238623666
39326234363161363132643630323662313536386161633033623833613639643030366533353038 62656631333030626337333765646339643561396537333462643166616165636261323663333963
31396430366236376232316462616138303832343862306361663061656263626466333036613366 32656336353837323231643561306134323065396131343965373534656530376237616134343034
36386234666363396161333830636430383039356638313830666332663931383937623433613432 61313430376531333261323530346430303163313864333033663536386561633939383030666430
35386136333830616239623161613538386238383130346435376230373838643339663066316130 30633864316363383631386636656331323234356438643132633637623637646536316532373639
63663938626662396336396562373537303635343337346637626662353530623639306237313062 38313535663930666335333239623935393232633432343561663938343335356565626266353630
34313664363838313133346533326137636239346564646661353566646163386663326232643766 35666134383431653163356230663038336563366433623562333333313733383536353635613835
34613931613063646339313764383562363930386165636134653932393833303633643266666366 35366539306437623336623230343666366438333334613765393930313234383365623466393934
61346238353636356564376165386339326662643634326431323766313831353039653565386437 32633931363164626464396534333431666630316365323265376161313138313232646665323532
62343338656466343562643930656430666230303863323238373534633136386336356266666166 39353563393235353632323930326537643162386430383461306136306339306535363565633662
36373536376263386434613533626130333261333633343439366266386336613665363530376432 35336434343938353538363563373363393464623633313638346236633662623837633263633166
36383735313566666461323363383530636333616332643637653364373834643337346537626630 64666630346235613664356433653436616166643762653431376666306364623237363363613333
61346331313237363832623535323936623163653233333562653035306336333831623338356231 31316533363966633362366266613863353136353662313636393234303361396531333063623863
35313438626632666465353535383434343630306233306238366439623335343536346234616134 30343534643835613462353739613730633331643837383836326236636235623965356333343961
39353938313536613531336134656262363830323561313730383331323165353930623066613064 32396135393564653537613731316133633665653061386664343233396537383332643163356639
63646465626233646163303964376532383338633131383065313236616537313064303265383733 36653134303361656438666635396339323332346133623439613031613764666665303335613738
66663132646130386638306335653531323531306437393364663135306466643365663235643461 64303537616531643237633835613937663739313466346463323037636432373235363038393461
61633665623532386466623635376331336662666134373366346536363134313037393939346634 66363162323562646264323133306538633430303538356231326436313432323931623135306262
64616332666630616235383136383536646233353663663634343762346236353533613832336436 30666132383131343262326531303931343539646130356332333964613938363033623731633463
33643235663366626438373932633866626366323035363835633036393266613163383933343466 36363635366165663064653934623235323930336538643634623233383535323930386664313530
36303630653731363637616337323030646164306264636237613032303738643938663032663365 65383264623736373261343331353339313361383539653262373338316163353063663038656465
65333334303030313562636166653766306265636238653161356166616138376665613338396564 35356133353465303137373039363535336232326233353937643465323539343263363739663531
36383732636331653934636666393861623965386666646630393261303365363839343838383266 66666566373835336433353761336338336533353463386230306665616638393864646264373563
31616162643662616332363737373865353366343030623839663330343861313032363161306262 33313339646237626264366263613036373931666366326663313964633738393164633534623839
34316161303432393365376431386165656464393632646534613930643966663330656532363162 39613132396661393761343230633135323732613836383661353665663831623766616639343331
63366331323634356337366433643262343531376638666536383766343963663839656438353465 62623862343737643930373432323563663264366234646533353835353264333333623734313435
33653132383336616166313330306233373630393530303161306634663830656433653739303466 62316666306437643835386235303139616534303932363263373863653633613464363431356132
63373436613663643263343262643536366138333439313538656136633932383065643135303237 32653661373365623364316663393262303538633039366138316630643031626565316335613436
34636637373863326539653836333430306537353436323832373131343435636233393762376430 36356137653139303334616631396634376262396133633161306233336162643632383635333333
34633338396138376632653863633935643462346438653066623633323431336230616564613236 65373633666637343666366463323765656633303732323631393064353739376430303934333232
39376639383837303430323939653435656437303635343335363965313734316134303166613238 63393831386363343233363732326234643039396637383132336162326336363033393436383931
62616238306363613334313362666365633335336637393332633564653035303034333866623634 37323763326461643837393139613165346136316663646464343338613061323936303030356466
30613432636539383031633266383166303639326133663065323236316561373461626637333138 39636138376632653063643963306334396339666665323234336135396561393166643236663733
61376134366335646634623062333633306666663666303464386430626639373663633938396331 32316364623831626134306163393239303564313466636663393166336662613739323864346637
35643138303561316531363165326230653461356133346531326466323966353330343438306630 38393064313362663935666632386339393561663735656639373633623933306635363330333736
36613466336638623934373232316635633032343561323535346233633261376235613263316233 62633966653138633231346331393533633333623835666463666330333835316631666238396334
34663635353732336234623935323631613831336639636338656332633832343238626361636364 33343232396135383834383564636235663634346336373536663539653861633862653332633962
65636632633534343735343737666237616331353737636332333034393630303133376136663332 36343136633532323565316431396630313837363138343833316363366165383337343930626361
31613532653566393164663738303138616233623031646665323266313863623761646465356135 66633535643839396463343264313336663430326137356465663166353833636134356434613839
62303864336538306661383931346364303961366330396639336461306537313465663239623766 39313439323431633966636533336530346138353935663433333661353634316632646333363434
36366262383662613432356333653765383432316333326535353062376539366434326638336361 33326165653033663965333934363633353235363931626463383565393331366566663666633733
30653136633362323066643732383636626530363163376230306338353066373732376133313064 32643863653661633137313263646630373465343434666234663236393832313761383535613363
63356532623331353538623561333364623037396262323864643061333131343061653330343632 65346335626535383335643361653531366139363637333838376562326138386361623562616165
63386262626431376231326131396164613463383034346362396439393133373666316637346134 37333730663134666466666130396662616138663962366630323330616333323539623532613733
64343538336332633131333530323462373864633665343439303162366335343234383362346434 62373531653564636662316334646338346337616433333533346539363066366338333233616337
36356662656530353932616339326233336637396664636464396462376636646432313665323235 33666130323330356364326633623165366465326331383361323630363330633833363536373930
66376635396436653137383039333932356665623932303433393264656632643632323165386430 64393464383533383335623263613136336632363132373663326263303162636331666164646234
34613534336632613230343532346364663338613932626333663432396461353638646237613330 32316133313438616563376230636537636334346637356339336533333264643461666439643238
63376265313536633838663031326133363563353266313464656666356538646333643361646239 66376461643136336665643366643363366365373636613537636462653737303131643661336364
63346163646164636539333566393865303534646534396366363262353430613463326637336664 34323261313632663064346263336463363932646261613232643030383038613137303532343766
35373433653336613934316333633565383863623562346663396433646633366130613339656466 36663366396338306534633062616436333133316438666261396136323535613436356337613339
62346339396438656334333934383432326661643236646537643564346538626533613565303235 33626565663137666265316533306231643435376465333566643366633638346631663435306534
64623038356162303338663037646137366136306232396535306565356132353830663566643465 37373836366438336365326265653433306334313630373737343630363534313131386332633134
30373233323930333862633133303663646661323566336238363435376138383066383136393938 65373862643431313031626236356565626133633663613564613238313962323934633437383930
37356361393334313735306238376134373930313834376566313236376665623339363039626463 65393765313934393438663338343239353732316130366466323638656439613234643036356265
37306364623863356335373234366266383331326635653730633535363464326431323030303534 36646631313362663833343934363930373536333531626161623038396331373065633965643735
39303231633664353834656162346531663933666133393462383135313764346564646162666563 37666162343930383133306139306131356232636530306234616464633736636538346431613066
30633539643432383934363339656465646166326261623465336136333236343030346238653234 31396630653338623733663262663335623466613430316663316435653233653837623131396462
32376363613864383162666138346565633239336361636435353362306163396133653432306262 31633531356239326538663737313437303735363565316263323131383362306235363835333830
39663464623636663930643162353537393834653537646536313330376136363230613063383362 64333438633837356633633435643830633938396637326662666137333934613531316538626633
38656632613834623362353366653766313735343665343661623836313339626433306338306366 62346431626662663538303431336234613164383536313363303932643062316538343332386165
31376666303734666461663462316339663130623763373034316463643537653839363762373431 62653937663135353638383363333634383031383362663039646133666634356661313537303566
64373064386564656438393030363135653030626665656337363366353130373237376331663231 32316361613261313830363532336238616265393661343133373231633964323535656530336136
35663463383530653163323565623234316431336663363136356238383064653739343661653066 38393163626133336235346162633165396333633339316462306433656262623736376332336330
32613535633631343563313262353063393733363230333236363665643238663338306639623932 63363733363263636336633432366633393533373566653836663165626162363735363963316532
35376136616464366563386138636564636138623939663535393565653638303731343561313466 66376238303538386537363233376431393833303263383733303261386237623136613435613530
64303838646538306233653437313663393730653139626166666433396434316239316662316537 34383738646330336636363336346264323132393962666334613134313635393165656236623234
36333037613761313263396631386636313662653062326630383038656463366430653236333131 35333535353562656463376135353562386532366235363865343038316561663933373339356336
33323330373833663763303930353339643363373266633734373732343632383138616235386166 35393830623430323365613863393932633535376436663666356562386461336239366665333436
65343465666264366139376466386366393230376264313935623961373966333964376362356133 32626534373830386134666233346333393064343933323830336462666136386538306463313531
65386564343332313037323336633465636439356363616261346435633536633331353935653364 65303263366530356138363162333833376631626631316435663631333438363833366234646131
64616332326239653730343530656466353664373935363062633132326436333538393961346535 33633236326533643139343766366131313834366663336361623563376465646338623537343934
37303533393036396662326263626432623135336530383939653833636634636135373431336638 35653665363665343264333237313663663431386530333230643832643537373464393836356365
39353539333436356363623665633533393538393039653230633737653231616363343636353631 31306465623539653361323233613136373464643334366466616561356336363161306566353033
39303361316565626564313237353738393533646666363362303866386462313633393531393034 39313735653062643361313538353464393566303435303839643061326631346434316537346561
33633131376230616361323530313231636334653164383833326164393965333532363736373130 35326536316233613035366532316132396531646561633834346530386337636532313233326433
35646162636538326461363163396362383366623635666162366136623363326266613632613966 31383364313331623433323364356566373433323034373531653736653866636539383639383163
37663566313338633765366333663331336132636261636536353163306333346431373733396634 61663362303435363539343331373931313065343662353964366566656537633664383239343430
61393536613634396530306239663365396563636563393038303831396135393831656430663363 62623865623665616638623937343361376133306564383461336136313035333731313837393762
30346564663765636361613265393862636664646330393962643637353536363063363834663763 34393936373865653761363139393530386135626538353031333736646235366165623535323338
62303836663963613438313161613963376262323337663166633530626264343034386563633266 33313964643732633032643037323064323461313930623836623438366261633033666439396535
38633664636362333935663666383135393530643063666162313762316230303230336665383438 31326131303738383237643566306561393635383831303433323963646133346534396663616338
30386664393239633931383230663464373235343964626534393663666236333933613965336632 33343139663162636134346637333330333637643063373330376134373533663864663338653264
65633230356565623162396161313465636532333436326430623531646563323434363734623462 31313530623933653934313530663139313439373662613130636135666630346261623933393833
31313638353237623230326361656533336661396533623566343062303164313566643538626536 30306166303938336439333833373434346139366162623736663662353931626639663139336462
32366564353863343936376362666666303838663162383139663039353864383961396437306364 39353336653163316136656238643031626563326238353064356430313533376536376635373262
65666666353335333733333538653336323763326437336338623466346435373239343533316365 62343534643661326435663031653634663130323035303063656163303436386131643435383733
35393463656533373237363537663234303231396565663230383463336665656562633162366365 30653061623439376262376338366166353836353535343939646134393335623630383866306539
33303337343464336262616332393465333963653164353063316630396432333264316136323465 30343862636131393863643831656338346334313930356164393135303166643265656138366266
39376634616632373536623563626561303933346366636239613233303039643866643038343861 64646636306339656662616239356666633330666237653166393663653565343533316337316237
63613230396632383031316336313862323166373939316362663236333564636437316338626661 36663930323730653236653235626437366338333263333135633634346434383530386331333166
63656138666137366439343364346164343435326339366530313662626664383665643931303036 38346563393934303830633932323564343166363936313035623034666436303831643265646433
38383761383964646231383431616265363932623630346162653738663531623236646138663165 36616362633230666132313937626136653530623865663037383239313939393739656663373166
30323738386664633235353562323638636466353965366638626164656535366661333338393534 37393233653533646664623865333865623862333263383635643238343538333137383461623565
31343039616562346232363831626136643131353834313161616531613633326637393864393831 65663036356666323137666536323435376565653234376530393166393939666663373361343530
34383432363230313463623536663432356333386236326335373937613062646634346439613961 36363466323162383930316637326333393937333232363537643065383231396236323936643364
33353364623363653136646262633963363265636263646466613833623330326434303634333161 64653061323033363135353436333036326636306639653139363064386366653562383539356131
65636365663133306537626232333939316531353533316231373962313233636130336333323665 31366432656634323730336665353132323862636339313036643234396339646165313764303734
35626363363432393032656434636635663263643664376134393239633538386438646232326234 34303737353466306537386633623261393537613735313032643033383434363233393061386538
33623766643863643262636338656131363861396130373438656537653061343134393436653336 35313834396533633735313237316433643835323465306431336331306638383165306234303366
36376163363133313165663436333563373335653761643236616664373231653632646539396132 64626239636439643538323734343966366535616437383632363631363731636463653133313266
63336335363631613732373834313566316336373939313639306331383063366536306138646639 62653036653761333166643432653563613264616362316463326538653536383763363237653238
31396337646465613237663733643762323339626262353862333030313661653231366461303338 37303564386266653239333636303034633538383232623630366232626638373664343336336462
31313463353934356239323464343036316538643130316463646532633234336239663162636265 38393038633737376562626131343131653236376432653230343833393366366566363334343263
66376137316235613130656266386661646336613037656563396338393134383835363232383130 39323062663565373132343131303530353464626133643731666239316363373437313335626232
39373835313533373261613139326130626530383039383936633830373931306262623431396236 39393931333033643265646535663935363831306163303966623164303264626161303434646538
64326337303236646136336466333764613032663537613935373538373939363965363734613339 37656232396336336635643335303165323361366562303137333831343832653763323937373061
32316332396433333031356365366638313332313966616231353836316162356664353665386334 35326263356263363636383861356339623966323564366430663632316664373536613462623838
35343032336664313366383435623632396538633264616637623363316461393561323966326133 33356462363038623633393337303161393536646665663339653234336137393139336132353235
39306639363264656633613462336564653066633263333132623139643132386130623138333532 37613138333065363731623535346138643833343663656162376139376638303231653537326131
63626433666566373037333035356231373238363039656562363131333634376138386662343230 31376334383564623331623733613365643161393165376563313235663930323037343161343866
32613762306235313361396262633666376663323033613339353836363534326232353236613537 37383336303138393064663265363939343535376433326433353165356564656132366565613839
38656139396265303030373539363766366439326332393130323631346233663436646362363237 65643365343038316139653665396334613131396238663162666365393339353539613634383935
35303266383763663332656235383836633733333965303066643466376465356533383439643766 64333535386661333363383437653864636434323832303932666364376333663633326434383064
65646663393632326136653332656164393834343766656333346364343332363032616636623637 31333464323331303939353732353763643562383538356363613739353135326630613334323037
34313535356133653830343138653366653664353032663537383862316262303733613264343235 35363335346464373064363231393435306534323431616365623765646362373738653738326232
34313939643639356261653334376533333330303632636266303031343031653432643763383462 30323563326466373766363935636535626632623138343537333038333964396262323831353038
30646633346239663763646437623866346461386535643338323162363039383531343330383432 34383665346430396361306437613339366437626266626231626239363362613065396137343962
62613564643966383463376133376538306539646463333338653136346665303836396538336237 36333463313130353165373365316563623330356638356233383333653661663462383161303136
32373565653061376461303938383139336437666339333762396561613661356464326266303166 36633038386563376335376236646537316563633739393235663930656131613030666133363430
65613532326438616434396461313130313761613063386633373462646462653065646162656264 62386263373338653161376636623061386234356632626636366232313639353138636332616630
36313035623662383965643336643830643033323136373066343733363036666332366537336630 61353435353239633630323163336133353462356263316130626638656238653264663361323135
31613532616135383365376635323037303361393561376166343638336663373532656165346333 37633038373433313863393965633661613231396536613835653361373837356232333833633236
34383961346431363966356339323030373565313266346436326433633934363964313866323938 32313439633632363465366337303739303137323839366261653836343332373230643264666535
32636538323838313064303330633436323062313637333764626437393737356337343661616336 35313865613632636437323462316639323531326432633334333432323266386435366366333838
37323230353931323339366136633334316261646165343430343834326339303238613534363138 38633830313731326638306634323565366265343130333339373032643832346166613737663430
65303932346435616563653961373561623761313534363732353633353330623030643033363730 62336434316264376361353830633562313065653433393363373734666134323263653537653830
61383961326632393362633932353065613636313932633966616534383065613636363734306466 62643838333065376234646166666332376462373536373234666163636562333865636134313766
66363933626561663038376334656534643566316134633338336161653532636239633536313933 39376237626137316366306366393038616631346161316263343237666430323966396331353065
66616162386538346432323761636134376638336337623539646262393535613263366536366464 30363437356262643034643330303534333331323464623662616534653932646130653466653332
66643566333162643261653136393331343431646233643130336562336366633664353734373636 62363030343833316133666164383037323936336232356266326261383964396364633233643737
30633762333366613764346436333737623266343631363330356366663632366565396564613431 32663635613939363265323936336263663465646666333263626139636162373462373064613362
31363439393463623334653666663636613366633437333237653363396561386233326334613831 65636266616665633033626337363136303066343232353537346635633431336266333535353131
33643936366566373435653161346566636639633039363939376366366336373437346664656335 66316266643335313033386432373962343332326238356430323435363238373334653036393839
65333633363336366263366433396661336262373931366639333362393939343031376166393362 65643337663034326232646464383934323661326432373061376662633765613861613466653937
32633330303861666231393137356639363164303736306631656338373938666630316261393766 30363065316561666235383332623364353938303734323537643064343333393864313339656161
64663935366265386463646163313336363066353164336631396365386465393933303566313333 36663264363363353430663930653965613761353630373034336166633864386234653831343636
62336139666337353637646566623537613038636265636537313330666631623864333662663531 32383934633666646235366534303130306436636665326338626566323362616664383162333837
64353563303165323066376133316266636433633863363039323535623862346663636464353031 39636539313737653933386261326262633963396364626430633631653735303934353436633532
34333132383661303739333861326230346234626339633336383136333135336464656435633135 63373866326666323565663435613530616130323035613438313634393264353266333838333131
32396139663737353062623533356638346563653032643465633333373761376362613434656432 33626236366633656365656534643538623431323635313133323630333365653132313932653863
31623363376461326535386164616665363065623933306435643061666630363862393138663165 32396139306437353839303933663432376434633462376335386264373934316339616663646130
63646263326665623066336361643836646437353432653963363165656364373531613362316632 34363130333061626433663636363032366237346136633966356139663462393836623562626532
63633233333764626433353433303835663962393963656536323734643966333930633162636361 35633930306639326639383632363561306164336535303139316162306431313236633036333964
62626437326437303766373331656232363763326565373535656266383365666336363466396438 33353063653561343764653235613330613331656338343337623431326564333761633731636531
34356266336132343830336431326638383933393163373130653735306534303364373933646434 38616539356239663861363237333733386635353532333039636631663639316434623633323634
37396235386162353232646639613531643332303038336330653234633437353464343131633961 64663139356536643864373836303335316464636163303464376264633630633738313064663630
31666433303238633934646531333736383831376234326530613335346530363962643935656662 30626363636265383064373764376538626235316138303831306262643932316332323733356535
35393066346136353534393934356466333036323666366263363561353861636538356363376362 39306564393236653530393864346261333434663232393765626265626166343864623931636133
31376662376566626439386165383934623135613162613339356535643762626662363263653330 37636532333762306339666466633939376432326230306437386337666630393530336163306335
33643166656634616534343133363030363930616231643535303163633436623463656364316631 62353037303265656534646262623836633030326636356566363362353161353764636639663538
62333562636533306562376665326338383539313632666435636332353363613534316162373261 65373831613431373665383565623361313235333036376239313039616433626539626432373363
31626535363334306630316463316264353633643738643939313932376461383833653232366438 37396232626663636165363863346533396132376531306139616664363261323339333239373639
31323834613565633237353331386331323063653132343639366333613633623062643932346462 30383136383232323837396665373235363433613864373437623834633164613266383165653966
64326533393438633530666266343435336165346131383330366264613138626630393939333131 35336337656361633435363531346134303435633262623465383938626639383332363736356630
32343566613730313236626165623935343535656137306263306635653966613165356263653733 32316637613233303531386562386636636361323163633732633732356630636165663266363532
62643036643430613231663332626431386432653632333538323465333562326339313538373332 66626433633938663262323464653335396662373634653466653964343065336139313539646536
34663165653833356366353065393161313230666662666638666432663132303334363965346635 39663134333530343537323764646566323635303361323032613633626536646236616465343365
32386236646366396530303635666539336538313530306462383335653738316364346637623361 39646336626632633839626461303633656633613939393139346639343935333966383564393263
32646561613539303636613535643966356537353731373034343231373437383432373638333564 32623639366134636563306463643230656337303666393634386432386332333137316534333361
65643932663566343761313064303265653963643264336230316163366638653537663464663937 34646631663137653139343131343236323930373036366263666162306230376664313331636238
36303333393833306235636565643831376135373737373562333964306537383332343162656563 31666465343633323931343063376530363838613934623537643338643236323237386163633131
31653462363734313531663738336430336630313537333039373366666539313362393134333465 32663263613265646662393339346463353166393630306665343534373238666235663538366637
35636130383936613166316536663735616335373863333564383938323633386262363534306230 34306664313462366462643732376365343464393833633035353863663933393566633065663531
35386530346539393434643362663961643430333532343032633832366137316362646234663437 64386231363931323363653665613732313438323630373430613634636437666565363734643730
61363265396264636664663665633038623736646130303430353964653136633131323266396439 61383265383938643336333666643434356166643831383765343736373436653430376561666338
63623931353330653662383261363431383866663764333037303463306637306262656630393261 36373033643665343431303464663838373766306466636534396634323232623434653736316531
63393765633137323033316465376464623634363537396135623334633234356437653430643062 32383365396330666333396364663561336663333965323866316131356331363633383437643230
36323766633162343862643538623364353839333861653862643864666234323965343336643762 33653337386534316537613830393337336630373436343130623535343938616133376138663236
34666639303061326136613433623130333234303562396235623639353136613032363139636266 39363133663735303037396631333062656566393461643337663161646361303739343161653731
66643732346339653764343965646263366639306263633837336232346465396535383530326337 38383636623238323261383038326433303532353039383035393666366265653334346630663839
64396462633330336633633766323932316637376663656632633037356133633331303166663436 65343336666462366432383764646137623336636662633731386534636463656364383465626531
38333434376534656562346638653130626462313564383366353537653531326335383138353330 30663631663932303161366332663964643662626631313732333764353763363764623234323030
35656361343039343933316435646232396363326663643133373734616336313237386365346238 36356330313261626134326363346133393638383834623830636165316631613432666261613032
31643732633630643066323935336432346363346636383239626365373232316230343239643365 31393231333837623266363130633331646531636136343163376166653261343464623133323635
36383135643830386539336431643635323062346465383930623531663834663437636433376336 61333636313962383961393831366262373837316531363166313964316538363634386430343739
32393035633534366639316339396632646538346661366564373366653766646263663137373561 64343363373165663934643833613064353264373539666139343734393663643735393736393065
37306630336566326433643939376134333030653837343638646133346262323935653565363664 37393262366564616561663532373137336262393439393031383439333336636535323834393138
30653036623638666535623964353738343461303834346535376235646566343761663438626135 63613734326238613762616366653533636130326631383631336433343635656230343266343365
64643731383535363065333331346263333464623732306566326666643230623231336231643136 35613033356130656632303339643236643330646339333037363830646431313032366363643336
66666664373334396139356332343564386332626632386332323434326533383063346532623035 36306332376436356639323738306638333866373532363632373861613863633133323862393036
62303765306132616630323264363666343162313435343737303631346635393636363337316138 61653637323635636163386139353038373039623937353533623336653565633730336330353936
30653966313563613533616362626263653261666464323664316132666462383334633937646330 61343431633635346339626536376464306663656262333531373438333363666237333832316238
38646563646130393238663537343739643566346130616461366361643961643730353465366233 38613336646263633932636233393734626338336433326232326164613436363232616334383230
63333163353532363564323032363162323235353262396338363533306131303934383562656561 31616333646361346638613065343065393736313735646234663333633534636331356663323463
39366133376134353234653830623061363832643564353936343737303932386638396232303332 35383261396563623363353432356431303539363666653261326363646464373865666531636131
64373736383538343662613033633564313532323663666333353632383431386239643631333739 61386634383337656539643338353066373932313134613266373366366630656236313664366164
38303536343233393763386330323730653861333831393464306537316363623936663631616236 64633163386431366232383835633235323130376462313832353736343262663737353739303934
62633932323831356361616130653266346439643837313339353338663330663136656133383332 62323436326631616134343831376561633965373561303039386266313935336164386636316139
32646330626161313961633035323432313461643261626130636139646333623861663165626132 37653238666631306161396537343462376638653263343134633234366236633966373438383639
30303330343632303239303938636662643037646431653662613839663865643638663966663064 35356532363534363133353264346661663437656162353739656265373130363931356239366339
32616330643030656532626635346138386431383963376262663966313164643236616164356663 38356538616137353737323132366130396335363262646635383763356534306465343463646632
37396432663463663039623334303235376462353930363939366430346664383431343062386561 64613431666435333330623035646463666532323131356538343034373936333762303931616463
31336338356537636632663661356561343838373666383862333036386137333336376638613431 35346231393338353065326430313032653966666362653438623333323466303431313135323733
32623234343065363762383035343462613766626134326165626636336335306239313363356362 39306466666532383933346164323931366337313937623730313162623261373666613363323233
63663138613632396135323762633964653831663437613763623162336339396639656434323065 63313161316266323561343363353038326439343661613439336132636361316563373766643164
346439636339643734636633613463343231 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
+30 -3
View File
@@ -11,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
@@ -18,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:
@@ -31,15 +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. # Файл содержит секреты из vault — без no_log их печатает --diff.
no_log: true no_log: true
notify: "Restart application"
- name: "Copy docker compose file" - name: "Copy docker compose file"
ansible.builtin.template: ansible.builtin.template:
@@ -56,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
-1
View File
@@ -24,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:
-1
View File
@@ -21,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:
+10 -16
View File
@@ -23,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:
@@ -74,7 +73,7 @@
owner: "{{ app_user }}" owner: "{{ app_user }}"
group: "{{ app_user }}" group: "{{ app_user }}"
mode: "0640" mode: "0640"
notify: "Restart application" notify: "Reload caddy configuration"
- name: "Copy docker compose file" - name: "Copy docker compose file"
ansible.builtin.template: ansible.builtin.template:
@@ -92,21 +91,16 @@
tags: tags:
- run-app - run-app
# - name: "Reload caddy"
# community.docker.docker_compose_v2_exec:
# project_src: '{{ base_dir }}'
# service: "{{ service_name }}"
# command: caddy reload --config /etc/caddy/Caddyfile
# tags:
# - run-app
handlers: handlers:
# Приложение читает конфиги только при старте, а `state: present` не # `state: present` не пересоздаёт контейнер, если docker-compose.yml не
# пересоздаёт контейнер, если docker-compose.yml не изменился, — поэтому # изменился, — правку Caddyfile надо применять отдельно. Через прокси идёт
# правку конфига применяет только явный рестарт. # весь трафик, поэтому не рестарт, а `caddy reload`: конфиг применяется без
- name: "Restart application" # разрыва соединений. Конфиг указываем явно — без --config caddy берёт файл
community.docker.docker_compose_v2: # из своего рабочего каталога и молча перезагружает не то.
- name: "Reload caddy configuration"
community.docker.docker_compose_v2_exec:
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
-1
View File
@@ -19,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:
+13 -4
View File
@@ -10,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"
@@ -32,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:
@@ -53,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:
@@ -70,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
-1
View File
@@ -17,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
@@ -19,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"
+3 -1
View File
@@ -20,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:
@@ -83,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
-1
View File
@@ -22,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:
-1
View File
@@ -23,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:
-1
View File
@@ -20,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:
+23 -1
View File
@@ -21,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"
@@ -37,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 }}"
@@ -72,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
-1
View File
@@ -21,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:
+4
View File
@@ -25,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"
-1
View File
@@ -30,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:
-1
View File
@@ -17,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:
+13 -1
View File
@@ -23,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:
@@ -48,6 +47,7 @@
# Vault-файл: ansible расшифровывает его при копировании, и без no_log # Vault-файл: ansible расшифровывает его при копировании, и без no_log
# --diff покажет содержимое открытым текстом. # --diff покажет содержимое открытым текстом.
no_log: true no_log: true
notify: "Restart application"
- name: "Copy docker compose file" - name: "Copy docker compose file"
ansible.builtin.template: ansible.builtin.template:
@@ -64,3 +64,15 @@
remove_orphans: true remove_orphans: true
tags: tags:
- run-app - run-app
handlers:
# Конфиг монтируется отдельным файлом, а `copy` подменяет его новым inode:
# запущенный контейнер продолжает видеть старый файл по старому inode.
# Bind-моунт переустанавливается при старте контейнера, поэтому применяет
# правку только явный рестарт.
- name: "Restart application"
community.docker.docker_compose_v2:
project_src: "{{ base_dir }}"
state: "restarted"
tags:
- run-app
-1
View File
@@ -20,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:
-1
View File
@@ -23,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:
+23
View File
@@ -1,7 +1,19 @@
--- ---
# ufw фильтрует только цепочку INPUT, то есть хостовые сервисы (sshd,
# zabbix-агент). Порты, опубликованные докером (80, 443, 2222), идут через
# PREROUTING/FORWARD мимо ufw — закрыть их можно только правкой `ports:` в
# docker-compose соответствующего приложения.
- name: "Configure UFW firewall" - name: "Configure UFW firewall"
hosts: all hosts: all
vars:
# Опросчики Zabbix у Timeweb: только им нужен доступ к агенту на хосте.
# Список — из Server= в /etc/zabbix/zabbix_agentd.conf.
zabbix_poller_ips:
- "92.53.116.12"
- "92.53.116.111"
- "92.53.116.119"
tasks: tasks:
- name: "Ensure UFW is installed" - name: "Ensure UFW is installed"
ansible.builtin.apt: ansible.builtin.apt:
@@ -49,6 +61,17 @@
port: "443" port: "443"
proto: udp proto: udp
# Агент хостера слушает 0.0.0.0:10050 и сам проверяет адрес источника,
# но держать порт открытым для всего интернета незачем.
- name: "Allow Zabbix agent on port 10050 from Timeweb pollers"
community.general.ufw:
rule: allow
src: "{{ item }}"
port: "10050"
proto: tcp
comment: "Timeweb zabbix poller"
loop: "{{ zabbix_poller_ips }}"
- name: "Enable UFW" - name: "Enable UFW"
community.general.ufw: community.general.ufw:
state: enabled state: enabled
-1
View File
@@ -20,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:
-2
View File
@@ -12,7 +12,6 @@
backups_dir: "{{ (base_dir, 'backups') | path_join }}" backups_dir: "{{ (base_dir, 'backups') | path_join }}"
gobackup_config: "{{ (base_dir, 'gobackup.yml') | path_join }}" gobackup_config: "{{ (base_dir, 'gobackup.yml') | path_join }}"
wanderer_version: "v0.18.3"
wanderer_origin: "https://wanderer.vakhrushev.me" wanderer_origin: "https://wanderer.vakhrushev.me"
tasks: tasks:
@@ -23,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:
+41 -1
View File
@@ -9,10 +9,50 @@ dependencies = [
"ansible-lint>=25.12.2", "ansible-lint>=25.12.2",
"croniter>=6.0.0", "croniter>=6.0.0",
"invoke>=2.2.1", "invoke>=2.2.1",
"mypy>=1.19.1", "pyrefly>=1.2.0",
"requests>=2.32.5", "requests>=2.32.5",
"ruff>=0.15.2", "ruff>=0.15.2",
"types-croniter>=6.0.0", "types-croniter>=6.0.0",
"types-requests>=2.32.4.20260107", "types-requests>=2.32.4.20260107",
"yamllint>=1.37.1", "yamllint>=1.37.1",
] ]
[tool.ruff.lint]
extend-select = [
# Правила, выпавшие из набора по умолчанию в ruff 0.16, но нужные проекту:
# E402 — импорты не в начале файла; A001/A002 — теневание встроенных имён
# аргументами invoke-задач.
"E402",
"A001",
"A002",
# Полная типизация: скрипты уезжают на сервер и правятся редко,
# аннотации — единственная страховка.
"ANN",
# pathlib вместо os.path, отсутствие закомментированного кода, стиль
# pytest и ограничение цикломатической сложности.
"PTH",
"ERA",
"PT",
"C90",
# Современный синтаксис: dict/list вместо typing.Dict/List, `X | None`
# вместо Optional. Те же места подсвечивает basedpyright в редакторе.
"UP",
# Наборы, по которым код уже чист: включены, чтобы так и оставалось.
"RET",
"N",
"Q",
"TID",
"G",
"LOG",
"FURB",
"PLC",
]
# Any в сигнатурах используется осознанно: разбор конфигов из TOML
# и параметры сторонних API без стабов типов.
ignore = ["ANN401"]
[tool.pyrefly]
# Без секции pyrefly ругается на отсутствие конфига; здесь же задаём,
# что проверять при запуске без аргументов.
project-includes = ["**/*.py"]
project-excludes = ["**/.venv/**", "**/galaxy.roles/**", "**/__pycache__/**"]
+59
View File
@@ -0,0 +1,59 @@
# owner
Заводит системного пользователя и группу под одно приложение: фиксированные
uid/gid, ssh-ключи и переменные окружения в `~/.env`.
Один сервис — один пользователь: контейнеры приложения работают от его uid/gid,
и файлы в `base_dir` принадлежат ему же. Роль вызывается первой задачей почти
каждого `playbook-<app>.yml`.
## Что делает
1. **Группа и пользователь.** Создаёт группу `owner_group` с `owner_gid` и
пользователя `owner_name` с `owner_uid` и шеллом `/bin/bash`. Номера задаёт
плейбук приложения (соглашение по нумерации — в `AGENTS.md`).
2. **SSH-ключи.** Прописывает `owner_ssh_keys` в `authorized_keys`.
3. **Переменные окружения.** Рендерит `~/.env` из `owner_env` плюс `USER_UID` и
`USER_GID` — их подставляют docker-compose шаблоны приложений. В `.bashrc`
добавляет строку, подгружающую `~/.env` целиком, и вычищает старые построчные
`export`-ы, оставшиеся от прежней схемы.
## Результат для плейбука
Роль регистрирует `owner_create_result` — результат модуля `user`. Плейбуки и
compose-шаблоны берут оттуда `owner_create_result.uid` и
`owner_create_result.group` (это gid) для `user:` в docker-compose.
## Переменные
Полный список и значения по умолчанию — в `defaults/main.yml`.
| Переменная | Обязательная | Описание |
| --------------------- | ------------ | --------------------------------------------------- |
| `owner_name` | да | Имя пользователя; по умолчанию оно же имя группы |
| `owner_uid` | да | Uid пользователя |
| `owner_group` | нет | Имя группы, по умолчанию `owner_name` |
| `owner_gid` | нет | Gid группы, по умолчанию `owner_uid` |
| `owner_extra_groups` | нет | Дополнительные группы; по умолчанию пусто |
| `owner_ssh_keys` | нет | Список публичных ключей для `authorized_keys` |
| `owner_env` | нет | Словарь переменных окружения, попадающих в `~/.env` |
## Про группу `docker`
Членство в группе `docker` равносильно root на хосте (`docker run -v /:/host`),
поэтому сервисным пользователям её не выдаём: контейнерами рулит Ansible от
root. Исключение — приложения, чей `backup.sh` дампит базу через
`docker compose exec`: оркестратор бэкапов запускает такие скрипты от имени
самого приложения. См. `docs/backlog/backup-dump-without-docker-group.md`.
## Пример использования
```yaml
- 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 }}"
```
+20
View File
@@ -0,0 +1,20 @@
---
galaxy_info:
role_name: owner
author: "Anton Vakhrushev"
description: "Системный пользователь приложения: группа, uid/gid, ssh-ключи и переменные окружения"
license: "MIT"
min_ansible_version: "2.14"
platforms:
- name: Ubuntu
versions:
- all
- name: Debian
versions:
- all
galaxy_tags:
- users
- system
- environment
dependencies: []
+28 -20
View File
@@ -1,21 +1,24 @@
--- ---
- name: 'Check app requirements for user "{{ owner_name }}".' # tasks file for owner
ansible.builtin.fail:
msg: You must set owner name.
when: not owner_name
- name: 'Check app requirements for user "{{ owner_name }}".' - name: "Validate owner role arguments"
ansible.builtin.fail: ansible.builtin.assert:
msg: You must set owner uid. that:
when: not owner_uid - owner_name is string and owner_name | length > 0
- owner_uid | int > 0
- owner_gid | int > 0
fail_msg: >-
Роль owner требует непустой owner_name и положительные owner_uid/owner_gid
(owner_name={{ owner_name }}, owner_uid={{ owner_uid }}, owner_gid={{ owner_gid }})
quiet: true
- name: 'Create group "{{ owner_group }}".' - name: "Create group {{ owner_group }}"
ansible.builtin.group: ansible.builtin.group:
name: "{{ owner_group }}" name: "{{ owner_group }}"
gid: "{{ owner_gid }}" gid: "{{ owner_gid }}"
state: present state: present
- name: 'Create user "{{ owner_name }}".' - name: "Create user {{ owner_name }}"
ansible.builtin.user: ansible.builtin.user:
name: "{{ owner_name }}" name: "{{ owner_name }}"
group: "{{ owner_group }}" group: "{{ owner_group }}"
@@ -24,20 +27,21 @@
shell: /bin/bash shell: /bin/bash
register: owner_create_result register: owner_create_result
- name: 'Set up user ssh keys for user "{{ owner_name }}".' - name: "Set up ssh keys for user {{ owner_name }}"
ansible.posix.authorized_key: ansible.posix.authorized_key:
user: "{{ owner_name }}" user: "{{ owner_name }}"
key: "{{ item }}" key: "{{ item }}"
state: present state: present
with_items: "{{ owner_ssh_keys }}" loop: "{{ owner_ssh_keys }}"
when: owner_ssh_keys | length > 0
- name: "Prepare env variables." # USER_UID/USER_GID кладём в окружение всегда: их подставляют docker-compose
# шаблоны приложений, чтобы контейнер работал от того же пользователя.
- name: "Prepare env variables"
ansible.builtin.set_fact: ansible.builtin.set_fact:
# yamllint disable-line rule:line-length owner_env_dict: >-
owner_env_dict: '{{ owner_env | combine({"USER_UID": owner_create_result.uid, "USER_GID": owner_create_result.group}) }}' {{ owner_env | combine({"USER_UID": owner_create_result.uid, "USER_GID": owner_create_result.group}) }}
- name: 'Set up environment variables for user "{{ owner_name }}".' - name: "Set up environment variables for user {{ owner_name }}"
ansible.builtin.template: ansible.builtin.template:
src: env.template src: env.template
dest: "/home/{{ owner_name }}/.env" dest: "/home/{{ owner_name }}/.env"
@@ -45,14 +49,18 @@
group: "{{ owner_group }}" group: "{{ owner_group }}"
mode: "0640" mode: "0640"
- name: 'Remove from bashrc absent environment variables for user "{{ owner_name }}".' # Раньше переменные писались в .bashrc построчно; теперь источник истины —
# ~/.env, поэтому старые строки вычищаем.
- name: "Remove from bashrc absent environment variables for user {{ owner_name }}"
ansible.builtin.lineinfile: ansible.builtin.lineinfile:
path: "/home/{{ owner_name }}/.bashrc" path: "/home/{{ owner_name }}/.bashrc"
regexp: "^export {{ item.key }}=" regexp: "^export {{ item.key }}="
state: absent state: absent
with_dict: "{{ owner_env_dict }}" loop: "{{ owner_env_dict | dict2items }}"
loop_control:
label: "{{ item.key }}"
- name: 'Include in bashrc environment variables for user "{{ owner_name }}".' - name: "Include in bashrc environment variables for user {{ owner_name }}"
ansible.builtin.lineinfile: ansible.builtin.lineinfile:
path: "/home/{{ owner_name }}/.bashrc" path: "/home/{{ owner_name }}/.bashrc"
regexp: "^export \\$\\(grep -v" regexp: "^export \\$\\(grep -v"
@@ -27,7 +27,6 @@ import hmac
import hashlib import hashlib
import base64 import base64
import argparse import argparse
import sys
# These values are required to calculate the signature. Do not change them. # These values are required to calculate the signature. Do not change them.
@@ -55,9 +54,6 @@ def calculate_key(secret_access_key: str) -> str:
def main() -> None: def main() -> None:
if sys.version_info[0] < 3:
raise Exception("Must be using Python 3")
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Convert a Secret Access Key to an SMTP password." description="Convert a Secret Access Key to an SMTP password."
) )
+21 -22
View File
@@ -5,6 +5,7 @@ import os
import re import re
import subprocess import subprocess
import sys import sys
from pathlib import Path
from invoke.context import Context from invoke.context import Context
from invoke.exceptions import Exit from invoke.exceptions import Exit
@@ -37,7 +38,7 @@ def _remote_host() -> str:
def _authelia_docker() -> str: def _authelia_docker() -> str:
"""Команда запуска authelia CLI на том же образе, что и задеплоенный сервис""" """Команда запуска authelia CLI на том же образе, что и задеплоенный сервис"""
pattern = re.compile(r"""image:\s*["']?(\S*authelia/authelia:[^"'\s]+)""") pattern = re.compile(r"""image:\s*["']?(\S*authelia/authelia:[^"'\s]+)""")
with open(AUTHELIA_COMPOSE_FILE, encoding="utf-8") as compose: with Path(AUTHELIA_COMPOSE_FILE).open(encoding="utf-8") as compose:
for line in compose: for line in compose:
match = pattern.search(line) match = pattern.search(line)
if match: if match:
@@ -56,7 +57,7 @@ def _rest_args() -> list[str]:
def _resolve_playbook(name: str) -> str: def _resolve_playbook(name: str) -> str:
candidates = [name, f"{name}.yml", f"playbook-{name}.yml"] candidates = [name, f"{name}.yml", f"playbook-{name}.yml"]
for candidate in candidates: for candidate in candidates:
if os.path.isfile(candidate): if Path(candidate).is_file():
return candidate return candidate
raise Exit( raise Exit(
f"Плейбук для '{name}' не найден. Проверял: {', '.join(candidates)}", code=1 f"Плейбук для '{name}' не найден. Проверял: {', '.join(candidates)}", code=1
@@ -141,16 +142,12 @@ def edit_encrypted(ctx: Context, path: str) -> None:
# Путь к канону — сосед по файловой системе (../ansible-roles/roles), # Путь к канону — сосед по файловой системе (../ansible-roles/roles),
# поэтому не зависит от текущей директории и одинаков везде. # поэтому не зависит от текущей директории и одинаков везде.
SHARED_ROLES_DIR = os.path.normpath( SHARED_ROLES_DIR = Path(__file__).resolve().parent.parent / "ansible-roles" / "roles"
os.path.join(
os.path.dirname(os.path.abspath(__file__)), "..", "ansible-roles", "roles"
)
)
SYNCED_ROLES = ["eget", "app_image"] SYNCED_ROLES = ["eget", "app_image"]
def _require_canon() -> None: def _require_canon() -> None:
if not os.path.isdir(SHARED_ROLES_DIR): if not SHARED_ROLES_DIR.is_dir():
raise Exit(f"Канон не найден: {SHARED_ROLES_DIR}", code=1) raise Exit(f"Канон не найден: {SHARED_ROLES_DIR}", code=1)
@@ -171,11 +168,13 @@ def roles_status(ctx: Context) -> None:
_require_canon() _require_canon()
drift = False drift = False
for role in SYNCED_ROLES: for role in SYNCED_ROLES:
canon = os.path.join(SHARED_ROLES_DIR, role) canon = SHARED_ROLES_DIR / role
if not os.path.isdir(canon): if not canon.is_dir():
print(f"{role}: missing in shared") print(f"{role}: missing in shared")
drift = True drift = True
elif subprocess.run(["diff", "-rq", f"roles/{role}", canon]).returncode != 0: elif (
subprocess.run(["diff", "-rq", f"roles/{role}", str(canon)]).returncode != 0
):
print(f"{role}: differs") print(f"{role}: differs")
drift = True drift = True
else: else:
@@ -189,10 +188,10 @@ def roles_pull(ctx: Context) -> None:
"""Канон → репозиторий: inv roles-pull [-- <role> ...]""" """Канон → репозиторий: inv roles-pull [-- <role> ...]"""
_require_canon() _require_canon()
for role in _roles_to_sync(): for role in _roles_to_sync():
src = os.path.join(SHARED_ROLES_DIR, role) src = SHARED_ROLES_DIR / role
if not os.path.isdir(src): if not src.is_dir():
raise Exit(f"Роль '{role}' отсутствует в каноне: {src}", code=1) raise Exit(f"Роль '{role}' отсутствует в каноне: {src}", code=1)
os.makedirs(f"roles/{role}", exist_ok=True) Path("roles", role).mkdir(parents=True, exist_ok=True)
ctx.run(f"rsync -a -i --delete {src}/ roles/{role}/") ctx.run(f"rsync -a -i --delete {src}/ roles/{role}/")
@@ -201,24 +200,24 @@ def roles_push(ctx: Context) -> None:
"""Репозиторий → канон, с подтверждением при расхождении: inv roles-push [-- <role> ...]""" """Репозиторий → канон, с подтверждением при расхождении: inv roles-push [-- <role> ...]"""
_require_canon() _require_canon()
for role in _roles_to_sync(): for role in _roles_to_sync():
local = f"roles/{role}" local = Path("roles", role)
canon = os.path.join(SHARED_ROLES_DIR, role) canon = SHARED_ROLES_DIR / role
if not os.path.isdir(local): if not local.is_dir():
raise Exit(f"Локальной роли нет: {local}", code=1) raise Exit(f"Локальной роли нет: {local}", code=1)
differs = ( differs = (
os.path.isdir(canon) canon.is_dir()
and subprocess.run(["diff", "-rq", local, canon]).returncode != 0 and subprocess.run(["diff", "-rq", str(local), str(canon)]).returncode != 0
) )
if differs: if differs:
# Защита от затирания более свежего канона устаревшей копией. # Защита от затирания более свежего канона устаревшей копией.
subprocess.run(["diff", "-r", canon, local]) subprocess.run(["diff", "-r", str(canon), str(local)])
answer = input( answer = input(
f"Канон '{role}' отличается. Перезаписать его копией из этого репо? [y/N] " f"Канон '{role}' отличается. Перезаписать его копией из этого репо? [y/N] "
) )
if answer.strip().lower() != "y": if answer.strip().lower() != "y":
print(f"{role}: пропущено") print(f"{role}: пропущено")
continue continue
os.makedirs(canon, exist_ok=True) canon.mkdir(parents=True, exist_ok=True)
ctx.run(f"rsync -a -i --delete {local}/ {canon}/") ctx.run(f"rsync -a -i --delete {local}/ {canon}/")
@@ -267,7 +266,7 @@ def authelia_validate_config(ctx: Context) -> None:
"""Отрендерить конфиг authelia из шаблона и проверить его""" """Отрендерить конфиг authelia из шаблона и проверить его"""
dest = "temp/configuration.yml" dest = "temp/configuration.yml"
# temp/ в .gitignore, на свежем клоне его нет — ansible сам директорию не создаёт. # temp/ в .gitignore, на свежем клоне его нет — ansible сам директорию не создаёт.
os.makedirs(os.path.dirname(dest), exist_ok=True) Path(dest).parent.mkdir(parents=True, exist_ok=True)
try: try:
ctx.run( ctx.run(
"uv run ansible localhost" "uv run ansible localhost"
Generated
+21 -95
View File
@@ -415,66 +415,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
] ]
[[package]]
name = "librt"
version = "0.8.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471, upload-time = "2026-02-17T16:13:06.101Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/95/21/d39b0a87ac52fc98f621fb6f8060efb017a767ebbbac2f99fbcbc9ddc0d7/librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a", size = 66516, upload-time = "2026-02-17T16:11:41.604Z" },
{ url = "https://files.pythonhosted.org/packages/69/f1/46375e71441c43e8ae335905e069f1c54febee63a146278bcee8782c84fd/librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9", size = 68634, upload-time = "2026-02-17T16:11:43.268Z" },
{ url = "https://files.pythonhosted.org/packages/0a/33/c510de7f93bf1fa19e13423a606d8189a02624a800710f6e6a0a0f0784b3/librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb", size = 198941, upload-time = "2026-02-17T16:11:44.28Z" },
{ url = "https://files.pythonhosted.org/packages/dd/36/e725903416409a533d92398e88ce665476f275081d0d7d42f9c4951999e5/librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d", size = 209991, upload-time = "2026-02-17T16:11:45.462Z" },
{ url = "https://files.pythonhosted.org/packages/30/7a/8d908a152e1875c9f8eac96c97a480df425e657cdb47854b9efaa4998889/librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7", size = 224476, upload-time = "2026-02-17T16:11:46.542Z" },
{ url = "https://files.pythonhosted.org/packages/a8/b8/a22c34f2c485b8903a06f3fe3315341fe6876ef3599792344669db98fcff/librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440", size = 217518, upload-time = "2026-02-17T16:11:47.746Z" },
{ url = "https://files.pythonhosted.org/packages/79/6f/5c6fea00357e4f82ba44f81dbfb027921f1ab10e320d4a64e1c408d035d9/librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9", size = 225116, upload-time = "2026-02-17T16:11:49.298Z" },
{ url = "https://files.pythonhosted.org/packages/f2/a0/95ced4e7b1267fe1e2720a111685bcddf0e781f7e9e0ce59d751c44dcfe5/librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972", size = 217751, upload-time = "2026-02-17T16:11:50.49Z" },
{ url = "https://files.pythonhosted.org/packages/93/c2/0517281cb4d4101c27ab59472924e67f55e375bc46bedae94ac6dc6e1902/librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921", size = 218378, upload-time = "2026-02-17T16:11:51.783Z" },
{ url = "https://files.pythonhosted.org/packages/43/e8/37b3ac108e8976888e559a7b227d0ceac03c384cfd3e7a1c2ee248dbae79/librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0", size = 241199, upload-time = "2026-02-17T16:11:53.561Z" },
{ url = "https://files.pythonhosted.org/packages/4b/5b/35812d041c53967fedf551a39399271bbe4257e681236a2cf1a69c8e7fa1/librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a", size = 54917, upload-time = "2026-02-17T16:11:54.758Z" },
{ url = "https://files.pythonhosted.org/packages/de/d1/fa5d5331b862b9775aaf2a100f5ef86854e5d4407f71bddf102f4421e034/librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444", size = 62017, upload-time = "2026-02-17T16:11:55.748Z" },
{ url = "https://files.pythonhosted.org/packages/c7/7c/c614252f9acda59b01a66e2ddfd243ed1c7e1deab0293332dfbccf862808/librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d", size = 52441, upload-time = "2026-02-17T16:11:56.801Z" },
{ url = "https://files.pythonhosted.org/packages/c5/3c/f614c8e4eaac7cbf2bbdf9528790b21d89e277ee20d57dc6e559c626105f/librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35", size = 66529, upload-time = "2026-02-17T16:11:57.809Z" },
{ url = "https://files.pythonhosted.org/packages/ab/96/5836544a45100ae411eda07d29e3d99448e5258b6e9c8059deb92945f5c2/librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583", size = 68669, upload-time = "2026-02-17T16:11:58.843Z" },
{ url = "https://files.pythonhosted.org/packages/06/53/f0b992b57af6d5531bf4677d75c44f095f2366a1741fb695ee462ae04b05/librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c", size = 199279, upload-time = "2026-02-17T16:11:59.862Z" },
{ url = "https://files.pythonhosted.org/packages/f3/ad/4848cc16e268d14280d8168aee4f31cea92bbd2b79ce33d3e166f2b4e4fc/librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04", size = 210288, upload-time = "2026-02-17T16:12:00.954Z" },
{ url = "https://files.pythonhosted.org/packages/52/05/27fdc2e95de26273d83b96742d8d3b7345f2ea2bdbd2405cc504644f2096/librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363", size = 224809, upload-time = "2026-02-17T16:12:02.108Z" },
{ url = "https://files.pythonhosted.org/packages/7a/d0/78200a45ba3240cb042bc597d6f2accba9193a2c57d0356268cbbe2d0925/librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0", size = 218075, upload-time = "2026-02-17T16:12:03.631Z" },
{ url = "https://files.pythonhosted.org/packages/af/72/a210839fa74c90474897124c064ffca07f8d4b347b6574d309686aae7ca6/librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012", size = 225486, upload-time = "2026-02-17T16:12:04.725Z" },
{ url = "https://files.pythonhosted.org/packages/a3/c1/a03cc63722339ddbf087485f253493e2b013039f5b707e8e6016141130fa/librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb", size = 218219, upload-time = "2026-02-17T16:12:05.828Z" },
{ url = "https://files.pythonhosted.org/packages/58/f5/fff6108af0acf941c6f274a946aea0e484bd10cd2dc37610287ce49388c5/librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b", size = 218750, upload-time = "2026-02-17T16:12:07.09Z" },
{ url = "https://files.pythonhosted.org/packages/71/67/5a387bfef30ec1e4b4f30562c8586566faf87e47d696768c19feb49e3646/librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d", size = 241624, upload-time = "2026-02-17T16:12:08.43Z" },
{ url = "https://files.pythonhosted.org/packages/d4/be/24f8502db11d405232ac1162eb98069ca49c3306c1d75c6ccc61d9af8789/librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a", size = 54969, upload-time = "2026-02-17T16:12:09.633Z" },
{ url = "https://files.pythonhosted.org/packages/5c/73/c9fdf6cb2a529c1a092ce769a12d88c8cca991194dfe641b6af12fa964d2/librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79", size = 62000, upload-time = "2026-02-17T16:12:10.632Z" },
{ url = "https://files.pythonhosted.org/packages/d3/97/68f80ca3ac4924f250cdfa6e20142a803e5e50fca96ef5148c52ee8c10ea/librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0", size = 52495, upload-time = "2026-02-17T16:12:11.633Z" },
{ url = "https://files.pythonhosted.org/packages/c9/6a/907ef6800f7bca71b525a05f1839b21f708c09043b1c6aa77b6b827b3996/librt-0.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f", size = 66081, upload-time = "2026-02-17T16:12:12.766Z" },
{ url = "https://files.pythonhosted.org/packages/1b/18/25e991cd5640c9fb0f8d91b18797b29066b792f17bf8493da183bf5caabe/librt-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c", size = 68309, upload-time = "2026-02-17T16:12:13.756Z" },
{ url = "https://files.pythonhosted.org/packages/a4/36/46820d03f058cfb5a9de5940640ba03165ed8aded69e0733c417bb04df34/librt-0.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7aae78ab5e3206181780e56912d1b9bb9f90a7249ce12f0e8bf531d0462dd0fc", size = 196804, upload-time = "2026-02-17T16:12:14.818Z" },
{ url = "https://files.pythonhosted.org/packages/59/18/5dd0d3b87b8ff9c061849fbdb347758d1f724b9a82241aa908e0ec54ccd0/librt-0.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:172d57ec04346b047ca6af181e1ea4858086c80bdf455f61994c4aa6fc3f866c", size = 206907, upload-time = "2026-02-17T16:12:16.513Z" },
{ url = "https://files.pythonhosted.org/packages/d1/96/ef04902aad1424fd7299b62d1890e803e6ab4018c3044dca5922319c4b97/librt-0.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b1977c4ea97ce5eb7755a78fae68d87e4102e4aaf54985e8b56806849cc06a3", size = 221217, upload-time = "2026-02-17T16:12:17.906Z" },
{ url = "https://files.pythonhosted.org/packages/6d/ff/7e01f2dda84a8f5d280637a2e5827210a8acca9a567a54507ef1c75b342d/librt-0.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10c42e1f6fd06733ef65ae7bebce2872bcafd8d6e6b0a08fe0a05a23b044fb14", size = 214622, upload-time = "2026-02-17T16:12:19.108Z" },
{ url = "https://files.pythonhosted.org/packages/1e/8c/5b093d08a13946034fed57619742f790faf77058558b14ca36a6e331161e/librt-0.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4c8dfa264b9193c4ee19113c985c95f876fae5e51f731494fc4e0cf594990ba7", size = 221987, upload-time = "2026-02-17T16:12:20.331Z" },
{ url = "https://files.pythonhosted.org/packages/d3/cc/86b0b3b151d40920ad45a94ce0171dec1aebba8a9d72bb3fa00c73ab25dd/librt-0.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:01170b6729a438f0dedc4a26ed342e3dc4f02d1000b4b19f980e1877f0c297e6", size = 215132, upload-time = "2026-02-17T16:12:21.54Z" },
{ url = "https://files.pythonhosted.org/packages/fc/be/8588164a46edf1e69858d952654e216a9a91174688eeefb9efbb38a9c799/librt-0.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7b02679a0d783bdae30d443025b94465d8c3dc512f32f5b5031f93f57ac32071", size = 215195, upload-time = "2026-02-17T16:12:23.073Z" },
{ url = "https://files.pythonhosted.org/packages/f5/f2/0b9279bea735c734d69344ecfe056c1ba211694a72df10f568745c899c76/librt-0.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:190b109bb69592a3401fe1ffdea41a2e73370ace2ffdc4a0e8e2b39cdea81b78", size = 237946, upload-time = "2026-02-17T16:12:24.275Z" },
{ url = "https://files.pythonhosted.org/packages/e9/cc/5f2a34fbc8aeb35314a3641f9956fa9051a947424652fad9882be7a97949/librt-0.8.1-cp314-cp314-win32.whl", hash = "sha256:e70a57ecf89a0f64c24e37f38d3fe217a58169d2fe6ed6d70554964042474023", size = 50689, upload-time = "2026-02-17T16:12:25.766Z" },
{ url = "https://files.pythonhosted.org/packages/a0/76/cd4d010ab2147339ca2b93e959c3686e964edc6de66ddacc935c325883d7/librt-0.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:7e2f3edca35664499fbb36e4770650c4bd4a08abc1f4458eab9df4ec56389730", size = 57875, upload-time = "2026-02-17T16:12:27.465Z" },
{ url = "https://files.pythonhosted.org/packages/84/0f/2143cb3c3ca48bd3379dcd11817163ca50781927c4537345d608b5045998/librt-0.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:0d2f82168e55ddefd27c01c654ce52379c0750ddc31ee86b4b266bcf4d65f2a3", size = 48058, upload-time = "2026-02-17T16:12:28.556Z" },
{ url = "https://files.pythonhosted.org/packages/d2/0e/9b23a87e37baf00311c3efe6b48d6b6c168c29902dfc3f04c338372fd7db/librt-0.8.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c74a2da57a094bd48d03fa5d196da83d2815678385d2978657499063709abe1", size = 68313, upload-time = "2026-02-17T16:12:29.659Z" },
{ url = "https://files.pythonhosted.org/packages/db/9a/859c41e5a4f1c84200a7d2b92f586aa27133c8243b6cac9926f6e54d01b9/librt-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a355d99c4c0d8e5b770313b8b247411ed40949ca44e33e46a4789b9293a907ee", size = 70994, upload-time = "2026-02-17T16:12:31.516Z" },
{ url = "https://files.pythonhosted.org/packages/4c/28/10605366ee599ed34223ac2bf66404c6fb59399f47108215d16d5ad751a8/librt-0.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2eb345e8b33fb748227409c9f1233d4df354d6e54091f0e8fc53acdb2ffedeb7", size = 220770, upload-time = "2026-02-17T16:12:33.294Z" },
{ url = "https://files.pythonhosted.org/packages/af/8d/16ed8fd452dafae9c48d17a6bc1ee3e818fd40ef718d149a8eff2c9f4ea2/librt-0.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9be2f15e53ce4e83cc08adc29b26fb5978db62ef2a366fbdf716c8a6c8901040", size = 235409, upload-time = "2026-02-17T16:12:35.443Z" },
{ url = "https://files.pythonhosted.org/packages/89/1b/7bdf3e49349c134b25db816e4a3db6b94a47ac69d7d46b1e682c2c4949be/librt-0.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:785ae29c1f5c6e7c2cde2c7c0e148147f4503da3abc5d44d482068da5322fd9e", size = 246473, upload-time = "2026-02-17T16:12:36.656Z" },
{ url = "https://files.pythonhosted.org/packages/4e/8a/91fab8e4fd2a24930a17188c7af5380eb27b203d72101c9cc000dbdfd95a/librt-0.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d3a7da44baf692f0c6aeb5b2a09c5e6fc7a703bca9ffa337ddd2e2da53f7732", size = 238866, upload-time = "2026-02-17T16:12:37.849Z" },
{ url = "https://files.pythonhosted.org/packages/b9/e0/c45a098843fc7c07e18a7f8a24ca8496aecbf7bdcd54980c6ca1aaa79a8e/librt-0.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5fc48998000cbc39ec0d5311312dda93ecf92b39aaf184c5e817d5d440b29624", size = 250248, upload-time = "2026-02-17T16:12:39.445Z" },
{ url = "https://files.pythonhosted.org/packages/82/30/07627de23036640c952cce0c1fe78972e77d7d2f8fd54fa5ef4554ff4a56/librt-0.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e96baa6820280077a78244b2e06e416480ed859bbd8e5d641cf5742919d8beb4", size = 240629, upload-time = "2026-02-17T16:12:40.889Z" },
{ url = "https://files.pythonhosted.org/packages/fb/c1/55bfe1ee3542eba055616f9098eaf6eddb966efb0ca0f44eaa4aba327307/librt-0.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31362dbfe297b23590530007062c32c6f6176f6099646bb2c95ab1b00a57c382", size = 239615, upload-time = "2026-02-17T16:12:42.446Z" },
{ url = "https://files.pythonhosted.org/packages/2b/39/191d3d28abc26c9099b19852e6c99f7f6d400b82fa5a4e80291bd3803e19/librt-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc3656283d11540ab0ea01978378e73e10002145117055e03722417aeab30994", size = 263001, upload-time = "2026-02-17T16:12:43.627Z" },
{ url = "https://files.pythonhosted.org/packages/b9/eb/7697f60fbe7042ab4e88f4ee6af496b7f222fffb0a4e3593ef1f29f81652/librt-0.8.1-cp314-cp314t-win32.whl", hash = "sha256:738f08021b3142c2918c03692608baed43bc51144c29e35807682f8070ee2a3a", size = 51328, upload-time = "2026-02-17T16:12:45.148Z" },
{ url = "https://files.pythonhosted.org/packages/7c/72/34bf2eb7a15414a23e5e70ecb9440c1d3179f393d9349338a91e2781c0fb/librt-0.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:89815a22daf9c51884fb5dbe4f1ef65ee6a146e0b6a8df05f753e2e4a9359bf4", size = 58722, upload-time = "2026-02-17T16:12:46.85Z" },
{ url = "https://files.pythonhosted.org/packages/b2/c8/d148e041732d631fc76036f8b30fae4e77b027a1e95b7a84bb522481a940/librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61", size = 48755, upload-time = "2026-02-17T16:12:47.943Z" },
]
[[package]] [[package]]
name = "markupsafe" name = "markupsafe"
version = "3.0.3" version = "3.0.3"
@@ -538,39 +478,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
] ]
[[package]]
name = "mypy"
version = "1.19.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "librt", marker = "platform_python_implementation != 'PyPy'" },
{ name = "mypy-extensions" },
{ name = "pathspec" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" },
{ url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" },
{ url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" },
{ url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" },
{ url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" },
{ url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" },
{ url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" },
{ url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" },
{ url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" },
{ url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" },
{ url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" },
{ url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" },
{ url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" },
{ url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" },
{ url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" },
{ url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" },
{ url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" },
{ url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" },
{ url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" },
]
[[package]] [[package]]
name = "mypy-extensions" name = "mypy-extensions"
version = "1.1.0" version = "1.1.0"
@@ -607,7 +514,7 @@ dependencies = [
{ name = "ansible-lint" }, { name = "ansible-lint" },
{ name = "croniter" }, { name = "croniter" },
{ name = "invoke" }, { name = "invoke" },
{ name = "mypy" }, { name = "pyrefly" },
{ name = "requests" }, { name = "requests" },
{ name = "ruff" }, { name = "ruff" },
{ name = "types-croniter" }, { name = "types-croniter" },
@@ -621,7 +528,7 @@ requires-dist = [
{ name = "ansible-lint", specifier = ">=25.12.2" }, { name = "ansible-lint", specifier = ">=25.12.2" },
{ name = "croniter", specifier = ">=6.0.0" }, { name = "croniter", specifier = ">=6.0.0" },
{ name = "invoke", specifier = ">=2.2.1" }, { name = "invoke", specifier = ">=2.2.1" },
{ name = "mypy", specifier = ">=1.19.1" }, { name = "pyrefly", specifier = ">=1.2.0" },
{ name = "requests", specifier = ">=2.32.5" }, { name = "requests", specifier = ">=2.32.5" },
{ name = "ruff", specifier = ">=0.15.2" }, { name = "ruff", specifier = ">=0.15.2" },
{ name = "types-croniter", specifier = ">=6.0.0" }, { name = "types-croniter", specifier = ">=6.0.0" },
@@ -647,6 +554,25 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" },
] ]
[[package]]
name = "pyrefly"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/89/01/a86e9f24722b095c3f88e3616132b75a21b0df53804bdc6a45314dd4d93c/pyrefly-1.2.0.tar.gz", hash = "sha256:5485f960fc2481617068c918335c39ab1507ef90b6b5bd35bf57726e60e73185", size = 6243654, upload-time = "2026-08-01T02:56:27.592Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7d/9d/3c0ef1d4843987b22f996ed381ec9cf5a3b1273e29804db276252e4c95eb/pyrefly-1.2.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7f46d983ac49ddd2b043694960a01dc6a19a5cfd8eec609d6bd9c42866f91b4e", size = 14026305, upload-time = "2026-08-01T02:56:02.611Z" },
{ url = "https://files.pythonhosted.org/packages/0a/06/03bbb78fbea54cdc65b626619f3597d5611aca4fdef11e72a4e8360e7e63/pyrefly-1.2.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:756f669b5555090f5c1a4fef30db1785fabe657764f7e4e6dc88994dfb8ca82d", size = 13463880, upload-time = "2026-08-01T02:56:04.93Z" },
{ url = "https://files.pythonhosted.org/packages/13/5a/7d8bc00a38e93bbc9c3e7bd14d305f7948717e667c9bcddeab9dd42fd255/pyrefly-1.2.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3465812ce5ef4781fb592edbf2724547296f0a3124be115d73c7e8b2401862d", size = 13907329, upload-time = "2026-08-01T02:56:07.104Z" },
{ url = "https://files.pythonhosted.org/packages/be/94/9e08b4bf799d0b8f36b55a2783c7ba5f51730cf0632a85a67b5b5ed876cd/pyrefly-1.2.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5de7b2ad2bba5c8055181681a84b74143eac2234a48ba5d1b7ed7e7a722b02bd", size = 15039020, upload-time = "2026-08-01T02:56:09.208Z" },
{ url = "https://files.pythonhosted.org/packages/5b/bd/bca5fd0c80f4daf8ee6903a29df9f3de1feb05ff0946b8f35ec8c5096b13/pyrefly-1.2.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:25822ea9505f589ea8a725e4268b475132fb89e038fbf092e446510443ac142a", size = 14986199, upload-time = "2026-08-01T02:56:11.924Z" },
{ url = "https://files.pythonhosted.org/packages/97/f7/f07087f3d185ad2eced0c56cef89ca5474dfb4ff25f146cd50a861c97553/pyrefly-1.2.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90efe75e17491ef5d636e10469e9278d7d0256b3b4c5e1f4750069bf3ae0f5d1", size = 14393715, upload-time = "2026-08-01T02:56:14.143Z" },
{ url = "https://files.pythonhosted.org/packages/d3/70/0d142c320e284b9e3ce35e9b1e58b8ce2ee1f578f2a7234bc30e5022b94f/pyrefly-1.2.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:368aaf7eee4f511ddc0f8e564cf14e01ab2f10b0db9105c6d5b153bf498d07bf", size = 13933008, upload-time = "2026-08-01T02:56:16.525Z" },
{ url = "https://files.pythonhosted.org/packages/5d/e8/e84f11b6e1f63fd453ad3654213b9a0f6f4de8cef6b58038eef2d0d5955d/pyrefly-1.2.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d52d5da7bc65fb7675fbaa80eda879d4f8787c494f04cac21603330d3abbdbbe", size = 14431827, upload-time = "2026-08-01T02:56:18.645Z" },
{ url = "https://files.pythonhosted.org/packages/0f/06/810d31380f66c75e1c0779a408d3b16117b1b368b57894f6aa66bef21686/pyrefly-1.2.0-py3-none-win32.whl", hash = "sha256:8c90751de8506d938e8f802659c74cf35bd7a0036510ee6c634a38eebb280bfa", size = 13229447, upload-time = "2026-08-01T02:56:20.921Z" },
{ url = "https://files.pythonhosted.org/packages/ed/98/4dafa3c7a1caed2dc8cc708dde09ba27963c7736508f55b626fff3024113/pyrefly-1.2.0-py3-none-win_amd64.whl", hash = "sha256:8a8964c224ccc4882730130955815de21ff443c1ac3f0b90685b19bf63848170", size = 14087387, upload-time = "2026-08-01T02:56:23.188Z" },
{ url = "https://files.pythonhosted.org/packages/1b/1c/df3cb0a2e5591660ded7a1836cd2f29dc48c91adb1c0a3a700a96f6d09e1/pyrefly-1.2.0-py3-none-win_arm64.whl", hash = "sha256:3a90bb8df39dfbac74b1f3b2e9d7c526b8f80568884c3944d955023a73ebf61e", size = 13430873, upload-time = "2026-08-01T02:56:25.425Z" },
]
[[package]] [[package]]
name = "python-dateutil" name = "python-dateutil"
version = "2.9.0.post0" version = "2.9.0.post0"