Compare commits

...
18 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
59 changed files with 1081 additions and 768 deletions
+11 -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`.
@@ -109,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-файлах.
@@ -149,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
-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"
+59 -27
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
@@ -252,7 +284,7 @@ dozzle.vakhrushev.me {
{{ goatcounter_sites | join(', ') }} { {{ goatcounter_sites | join(', ') }} {
tls anwinged@ya.ru tls anwinged@ya.ru
import common import common
reverse_proxy { reverse_proxy {
to goatcounter_app:8080 to goatcounter_app:8080
} }
@@ -261,7 +293,7 @@ dozzle.vakhrushev.me {
goaccess.vakhrushev.me { 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
@@ -281,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"
+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 }}
+3
View File
@@ -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"
+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
@@ -9,7 +9,11 @@ services:
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:
+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:
+245 -245
View File
@@ -1,246 +1,246 @@
$ANSIBLE_VAULT;1.1;AES256 $ANSIBLE_VAULT;1.1;AES256
33616165353931656432336130616463663066336561643964346139376535633630313039313831 62666365313535383334366166313961303933373163323236626565303038396662363239376438
3163663434323730343337333137313366333832313330330a383833386138666139313536353763 3738323565643363323865663630303330306530303263350a393336646463663563666362643362
39666132343135613236326436633630663662373036623030623336366433356132376233623333 61313337363936313363316635376163646633383032613265363936396536386437306139396461
6366333565326331320a353037383539653766653765343365633134643032323739363436633637 3963656162316562620a393737383636343965656433346466316333613331303435323732363239
65356435313961613266326664393165376335313534343832366436303339666233333132313865 39313834373536396237333264303930393762383333633363333265363039373061353334613530
34323865633337386561643237383133613566613534383664333664383562366634396361646363 33633161373631363061383564633465633962383835633237623938343865303363633461313830
64663530613337643461643133376637663462363261343464373238393730393939323762303263 36383865663932666562333733653935626166663066363233303962643364343739663463396364
30313432613136366330343930383263313436663233346630366263643939343530353661633638 34353034656130343739336532393334306261366331353030306161636437363464336536386638
30666266353161396163636236666665643035336237613037323361303361323934333933613464 62653362633363393838366564313165346262653432663563366362616364396633323563643239
34373639323163363835666633656133363731326665383261346633633835663937346233326135 35333232396239383233656231333264663938373364313730623364393436626361653736613939
61356134656165373136396634306164646361303536653633613732613635626234643961396437 64393833353765346633363334656631343064656333383664373039393966346332393134303030
64313164306130666364653561306562633732663561346562626538353462386461623662626232 36373436343766323038303438386436376666393239643636366330336336333761356436376234
35313634303163666435326230636464323038346137353164663837663734306536666233393736 33383266663030643931626662383636333637616539313366326539383139616334363939626161
62356562396663356535613634313639653363623030373661666333663138366630386630343831 34346263643031363236313334623162343236636665383262393666306366383064343965373639
30373231373466303866653933303739613835313734396564303339373439323436613235363934 34323062656633386661343332306664633361396233656238613436636530323839653838376563
30326338346635663030393135363836663332333031633239346435623734303932366466346430 33336664643365363930366636656138616236626639663564613132653465636462633364646166
66646432613165666665396535626634316161316334653830386365626631393466363439656135 36393966326365373530663839653737656438646662373730333666376137653337303834383032
34323332643035313835616636623038613137336262366630663864663866393164373832613938 33363636376435623064646566323065383466623138323938303630353330663132636431333830
65646133353131333430386631616464663162373533303838633335393737653465663762346639 30313434333262616138343634303438386236326330636161336462363333386561643462666439
62303166323761373164383163616539313835356134343039326166636630653337316334623535 35343837653934613338313537336232656564663337666262656361383736363939383433623938
31643431616337313266383736646162383765663630613332323733303233656432633666376331 32633965633931643836346362356334666230663734643130656566353937636331303633323934
30303763636131636330626635323639316161343365333864396633356631653431396438613335 62656634643263333566343739666634663061386137373539383736613864656230373566666162
61326365356130316263353034336131303835323535376466313534666230393766386333636265 32616363633534313933613533373265623366303766626232623966623531373930613831643034
65336538346136303038393566373131313762343465313539373961333739623433393532626237 64306236353164643138666564616464383638636263326531633866363732653664313364386661
62383564376232613138323262343064626338633934383536633537336462336664386463363564 30633536623336353532363261623938353232396336343662643965353839333761376463363631
64333164613234373033333463323662376533333165363461393533666566376135356338356430 66353862646131343961363363303432336431633761313633386235383435336563383333613830
61333732653863376266616436356537663761333531396237393637383062663532373633633531 62333864373362663538326363656531643733356232643632396631323636376164653333316137
36613537633063636539363961666133316338353439613432386139376435396464386330306433 39626336333139323733653535663132333030323938303166396336356136653231366332613039
33623037383761656231646331306561633763306135386638383366356232616230373465393733 30346237353532623065303033326534663034333938663365303264323061613964313139316538
39656133313062356633663865353137336235663532363661336231343139343561643639623466 32633537346165616136623431653766383535393230303064383433626634386331356434323561
66356535633031363231313966653566383137383436376166613863633235343061303037333032 31646137623339373662626631356337616666303131633138366433356163373766616531383138
38353961386562353336343764383938633633363761396563373065353266666434306235313230 33366361323935363163363731636631643135636332316439303335616661373039346433356138
62306337373430656531353430303131343239323739303434333632396365613236613861643136 34613061646138626637316262623632303339653633346161353836623330313432396333323464
31356662653162323465316334623733666331323064353337376639343563663436346461323965 30346232646661643839663835373439323031623462386432623332316535623764343039333039
31623731613334636361353961306163626666376162313165333665626439613339663138376338 62313031663731306539303634666461376133643138363235383531366239643331313238623666
61333234663239353562336632613662323139306639363234623830643736653536613063393165 62656631333030626337333765646339643561396537333462643166616165636261323663333963
30663836393039373665336461316632616431626165376631643566613932633036613537383663 32656336353837323231643561306134323065396131343965373534656530376237616134343034
36633166386139636539303463623266633333326137666135643338303239353436323232386233 61313430376531333261323530346430303163313864333033663536386561633939383030666430
37376630366635666533646538636539363633666634346462623635623539623361336362626563 30633864316363383631386636656331323234356438643132633637623637646536316532373639
30393464666336393230393334613665643231373633653631333965633632303437353634376333 38313535663930666335333239623935393232633432343561663938343335356565626266353630
37313836666630313963626536626537333434383663383538313538343134326664323032363666 35666134383431653163356230663038336563366433623562333333313733383536353635613835
64653562313433386661396133373534636362656666646563376166313361643031333537393837 35366539306437623336623230343666366438333334613765393930313234383365623466393934
62326231373431616633633862396334393133393832383231373433353230303638653139653065 32633931363164626464396534333431666630316365323265376161313138313232646665323532
35643637623361346439613831636137663531613638396232623632363837616435323836383036 39353563393235353632323930326537643162386430383461306136306339306535363565633662
38323161343066653563616638663631616636356339373932623237306639333065613430643864 35336434343938353538363563373363393464623633313638346236633662623837633263633166
34613635633131663433363862333732353562353731333165666533356331363964343731636364 64666630346235613664356433653436616166643762653431376666306364623237363363613333
35313537336366336435333831313834353565343635386434303362333238623930623461393037 31316533363966633362366266613863353136353662313636393234303361396531333063623863
34653163626331326364343364393963373739326430336533653462313833626262323232383263 30343534643835613462353739613730633331643837383836326236636235623965356333343961
61643636316630353530633234666462363032333135303431613361366530306538623133306336 32396135393564653537613731316133633665653061386664343233396537383332643163356639
32323265623863613037653962323361393966666464346539636239336164366635396262383032 36653134303361656438666635396339323332346133623439613031613764666665303335613738
61643231356235643532333666626366633430643262626263656135636436333030663739366638 64303537616531643237633835613937663739313466346463323037636432373235363038393461
35303462633731373437306330363265663965663664633237646334386532653462373431306133 66363162323562646264323133306538633430303538356231326436313432323931623135306262
63303434643630323461363135386233346662616236343736306339653662396330383664656334 30666132383131343262326531303931343539646130356332333964613938363033623731633463
37616634366566613934656132316434643162353233396331323033313332653230316135336564 36363635366165663064653934623235323930336538643634623233383535323930386664313530
66373735303535303130666639626139333664383232343938336133396164393362333237653966 65383264623736373261343331353339313361383539653262373338316163353063663038656465
35363931326336336262313266653931366239363931373134346464353164313639353738396231 35356133353465303137373039363535336232326233353937643465323539343263363739663531
66636533353536373532623032626333356266633439613961633137326439633964353634363362 66666566373835336433353761336338336533353463386230306665616638393864646264373563
62353461366637366531316433616334343762376239376562356230346534353530356531313339 33313339646237626264366263613036373931666366326663313964633738393164633534623839
32656131343430383035333264393331643262346632623166343433313262323234383064613636 39613132396661393761343230633135323732613836383661353665663831623766616639343331
62336330636136346162393961353432353236633630666239646138316366363739616464353765 62623862343737643930373432323563663264366234646533353835353264333333623734313435
35326661313864313235633462623231623634343662626131346530333762313535393937336437 62316666306437643835386235303139616534303932363263373863653633613464363431356132
31376437313936383232633231386538396664346465666130353061656133306163653734313862 32653661373365623364316663393262303538633039366138316630643031626565316335613436
64653736366232333561313435306335623730313635333938353965313762313334373932653030 36356137653139303334616631396634376262396133633161306233336162643632383635333333
35366266626636356234386631373838653337646361343233336461633330653632333338356361 65373633666637343666366463323765656633303732323631393064353739376430303934333232
64396331326638333264633837326364636137633861333338643733393361633637656162616262 63393831386363343233363732326234643039396637383132336162326336363033393436383931
63366333376534663932626635363239396134303362323239646339363836306531666266363765 37323763326461643837393139613165346136316663646464343338613061323936303030356466
37646362336465346666376135373738663363653334393334616166396537333535353436393134 39636138376632653063643963306334396339666665323234336135396561393166643236663733
63373535666535373432396134316363646239383733653638343661306266633831313935633639 32316364623831626134306163393239303564313466636663393166336662613739323864346637
30386139323464343234336230363037373336383566376431363031326534653235656465373061 38393064313362663935666632386339393561663735656639373633623933306635363330333736
30616531323365326639366431656437643435343739623563346266333865363062376464346366 62633966653138633231346331393533633333623835666463666330333835316631666238396334
30353437663063396532323134323436323966326662643632663936316564336464303364643235 33343232396135383834383564636235663634346336373536663539653861633862653332633962
39306532393838336235303335653537386530376131666236366363393132663931323534306639 36343136633532323565316431396630313837363138343833316363366165383337343930626361
34383134666664613066663965303363336138613033343863373661376132333633646435383038 66633535643839396463343264313336663430326137356465663166353833636134356434613839
63373330363532303339373934653264613761353962633061303936383336653338323065646131 39313439323431633966636533336530346138353935663433333661353634316632646333363434
36376566643032663863623835613263663163376462356135376664313239653532333363656633 33326165653033663965333934363633353235363931626463383565393331366566663666633733
39303063623136363238393761376166303837356334663734623736633662626566353164313134 32643863653661633137313263646630373465343434666234663236393832313761383535613363
33383532363462396363356230623136313436646536666234363562333534303665333335633332 65346335626535383335643361653531366139363637333838376562326138386361623562616165
66353635323162373836393033653937633336386531623966646663643832356162373661383663 37333730663134666466666130396662616138663962366630323330616333323539623532613733
33353262643163316232316261353939303939336231366331366432393738333137303963383034 62373531653564636662316334646338346337616433333533346539363066366338333233616337
65376637356539643138343039373237376231386438616566666562333938636431373037643166 33666130323330356364326633623165366465326331383361323630363330633833363536373930
34376361363930326465353563653236396230656333316338623165366330653034333234653632 64393464383533383335623263613136336632363132373663326263303162636331666164646234
34363033613939316330386264373630376633646130636539633561333233343135623636393564 32316133313438616563376230636537636334346637356339336533333264643461666439643238
66356664363562353564613137363239343830393639333130383435396630396439633834623734 66376461643136336665643366643363366365373636613537636462653737303131643661336364
64323633666564363736623035646235393064313733386635353365366461383634643939393264 34323261313632663064346263336463363932646261613232643030383038613137303532343766
35363631666630373138656233623966613437666432633666333436663537363932343463373734 36663366396338306534633062616436333133316438666261396136323535613436356337613339
36313962363538326537343931353538333030643936633364316439343236306338333730373830 33626565663137666265316533306231643435376465333566643366633638346631663435306534
63396337316130393036323266643063353665303539386265623134313164656534633131303565 37373836366438336365326265653433306334313630373737343630363534313131386332633134
31646564323630343964306235386165663438336465343764633964623262626237636436343032 65373862643431313031626236356565626133633663613564613238313962323934633437383930
66653962333838303432316435316663383964333036313339343437323238333466313237336166 65393765313934393438663338343239353732316130366466323638656439613234643036356265
31336330353530373862656139626365303539343537393964326434653765646363373238383539 36646631313362663833343934363930373536333531626161623038396331373065633965643735
65353861366239343034316365343262656136353931353834663639666132343531313433306431 37666162343930383133306139306131356232636530306234616464633736636538346431613066
65306163393162653531343838633061336666363461336262636338653332396163353432383230 31396630653338623733663262663335623466613430316663316435653233653837623131396462
35626231656431653064373630303936353432373332316130383831343463653339626639383834 31633531356239326538663737313437303735363565316263323131383362306235363835333830
39343131393032613034623237383738666634393635343034333639363034373832646137383864 64333438633837356633633435643830633938396637326662666137333934613531316538626633
35363438376131656262323364623836623137663662343036356561396262386336346261353133 62346431626662663538303431336234613164383536313363303932643062316538343332386165
37633634626439663039396564646639663162646436646162393138333561653331363338313763 62653937663135353638383363333634383031383362663039646133666634356661313537303566
37353138626232376236366432663262333566323533646665396234656366313939376132373562 32316361613261313830363532336238616265393661343133373231633964323535656530336136
32653964333538666430313230666262666362306231303637623539623238643862353532306166 38393163626133336235346162633165396333633339316462306433656262623736376332336330
35353831653063393664343061393166323962303564383166626539366464373935616164313066 63363733363263636336633432366633393533373566653836663165626162363735363963316532
34666330656462326338313837653666653037363566333062313765383136636436363732346265 66376238303538386537363233376431393833303263383733303261386237623136613435613530
39363161643738303433663838326163383534636463346264343232393564653230636566653563 34383738646330336636363336346264323132393962666334613134313635393165656236623234
38363138383637653963376432633161616565663663333038363736653362383261646337356633 35333535353562656463376135353562386532366235363865343038316561663933373339356336
61613235303765613830633866636461363861646439633566323938643738613932656431383330 35393830623430323365613863393932633535376436663666356562386461336239366665333436
65373933323161303761366639633331366531383664376435646337353365653332643163336438 32626534373830386134666233346333393064343933323830336462666136386538306463313531
61393533643962383863626530363534396134633030313865306263343139376165663239323761 65303263366530356138363162333833376631626631316435663631333438363833366234646131
66363737366662373862323938373764333261386639313433346539656664383063353336363031 33633236326533643139343766366131313834366663336361623563376465646338623537343934
37303962663834623062346166616461373737656435313365373863383761646466616535633163 35653665363665343264333237313663663431386530333230643832643537373464393836356365
32353562636333636661623461653264666439396230326437613361336434363238666238613866 31306465623539653361323233613136373464643334366466616561356336363161306566353033
39633365386639363263373439623431643964393934383831353733646264393838643430643134 39313735653062643361313538353464393566303435303839643061326631346434316537346561
31616338366663653036353633656230646232636532353431336535353137663832376261333665 35326536316233613035366532316132396531646561633834346530386337636532313233326433
31356565316636396137336536326631393439303330316635363464393434643333336334623261 31383364313331623433323364356566373433323034373531653736653866636539383639383163
66643938313739623135636365346566636435663662343932346238633431313730623661396237 61663362303435363539343331373931313065343662353964366566656537633664383239343430
63323833313732353762313237396633316137313335313536623530323465386231623265666463 62623865623665616638623937343361376133306564383461336136313035333731313837393762
38636461643233333733323030346231663833646339653037353065316363343031353066373665 34393936373865653761363139393530386135626538353031333736646235366165623535323338
37643764613933666630376563353662623461643866303335383237373532646331356361343036 33313964643732633032643037323064323461313930623836623438366261633033666439396535
38353964623237393133383063376135353538373035653831633333333133626165333538326264 31326131303738383237643566306561393635383831303433323963646133346534396663616338
64303464646261306536393264326636663035323638633034626139326331646161393334663365 33343139663162636134346637333330333637643063373330376134373533663864663338653264
64386561663564613636613163353732646362383931646366616333316463633830626539346137 31313530623933653934313530663139313439373662613130636135666630346261623933393833
32303831326435326235623836643031633863333562613438643465363339663561333239383835 30306166303938336439333833373434346139366162623736663662353931626639663139336462
66366561653733323638623736363265366434393835326263623962343832623230303337663637 39353336653163316136656238643031626563326238353064356430313533376536376635373262
31383361323434353232383363386339366232626264343137623432656234633532666436623837 62343534643661326435663031653634663130323035303063656163303436386131643435383733
64353239373736366330306331316331393065323633613235343666663932386563623764353661 30653061623439376262376338366166353836353535343939646134393335623630383866306539
32386361373537613838393536303031303630613637613961303863356231316364643230333139 30343862636131393863643831656338346334313930356164393135303166643265656138366266
30343362636265373265363937363965313939663039356335396161376530626433363362393231 64646636306339656662616239356666633330666237653166393663653565343533316337316237
39356139333633393433646166303762623330323763336464626537643837643134343639623331 36663930323730653236653235626437366338333263333135633634346434383530386331333166
32386230653535636663366433333765613230373761363236663665303665393635623631613530 38346563393934303830633932323564343166363936313035623034666436303831643265646433
30316561393063653732343137356638636263373530336266653239316431666464353861323633 36616362633230666132313937626136653530623865663037383239313939393739656663373166
37656664393035366337343262616532626262356263653433373739373865356139383033376536 37393233653533646664623865333865623862333263383635643238343538333137383461623565
36333564333039623663353162383531653066306332306436346366633030636632336637343566 65663036356666323137666536323435376565653234376530393166393939666663373361343530
66393966613239633638313161636266313936346630623132633966383535336630316462306265 36363466323162383930316637326333393937333232363537643065383231396236323936643364
64396537346538383364343336333734343863623663663238653134613737383966326436386438 64653061323033363135353436333036326636306639653139363064386366653562383539356131
32646266373533376434613262383432363633613031613632633233633863343834313864316365 31366432656634323730336665353132323862636339313036643234396339646165313764303734
33646661653534376437613061386532363035646561613533366134393739373565663334383134 34303737353466306537386633623261393537613735313032643033383434363233393061386538
64303539393838383466343437386139616361393139363231663737303730353663663334653037 35313834396533633735313237316433643835323465306431336331306638383165306234303366
61306165316135373633373630336466623036666134353436393834346638636165646165333065 64626239636439643538323734343966366535616437383632363631363731636463653133313266
34636333646331346334303565363338396463373831353265636231623130313539323437343732 62653036653761333166643432653563613264616362316463326538653536383763363237653238
37653030636464343962613764626264353166356161336239386631616339383063653138656530 37303564386266653239333636303034633538383232623630366232626638373664343336336462
34343133366537666238616132376331303933366135666231643662633139383134646533656438 38393038633737376562626131343131653236376432653230343833393366366566363334343263
66306232363562653231653865316561373264306333646663373062653535623136393038613231 39323062663565373132343131303530353464626133643731666239316363373437313335626232
34623562313666346131316462346131613761383733643161336266343639626132636536396334 39393931333033643265646535663935363831306163303966623164303264626161303434646538
37336263326532376336363030383864626534343937303134666564636230396462343862353431 37656232396336336635643335303165323361366562303137333831343832653763323937373061
34306165616662663535346330656130636131656663353232656361643137306562623765373061 35326263356263363636383861356339623966323564366430663632316664373536613462623838
38366266376530613636306664383865393631616462333830386535393734366239313834666338 33356462363038623633393337303161393536646665663339653234336137393139336132353235
30613162343361646433666366346637333837346664323230653037383366613431313361343366 37613138333065363731623535346138643833343663656162376139376638303231653537326131
66346535333436303335313337623362623231303363323566366166346636623263326465663536 31376334383564623331623733613365643161393165376563313235663930323037343161343866
30396538353934323465323165626132396563326333303066313862316634336661303433333637 37383336303138393064663265363939343535376433326433353165356564656132366565613839
32366562396666363333653461313866376332373065623063333335313538633861656639396638 65643365343038316139653665396334613131396238663162666365393339353539613634383935
39323636656432656630666531336661366134346635623464313031323037633137356633613330 64333535386661333363383437653864636434323832303932666364376333663633326434383064
36323065613562663865393033616661666136633063653163303665373535303135623565616539 31333464323331303939353732353763643562383538356363613739353135326630613334323037
32343166366536333261653138333433656635646462623035646537353035663861306463363638 35363335346464373064363231393435306534323431616365623765646362373738653738326232
61646664366465383134386365353537656266363065653937646530326639663739626539663837 30323563326466373766363935636535626632623138343537333038333964396262323831353038
31353537393438386239376631366661386439343132616536333463366665396161633337653630 34383665346430396361306437613339366437626266626231626239363362613065396137343962
33366133333363383034663832346561316136613438326130663733376664623831396135383336 36333463313130353165373365316563623330356638356233383333653661663462383161303136
30343335356532363534373539616334383034356336663962373636303866653031623366626535 36633038386563376335376236646537316563633739393235663930656131613030666133363430
32303762383738663764313434333864373739386436643333356264383333396636333831383833 62386263373338653161376636623061386234356632626636366232313639353138636332616630
30313235363932616633383462633335613534343361313837393236336539303333336663656435 61353435353239633630323163336133353462356263316130626638656238653264663361323135
34616437323931326436306430366436336338326339323037316230383262343032343765613461 37633038373433313863393965633661613231396536613835653361373837356232333833633236
36396231656363333566643232306639376466613636336265363333333066626263636530623365 32313439633632363465366337303739303137323839366261653836343332373230643264666535
39333935626161623263383739663461613262306437343366633862643935613633613236303334 35313865613632636437323462316639323531326432633334333432323266386435366366333838
37393765326665303636303563393634373933313838353063386533396234623837343333663861 38633830313731326638306634323565366265343130333339373032643832346166613737663430
63353536633061343066326437306233373736626539353637623266306434633932343138326562 62336434316264376361353830633562313065653433393363373734666134323263653537653830
66663530323438313664383132613139643562643966386664613964613865306231663566303762 62643838333065376234646166666332376462373536373234666163636562333865636134313766
35396137376435623636346531626434393737656437376533356564333130346464663232613333 39376237626137316366306366393038616631346161316263343237666430323966396331353065
30363834643863353139313664383061623061656537386536633236663165666665643435363132 30363437356262643034643330303534333331323464623662616534653932646130653466653332
30386666303461353534623963343732646564613939393365353763303563623265653631343539 62363030343833316133666164383037323936336232356266326261383964396364633233643737
36653934653661303533346339633665346165333162643334396134366162616266656139353033 32663635613939363265323936336263663465646666333263626139636162373462373064613362
36343864373264633735316236323733346131646330326564396132613661636165323462346639 65636266616665633033626337363136303066343232353537346635633431336266333535353131
39636238383832316132376534373034363136646335303835336539633431666330316330363432 66316266643335313033386432373962343332326238356430323435363238373334653036393839
36663835313166386235653639396333316236333162316266306566623765393135363033393434 65643337663034326232646464383934323661326432373061376662633765613861613466653937
34353839623362626163346531643030613632303263663331386462613038303033363461346133 30363065316561666235383332623364353938303734323537643064343333393864313339656161
65353966336635623234643136363439313461393831306334393036623733386232333766383033 36663264363363353430663930653965613761353630373034336166633864386234653831343636
35343936663731633665303866333632663066346535383737356262383736643237343637353039 32383934633666646235366534303130306436636665326338626566323362616664383162333837
32386166653565623638376531396639373432346163366130333765666534666637373639616534 39636539313737653933386261326262633963396364626430633631653735303934353436633532
36386532386133333264376637333666323438336561643636353936636531353934316335323537 63373866326666323565663435613530616130323035613438313634393264353266333838333131
33626239346261663833643037623232343839306231363365616634636262363238633034633331 33626236366633656365656534643538623431323635313133323630333365653132313932653863
37363831666630336232623762643862323762303765373062363336303463666562643436663164 32396139306437353839303933663432376434633462376335386264373934316339616663646130
66613466396235623732613337666430646461313136333437663635613661363937663234323936 34363130333061626433663636363032366237346136633966356139663462393836623562626532
66336535646430333536626632643334336234616166636166393762643536333566633163323633 35633930306639326639383632363561306164336535303139316162306431313236633036333964
63353366323339643563313939303734656561393137643434346339373439643363343239306235 33353063653561343764653235613330613331656338343337623431326564333761633731636531
37316563643138356433333934363965646635353764366338393563656237666435646566343838 38616539356239663861363237333733386635353532333039636631663639316434623633323634
36333831333837336532393934323264353661383530623932343132666461656639323764646134 64663139356536643864373836303335316464636163303464376264633630633738313064663630
32656361663037396430666333336538336235623337373065393664633739353630633431333833 30626363636265383064373764376538626235316138303831306262643932316332323733356535
34363461373234393534393431306565623862356634333161303964343761333735303162636364 39306564393236653530393864346261333434663232393765626265626166343864623931636133
66653964383362323034643566343930623731613433333965623466613161356530306233646364 37636532333762306339666466633939376432326230306437386337666630393530336163306335
32643039333233613964623463313462326331613936353162386435336337333462306331393836 62353037303265656534646262623836633030326636356566363362353161353764636639663538
62373333616438373261383634383262393738613933323935343132323565613565653363356339 65373831613431373665383565623361313235333036376239313039616433626539626432373363
30353062313538366161336366396264653437346138323830363637633837313864383539363862 37396232626663636165363863346533396132376531306139616664363261323339333239373639
66343235306330323261633261623236663163316139323463363635616636363237343431363862 30383136383232323837396665373235363433613864373437623834633164613266383165653966
61333662623166343263343737306539333734323137336238316131333938363233636136646635 35336337656361633435363531346134303435633262623465383938626639383332363736356630
62636333306330366561646165346136343932316566363533343038656437303839333362646133 32316637613233303531386562386636636361323163633732633732356630636165663266363532
63376431373930653437333731633634326636343238653133633261623663663166316530666434 66626433633938663262323464653335396662373634653466653964343065336139313539646536
38653131393163323136643061373065626534653963343935323534623561373932643262376637 39663134333530343537323764646566323635303361323032613633626536646236616465343365
32326230656632373334633563366161643033383638623537386562636537323832343330636230 39646336626632633839626461303633656633613939393139346639343935333966383564393263
38333163663436373136323162646333616234336133383965323939306137386132356330356137 32623639366134636563306463643230656337303666393634386432386332333137316534333361
66373864336433663239393563623061313137633963303438393665623132316232656336376234 34646631663137653139343131343236323930373036366263666162306230376664313331636238
32396266343237366532353766663332666165393263346261646363373263623938616161336333 31666465343633323931343063376530363838613934623537643338643236323237386163633131
61383563653239633433303835613835633464376434636434616365326234313638323430636535 32663263613265646662393339346463353166393630306665343534373238666235663538366637
61613061343665343865643735623132303066323035383533373962376439336563633262633631 34306664313462366462643732376365343464393833633035353863663933393566633065663531
38353466353265636233666632373464376165366364376231393438653364313737363765626566 64386231363931323363653665613732313438323630373430613634636437666565363734643730
35663439643832353334613462636432343166313334396638326237616235303463373133646635 61383265383938643336333666643434356166643831383765343736373436653430376561666338
38666166663630373962376539333237343164333632396165396462323534663234303639663839 36373033643665343431303464663838373766306466636534396634323232623434653736316531
66656639356639393665653833613635393239323666376533663534353036623739303837366638 32383365396330666333396364663561336663333965323866316131356331363633383437643230
39643332653337363039303366353231623263656230326466353236343136626661633539643964 33653337386534316537613830393337336630373436343130623535343938616133376138663236
65383762326661353330616139386535356266336265306135373335353836366434346636313230 39363133663735303037396631333062656566393461643337663161646361303739343161653731
36343336313463316661663834376361663935663633303236373734613864323563626436353262 38383636623238323261383038326433303532353039383035393666366265653334346630663839
64616635653864366433663135323763393630386234343064656631653730316434333435653463 65343336666462366432383764646137623336636662633731386534636463656364383465626531
66363566646461346363343363396266623764363934653561313962643662336662363439643736 30663631663932303161366332663964643662626631313732333764353763363764623234323030
30323737653532653665326363306163666439396265656537653766393239336633363033623039 36356330313261626134326363346133393638383834623830636165316631613432666261613032
63363164343334663066643863383731666233663164303661346362376231363233333564616162 31393231333837623266363130633331646531636136343163376166653261343464623133323635
62343163393961626638303131346130646564373866633561363836316531333230313638616334 61333636313962383961393831366262373837316531363166313964316538363634386430343739
62653939623663336665666163393166663434383234323531636563366636336631663065326163 64343363373165663934643833613064353264373539666139343734393663643735393736393065
61343238386236643937336530626131616266333463383866626435663834376537313365346133 37393262366564616561663532373137336262393439393031383439333336636535323834393138
66313364356634663831316332663634333632363263373332666232626461623933353534633764 63613734326238613762616366653533636130326631383631336433343635656230343266343365
33643063313065313637653732313066343661383637353565646637306337303830356630343338 35613033356130656632303339643236643330646339333037363830646431313032366363643336
66333836303331346233353434343964356162353432616333353930613431663365396166313563 36306332376436356639323738306638333866373532363632373861613863633133323862393036
66343133333061363636303736336536336562333638386131366633303335646432353239333562 61653637323635636163386139353038373039623937353533623336653565633730336330353936
34633235303934626661653839643664326564356565386436363863336331306530616261646132 61343431633635346339626536376464306663656262333531373438333363666237333832316238
30326266316530346534393566316630616366336262383434383635636139316261383161323437 38613336646263633932636233393734626338336433326232326164613436363232616334383230
61363231653037623836356438623039373331323930306465653739323061613334623763353334 31616333646361346638613065343065393736313735646234663333633534636331356663323463
30356532636133626639653638326330616138366234623637396332633132613436633463303634 35383261396563623363353432356431303539363666653261326363646464373865666531636131
38356166636231653938333832373838376262316337373130383936353335333034636236313434 61386634383337656539643338353066373932313134613266373366366630656236313664366164
61613930383065623630643034643539623439626335633733613832623930386362646235633537 64633163386431366232383835633235323130376462313832353736343262663737353739303934
35313036366465363461333461633965666664333262396163323537646634643066316532333935 62323436326631616134343831376561633965373561303039386266313935336164386636316139
39306538393362386164626334363936313666353633343431343362623738376437353830373432 37653238666631306161396537343462376638653263343134633234366236633966373438383639
61343866316338336538666635393631373665623834333231616266666133346531373138643866 35356532363534363133353264346661663437656162353739656265373130363931356239366339
33393433333865323730363730386366623032383534396261656134323132663536363331306464 38356538616137353737323132366130396335363262646635383763356534306465343463646632
63376232653762313539633137623039633862613236303465383838666133363931393034663834 64613431666435333330623035646463666532323131356538343034373936333762303931616463
66646265623162646466636264646265396331346634613138663861363761363334613239323536 35346231393338353065326430313032653966666362653438623333323466303431313135323733
63623138373766336664636266336239346331343063373564313265663166633430613366633630 39306466666532383933346164323931366337313937623730313162623261373666613363323233
62363961653431366431316536633735646563333732613163386236636138616239636333316534 63313161316266323561343363353038326439343661613439336132636361316563373766643164
62363465333865323564336366396463626339386135316130343131626261376631376433356465 31646536643439643536303864386530633439646135396336383332646266613461623837656431
34616133653739646236636538383366333163383832656632366230316635313535326435393961 30356266356664666436323764316363636330633636646630633536626266343262383731633436
35336330303066346165656561386663656333653234326162363262363163353131343764316562 62313964646436366130313730623332613530386538363533633263623866663735386531353861
66363835336339376336323430313764653632646131633330323230376233353365333661386434 61363361663739333637353436336435306162643638383134653338656665326531356264343137
39623235626666633862356438303734303965653837376266323932353861633362383630636438 61303262373531646634313239393361323835376233633964613937313966643132653264663265
34643763306461353038376563666334386334383038376161636436343233633331313738333463 30386334353335343237353734303132643534316130376437386663363937366432303866346231
31653031646266633462663136353039626432613462343763623733663135383366616565633037 32616639383462363333373237343663383332343364373465646633383034383034376534396162
63303266356566636163303230613734656132643963613063366135313834313436343563643333 61643739323330353130333361396335363634373131653835363036333563613239383966343663
65346663333238353466656336626535666534363733616330386538356436396237393937396166 35306362353435663762656461666539393139616264306666393066333764663039623764323937
65376663363864393262353332343064626261363030323333323533376564313436376533666533 63643539386161353237343230333832326137666539613539346366353239623265626562386562
65643062396461383635393865363032366234336339633464386166386331376630306632313434 39623361376430633461336562613232393631623733326164643637343632633538666437626235
33343933363032633332646638393132613666633166356638313431323330313266306664316464 34393638363839653136646562336131376335303661373339326661353935623164316232653034
37363632333737616630373961306237323332613733623864396665366537353533316665376531 37306134323561616631313838336364343463303631343765393130623163346365636664333061
36363334613462306535653738663762333265616630626638396132386434313432353538396236 33356133626535383765636463326138646266363061326430393934343330356136323664326630
6637 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
@@ -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 }}"
-1
View File
@@ -29,7 +29,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
@@ -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"