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