Compare commits
146 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
c39de421e0
|
|||
|
a50b399a85
|
|||
|
94b09be53c
|
|||
|
b637fea882
|
|||
|
933a0b9570
|
|||
|
96710360d9
|
|||
|
d9f0d94e1f
|
|||
|
9b853d351c
|
|||
|
11744f776a
|
|||
|
0df5f358d0
|
|||
|
62e2a72e52
|
|||
|
7c91f4f355
|
|||
|
68d8bf6a68
|
|||
|
e585bfdca2
|
|||
|
41822e04e8
|
|||
|
21ccc7ac8c
|
|||
|
81478c2323
|
|||
|
313b1820be
|
|||
|
2f2c1b0754
|
|||
|
e45e1db002
|
|||
|
dc49b3497b
|
|||
|
02ea9a3735
|
|||
|
a3e53b21e6
|
|||
|
1b120e3ae6
|
|||
|
a22be7c7d1
|
|||
|
7d711425fd
|
|||
|
e03a4c417d
|
|||
|
10e1e8187b
|
|||
|
1ce168655d
|
|||
|
fe024b3b12
|
|||
|
8378f0edb0
|
|||
|
48737c1b6d
|
|||
|
600a30ec11
|
|||
|
670947fcdf
|
|||
|
3545905cbd
|
|||
|
893996f0c9
|
|||
|
4a5db6e2bc
|
|||
|
ca6875eaad
|
|||
|
dbf0aa255a
|
|||
|
bc6fff68bb
|
|||
|
512f31b350
|
|||
|
d25a28c611
|
|||
|
472c7a984f
|
|||
|
df3a37e610
|
|||
|
8efab2002f
|
|||
|
6edb72077a
|
|||
|
07eacad003
|
|||
|
3b1736534d
|
|||
|
4d92b3bd3e
|
|||
|
27834c6711
|
|||
|
89f46566c8
|
|||
|
7f1809b4ca
|
|||
|
6784381833
|
|||
|
7c42acf893
|
|||
|
452f7973a9
|
|||
|
303aefb75f
|
|||
|
22307d81c9
|
|||
|
cc811f954d
|
|||
|
f17c4ac227
|
|||
|
25d20df5a9
|
|||
|
7e1a8e2e99
|
|||
|
b90b87caa1
|
|||
|
75ce60d8a0
|
|||
|
0aa34efd00
|
|||
|
b7a18f1296
|
|||
|
6bfb362b20
|
|||
|
5f619eaccc
|
|||
|
362d6d8710
|
|||
|
5e6df110c8
|
|||
|
a0543e13f4
|
|||
|
41fe116dd7
|
|||
|
e34f8505a2
|
|||
|
53f43264cc
|
|||
|
c1f5eaeca0
|
|||
|
fb9c754461
|
|||
|
b7495e1d6b
|
|||
|
8ab5e89851
|
|||
|
7f0a2a8eb6
|
|||
|
c686b292dc
|
|||
|
fb87cf77b2
|
|||
|
4e467d0f9b
|
|||
|
b44ec03b7b
|
|||
|
ebfcc1d3ab
|
|||
|
9183bbc58b
|
|||
|
6005cbaae8
|
|||
|
b839b2787f
|
|||
|
c714971595
|
|||
|
7c02f5f22a
|
|||
|
c9440c8d50
|
|||
|
b5ebfeee39
|
|||
|
96f5e11bbc
|
|||
|
c16131e773
|
|||
|
6350a1112d
|
|||
|
f3b888853e
|
|||
|
2709547958
|
|||
|
988730c798
|
|||
|
ae3f925777
|
|||
|
b13cc65a14
|
|||
|
b793b7806b
|
|||
|
d6be9fbfb8
|
|||
|
527ca62cb2
|
|||
|
c492e6f697
|
|||
|
d46b44bc70
|
|||
|
e0ffb8636d
|
|||
|
5ccee8e0a1
|
|||
|
a66b5fdc3d
|
|||
|
dad43879b2
|
|||
|
3880aefdfd
|
|||
|
254edf1e45
|
|||
|
e427422253
|
|||
|
516497c8fd
|
|||
|
670d830e03
|
|||
|
b47826feb4
|
|||
|
5ce2f1fbd4
|
|||
|
e39981eee2
|
|||
|
83bfba2180
|
|||
|
b42dd429fd
|
|||
|
a056e8662d
|
|||
|
4a693470fc
|
|||
|
ab9ac67b2e
|
|||
|
8728eb0203
|
|||
|
926f4ea135
|
|||
|
7fb65caf66
|
|||
|
d5d8bb71d8
|
|||
|
07443e4b2e
|
|||
|
396c2048ae
|
|||
|
62c47cc5d7
|
|||
|
a44e3d6766
|
|||
|
2e56cc97d9
|
|||
|
1a98aa504c
|
|||
|
47d464109b
|
|||
|
6358e3795c
|
|||
|
d0ae59ce36
|
|||
|
d169d7996f
|
|||
|
f80a1008c7
|
|||
|
af5b00d62d
|
|||
|
19ae4632ea
|
|||
|
188756501a
|
|||
|
4cf3b52f2e
|
|||
|
c36752a16f
|
|||
|
a2f7e90f89
|
|||
|
2ea3b1b166
|
|||
|
e5c1e19e5e
|
|||
|
8439ab3693
|
|||
|
dcb09e349b
|
|||
|
a6781b4f64
|
@@ -1,6 +1,9 @@
|
||||
---
|
||||
exclude_paths:
|
||||
- ".ansible/"
|
||||
- ".crush/"
|
||||
- ".gitea/"
|
||||
- ".venv/"
|
||||
- ".vscode/"
|
||||
- "galaxy.roles/"
|
||||
- "Taskfile.yml"
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
name: adr
|
||||
description: >-
|
||||
Создаёт и сопровождает Architecture Decision Records (ADR) в docs/adr/.
|
||||
Используй, когда уже сделанное архитектурное или инфраструктурное
|
||||
изменение нужно зафиксировать постфактум: выбор инструмента/подхода,
|
||||
структурное решение, намеренный отказ от очевидного варианта, либо
|
||||
прямая просьба «записать решение / завести ADR». А также когда старую
|
||||
ADR нужно пометить заменённой или устаревшей. ADR пишут ПОСТФАКТУМ;
|
||||
идеи, планы и обсуждения — это drafts, а не ADR.
|
||||
---
|
||||
|
||||
# Работа с ADR
|
||||
|
||||
ADR живут в `docs/adr/`. Формат и соглашения — `docs/adr/README.md`,
|
||||
шаблон — `docs/adr/template.md`. Читай README перед первой записью в
|
||||
сессии: правила там — источник истины, эта инструкция лишь даёт порядок
|
||||
действий.
|
||||
|
||||
Язык записей — **русский**, стиль — как в `docs/drafts/`: конкретно,
|
||||
по делу, без воды. Калибровка под личный хобби-сервер: не раздувай
|
||||
запись, не предлагай корпоративные процессы.
|
||||
|
||||
## Сначала реши, нужен ли ADR
|
||||
|
||||
Заводи, если изменение **уже сделано** и это: выбор технологии/
|
||||
инструмента, структурное решение, решение с долгосрочными последствиями
|
||||
или дорогим откатом, либо намеренный отказ от очевидного варианта.
|
||||
|
||||
Не заводи:
|
||||
- для рутины (бамп версии образа, сервис по накатанной схеме) и того,
|
||||
что видно из кода/git;
|
||||
- для идей, планов и того, что ещё не реализовано — это `docs/drafts/`,
|
||||
а не ADR.
|
||||
|
||||
Если сомневаешься — спроси пользователя, не плоди записи молча.
|
||||
|
||||
## Создание новой ADR
|
||||
|
||||
1. **Дата.** Когда изменение реально сделано. Обычно сегодня
|
||||
(`date +%F`). Если оформляем задним числом — уточни дату у
|
||||
пользователя, не подставляй сегодняшнюю вслепую.
|
||||
2. **Идентификатор и имя файла:** `ADR-ГГГГ-ММ-ДД-kebab-slug.md`
|
||||
(slug — латиницей). Если за эту дату уже есть ADR — slug просто
|
||||
должен отличаться; проверь `ls docs/adr/`.
|
||||
3. **Файл.** Возьми за основу `docs/adr/template.md`.
|
||||
4. **Заполни** по шаблону:
|
||||
- Заголовок `# Человеческий заголовок` (без даты/ID в тексте).
|
||||
- Метаданные: только `- Дата: ГГГГ-ММ-ДД`. **Строку статуса не
|
||||
добавляй** — у активной записи статуса нет.
|
||||
- **Контекст** — какая проблема и ограничения вынудили это делать.
|
||||
- **Рассмотренные варианты** — *опциональная* секция. **Спроси
|
||||
пользователя, рассматривал ли он какие-то альтернативы.** Если да —
|
||||
перечисли их с плюсами/минусами (особенно те, что отвергнуты); если
|
||||
нет (решение было единственным очевидным) — удали секцию целиком.
|
||||
- **Решение** — что сделано и, **главное, почему**: какое намерение и
|
||||
причина за этим стоят.
|
||||
- **Последствия** — `+`/`-` и что нужно сделать как следствие.
|
||||
5. **Индекс.** Добавь строку в таблицу «Список записей» в
|
||||
`docs/adr/README.md`, **сверху** (новые сверху). Статус — `—`.
|
||||
|
||||
Самое важное в ADR — сохранить **почему**: намерение и причинность, а не
|
||||
просто «что сделали». Не придумывай причины и альтернативы за
|
||||
пользователя — если их нет в контексте сессии, обязательно спроси: что
|
||||
подтолкнуло к изменению и какие варианты рассматривались.
|
||||
|
||||
## Замена / устаревание решения
|
||||
|
||||
Старые ADR неизменяемы. Если решение пересмотрено:
|
||||
|
||||
1. Заведи новую ADR (шаги выше). В её «Контексте» — строка
|
||||
«Заменяет ADR-ГГГГ-ММ-ДД-slug».
|
||||
2. В старой ADR добавь строку статуса сразу под датой:
|
||||
`- Статус: заменено на ADR-ГГГГ-ММ-ДД-slug` (или `- Статус: устарело`,
|
||||
если замены нет). Тело не трогай.
|
||||
3. Обнови статус старой записи в индексе `README.md`.
|
||||
|
||||
## После записи
|
||||
|
||||
Покажи пользователю путь к файлу и кратко содержание. Не коммить без
|
||||
явной просьбы.
|
||||
@@ -47,4 +47,4 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Run ansible-lint
|
||||
run: ansible-lint .
|
||||
run: ansible-lint -vv
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/.ansible
|
||||
/.idea
|
||||
/.vagrant
|
||||
/.venv
|
||||
/.vscode
|
||||
|
||||
/galaxy.roles/
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
3.12
|
||||
@@ -2,6 +2,9 @@ extends: default
|
||||
|
||||
ignore:
|
||||
- ".ansible/"
|
||||
- ".crush/"
|
||||
- ".venv/"
|
||||
- ".vscode/"
|
||||
- "galaxy.roles/"
|
||||
|
||||
rules:
|
||||
|
||||
@@ -1,69 +1,137 @@
|
||||
# AGENTS GUIDE
|
||||
|
||||
## Overview
|
||||
Ansible-based server automation for personal services. Playbooks provision Dockerized apps (e.g., gitea, authelia, homepage, miniflux, wakapi, memos) via per-app users, Caddy proxy, and Yandex Docker Registry. Secrets are managed with Ansible Vault.
|
||||
## Обзор
|
||||
|
||||
## Project Layout
|
||||
- Playbooks: `playbook-*.yml` (per service), `playbook-all-*.yml` for grouped actions.
|
||||
- Inventory: `production.yml` (ungrouped host `server`).
|
||||
- Variables: `vars/*.yml` (app configs, images), secrets in `vars/secrets.yml` (vault-encrypted).
|
||||
- Roles: custom roles under `roles/` (e.g., `eget`, `owner`, `secrets`) plus galaxy roles fetched to `galaxy.roles/`.
|
||||
- Files/templates: service docker-compose and backup templates under `files/`, shared templates under `templates/`.
|
||||
- Scripts: helper Python scripts in `scripts/` (SMTP utilities) and `files/backups/backup-all.py`.
|
||||
- CI: `.gitea/workflows/lint.yml` runs yamllint and ansible-lint.
|
||||
- Hooks: `lefthook.yml` references local hooks in `/home/av/projects/private/git-hooks` (gitleaks, vault check).
|
||||
- Formatting: `.editorconfig` enforces LF, trailing newline, 4-space indent; YAML/Jinja use 2-space indent.
|
||||
Ansible-проект для автоматизации личного сервера. Плейбуки разворачивают докеризированные приложения (gitea, authelia, miniflux, wakapi, memos, outline, gramps, calibre, wanderer, remembos, transcriber и др.) через выделенных системных пользователей, Caddy-прокси и Yandex Docker Registry. Секреты управляются через Ansible Vault.
|
||||
|
||||
## Setup
|
||||
- Copy vault password sample: `cp ansible-vault-password-file.dist ansible-vault-password-file` (needed for ansible and CI).
|
||||
- Install galaxy roles: `ansible-galaxy role install --role-file requirements.yml --force` (or `task install-roles`).
|
||||
- Ensure `yq`, `task`, `ansible` installed per README requirements.
|
||||
## Структура проекта
|
||||
|
||||
## Tasks (taskfile)
|
||||
- `task install-roles` — install galaxy roles into `galaxy.roles/`.
|
||||
- `task ssh` — SSH to target using inventory (`production.yml`).
|
||||
- `task btop` — run `btop` on remote.
|
||||
- `task encrypt|decrypt -- <files>` — ansible-vault helpers.
|
||||
- Authelia helpers:
|
||||
- `task authelia-cli -- <args>` — run authelia CLI in docker.
|
||||
- `task authelia-validate-config` — render `files/authelia/configuration.template.yml` with secrets and validate via authelia docker image.
|
||||
- `task authelia-gen-random-string LEN=64` — generate random string.
|
||||
- `task authelia-gen-secret-and-hash LEN=72` — generate hashed secret.
|
||||
- `task format-py-files` — run Black via docker (pyfound/black).
|
||||
- `playbook-*.yml` — плейбуки по одному на сервис, `playbook-all-*.yml` для групповых запусков.
|
||||
- `production.yml` — инвентарь с единственным хостом `server`.
|
||||
- `vars/*.yml` — переменные приложений и образов, `vars/secrets.yml` — зашифрованные секреты (vault).
|
||||
- `roles/` — кастомные роли (`eget`, `owner`, `secrets`), галактические роли в `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).
|
||||
- `tasks.py` — задачи через invoke (`inv <task>`).
|
||||
- `pyproject.toml` — зависимости Python, управляются через `uv`.
|
||||
|
||||
## Ansible Usage
|
||||
- Inventory: `production.yml` with `server` host. `ansible.cfg` points to `./ansible-vault-password-file` and `./galaxy.roles` for roles path.
|
||||
- Typical deploy example (from README): `ansible-playbook -i production.yml --diff playbook-gitea.yml`.
|
||||
- Per-app playbooks: `playbook-<app>.yml`; grouped runs: `playbook-all-setup.yml`, `playbook-all-applications.yml`, `playbook-upgrade.yml`, etc.
|
||||
- Secrets: encrypted `vars/secrets.yml`; additional `files/<app>/secrets.yml` used for templating (e.g., Authelia). Respect `.crushignore` ignoring vault files.
|
||||
- Templates: many `docker-compose.template.yml` and `*.template.sh` files under `files/*` plus shared `templates/env.j2`. Use `vars/*.yml` to supply values.
|
||||
- Custom roles:
|
||||
- `roles/eget`: installs `eget` tool; see defaults/vars for version/source.
|
||||
- `roles/owner`: manages user/group and env template.
|
||||
- `roles/secrets`: manages vault-related items.
|
||||
## Настройка окружения
|
||||
|
||||
## Linting & CI
|
||||
- Local lint configs: `.yamllint.yml`, `.ansible-lint.yml` (excludes `.ansible/`, `.gitea/`, `galaxy.roles/`, `Taskfile.yml`).
|
||||
- CI (.gitea/workflows/lint.yml) installs `yamllint` and `ansible-lint` and runs `yamllint .` then `ansible-lint .`; creates dummy vault file if missing.
|
||||
- Pre-commit via lefthook (local hooks path): runs `gitleaks git --staged` and secret-file vault check script.
|
||||
```bash
|
||||
uv sync
|
||||
cp ansible-vault-password-file.dist ansible-vault-password-file
|
||||
uv run ansible-galaxy install --role-file requirements.yml
|
||||
```
|
||||
|
||||
## Coding/Templating Conventions
|
||||
- Indentation: 2 spaces for YAML/Jinja (`.editorconfig`), 4 spaces default elsewhere.
|
||||
- End-of-line: LF; ensure final newline.
|
||||
- Template suffixes `.template.yml`, `.yml.j2`, `.template.sh` are rendered via Ansible `template` module.
|
||||
- Avoid committing real secrets; `.crushignore` excludes `ansible-vault-password-file` and `*secrets.yml`.
|
||||
- Service directories under `files/` hold docker-compose and backup templates; ensure per-app users and registry settings align with `vars/*.yml`.
|
||||
Требуется: `uv`, `ansible`, `yq`.
|
||||
|
||||
## Testing/Validation
|
||||
- YAML lint: `yamllint .` (CI default).
|
||||
- Ansible lint: `ansible-lint .` (CI default).
|
||||
- Authelia config validation: `task authelia-validate-config` (renders with secrets then validates via docker).
|
||||
- Black formatting for Python helpers: `task format-py-files`.
|
||||
- Python types validation with mypy: `mypy <file.py>`.
|
||||
## Задачи (invoke)
|
||||
|
||||
## Operational Notes
|
||||
- Deployments rely on `production.yml` inventory and per-app playbooks; run with `--diff` for visibility.
|
||||
- Yandex Docker Registry auth helper: `files/yandex-docker-registry-auth.sh`.
|
||||
- Backups: templates and scripts under `files/backups/` per service; `backup-all.py` orchestrates.
|
||||
- Home network/DNS reference in README (Yandex domains).
|
||||
- Ensure `ansible-vault-password-file` present for vault operations and CI.
|
||||
Таск-раннер — `invoke` (файл `tasks.py`), вызывается через `inv`:
|
||||
|
||||
- `inv pl -- <app> [app2 ...]` — запуск плейбука (`ansible-playbook -i production.yml --diff`).
|
||||
- `inv install-roles` — установка галактических ролей.
|
||||
- `inv ssh` — SSH на сервер.
|
||||
- `inv zj` — zellij на удалённом сервере.
|
||||
- `inv btop` — btop на удалённом сервере.
|
||||
- `inv encrypt -- <file>` / `inv decrypt -- <file>` — шифрование/дешифрование через ansible-vault.
|
||||
- `inv authelia-cli -- <args>` — запуск Authelia CLI в docker.
|
||||
- `inv authelia-validate-config` — рендер и валидация конфига Authelia через docker.
|
||||
- `inv authelia-gen-random-string LEN=10` — генерация случайной строки.
|
||||
- `inv authelia-gen-secret-and-hash LEN=72` — генерация секрета и его хэша (pbkdf2-sha512).
|
||||
- `inv format-py-files` — форматирование Python-файлов через Black (docker).
|
||||
|
||||
## Плейбуки
|
||||
|
||||
### Системные
|
||||
|
||||
- `playbook-system.yml` — базовая настройка системы (apt-пакеты, безопасность, fail2ban, монтирование хранилища).
|
||||
- `playbook-docker.yml` — установка Docker CE, создание сетей (web_proxy_network, monitoring_network), cron очистки образов.
|
||||
- `playbook-eget.yml` — установка eget и инструментов через него (rclone, restic, resticprofile, btop, gobackup, task, dust, zellij).
|
||||
- `playbook-ufw.yml` — настройка файрвола UFW (SSH/22, Gitea SSH/2222, HTTP/80, HTTPS/443).
|
||||
- `playbook-upgrade.yml` — обновление системных пакетов, очистка Docker.
|
||||
- `playbook-backups.yml` — настройка restic-бэкапов и оркестратора backup-all.py с cron-расписанием.
|
||||
- `playbook-caddyproxy.yml` — Caddy reverse proxy.
|
||||
|
||||
### Приложения
|
||||
|
||||
- `playbook-gitea.yml` — Git-сервер.
|
||||
- `playbook-authelia.yml` — аутентификация/SSO.
|
||||
- `playbook-miniflux.yml` — RSS-ридер.
|
||||
- `playbook-wakapi.yml` — трекинг времени.
|
||||
- `playbook-memos.yml` — заметки.
|
||||
- `playbook-outline.yml` — вики/база знаний.
|
||||
- `playbook-homepage.yml` — дашборд (образ из Yandex Registry).
|
||||
- `playbook-rssbridge.yml` — RSS-агрегатор.
|
||||
- `playbook-netdata.yml` — мониторинг.
|
||||
- `playbook-dozzle.yml` — просмотр Docker-логов.
|
||||
- `playbook-goaccess.yml` — аналитика веб-логов Caddy в реальном времени.
|
||||
- `playbook-gramps.yml` — генеалогия.
|
||||
- `playbook-calibre.yml` — управление электронными книгами.
|
||||
- `playbook-transcriber.yml` — транскрибация (образ из Yandex Registry).
|
||||
- `playbook-wanderer.yml` — пешие маршруты.
|
||||
- `playbook-remembos.yml` — интервальное повторение.
|
||||
- `playbook-tuwunel.yml` — Matrix-сервер (Tuwunel) с federation-делегацией на apex-домен.
|
||||
|
||||
### Агрегатные и служебные
|
||||
|
||||
- `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>`).
|
||||
|
||||
## Роли
|
||||
|
||||
- `roles/owner` — создаёт системного пользователя/группу для приложения, настраивает SSH-ключи, переменные окружения (~/.env, ~/.bashrc).
|
||||
- `roles/eget` — скачивает и устанавливает утилиту eget.
|
||||
- `roles/secrets` — управляет vault-зашифрованными файлами секретов для приложений.
|
||||
|
||||
Галактические роли (`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` и др.
|
||||
- Каждое приложение: `app_name`, `app_user`, `app_owner_uid`, `app_owner_gid`, `base_dir`, `data_dir`.
|
||||
|
||||
## Линтинг и CI
|
||||
|
||||
- CI (`.gitea/workflows/lint.yml`): два параллельных job — yamllint и ansible-lint.
|
||||
- Конфиги: `.yamllint.yml` (макс. длина строки 120), `.ansible-lint.yml` (профиль production, offline).
|
||||
- Pre-commit хуки через lefthook:
|
||||
- `ruff format` + `ruff check` — форматирование и линтинг Python.
|
||||
- `mypy` — проверка типов Python.
|
||||
- `yamllint` — линтинг YAML.
|
||||
- `ansible-lint` — линтинг Ansible (профиль production).
|
||||
- `gitleaks` — поиск секретов в staged-файлах.
|
||||
- Проверка что секретные файлы зашифрованы vault.
|
||||
|
||||
## Соглашения по коду
|
||||
|
||||
- Отступы: 2 пробела для YAML/Jinja, 4 пробела в остальных файлах (`.editorconfig`).
|
||||
- Окончания строк: LF, завершающий перевод строки обязателен.
|
||||
- Не коммитить незашифрованные секреты; `.crushignore` исключает `ansible-vault-password-file` и `*secrets.yml`.
|
||||
- Директории в `files/<app>/` содержат docker-compose и шаблоны бэкапов; пользователи и настройки реестра должны соответствовать `vars/*.yml`.
|
||||
|
||||
## Деплой
|
||||
|
||||
```bash
|
||||
# Один сервис
|
||||
inv pl -- gitea
|
||||
|
||||
# Несколько сервисов
|
||||
inv pl -- gitea miniflux wakapi
|
||||
|
||||
# Напрямую через ansible-playbook
|
||||
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`.
|
||||
|
||||
@@ -5,26 +5,33 @@
|
||||
> В этом проекте не самые оптимальные решения.
|
||||
> Но они помогают мне поддерживать сервер для моих личных проектов уже много лет.
|
||||
|
||||
История и обоснования значимых решений — в [ADR-записях](docs/adr/)
|
||||
(`docs/adr/`): *почему* приняты те или иные изменения, а не только что сделано.
|
||||
|
||||
## Требования
|
||||
|
||||
- [uv](https://docs.astral.sh/uv/)
|
||||
- [ansible](https://docs.ansible.com/ansible/latest/getting_started/index.html)
|
||||
- [task](https://taskfile.dev/)
|
||||
- [yq](https://github.com/mikefarah/yq)
|
||||
|
||||
## Установка
|
||||
|
||||
```bash
|
||||
$ cp ansible-vault-password-file.dist ansible-vault-password-file
|
||||
$ ansible-galaxy install --role-file requirements.yml
|
||||
uv sync
|
||||
|
||||
cp ansible-vault-password-file.dist ansible-vault-password-file
|
||||
|
||||
uv run ansible-galaxy install --role-file requirements.yml
|
||||
```
|
||||
|
||||
## Структура
|
||||
|
||||
- Для каждого приложения создается свой пользователь (опционально).
|
||||
- Для каждого приложения создается свой пользователь.
|
||||
- Для доступа используется ssh-ключ.
|
||||
- Безопасность осуществляется с помощью `ufw` и `fail2ban`.
|
||||
- Докер используется для запуска и изоляции приложений. Для загрузки образов настраивается Yandex Docker Registry.
|
||||
- Выход во внешнюю сеть через proxy server [Caddy](https://caddyserver.com/).
|
||||
- Чувствительные данные в `vars/vars.yaml` зашифрованы с помощью Ansible Vault.
|
||||
- Чувствительные данные в [secrets.yml](vars/secrets.yml) зашифрованы с помощью Ansible Vault.
|
||||
- Для мониторинга за сервером устанавливается [netdata](https://github.com/netdata/netdata).
|
||||
|
||||
## Настройка DNS
|
||||
@@ -33,8 +40,20 @@ $ ansible-galaxy install --role-file requirements.yml
|
||||
|
||||
## Деплой приложений
|
||||
|
||||
Деплой всех приложений через ansible:
|
||||
Деплой приложения через ansible:
|
||||
|
||||
```bash
|
||||
ansible-playbook -i production.yml --diff playbook-gitea.yml
|
||||
uv run ansible-playbook ansible-playbook -i timeweb.yml --diff playbook-gitea.yml
|
||||
```
|
||||
|
||||
Или через таску invoke:
|
||||
|
||||
```bash
|
||||
./inv pl -- gitea
|
||||
```
|
||||
|
||||
## Удаление приложения <name>
|
||||
|
||||
```bash
|
||||
uv run ansible-playbook -i timeweb.yml --diff playbook-remove-user-and-app.yml --extra-vars user_name=<name>
|
||||
```
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
# https://taskfile.dev
|
||||
|
||||
version: '3'
|
||||
|
||||
vars:
|
||||
USER_ID:
|
||||
sh: 'id -u'
|
||||
GROUP_ID:
|
||||
sh: 'id -g'
|
||||
HOSTS_FILE: 'production.yml'
|
||||
REMOTE_USER:
|
||||
sh: 'yq .ungrouped.hosts.server.ansible_user {{.HOSTS_FILE}}'
|
||||
REMOTE_HOST:
|
||||
sh: 'yq .ungrouped.hosts.server.ansible_host {{.HOSTS_FILE}}'
|
||||
AUTHELIA_DOCKER: 'docker run --rm -v $PWD:/data authelia/authelia:4.39.4 authelia'
|
||||
|
||||
tasks:
|
||||
install-roles:
|
||||
cmds:
|
||||
- ansible-galaxy role install --role-file requirements.yml --force
|
||||
|
||||
ssh:
|
||||
cmds:
|
||||
- ssh {{.REMOTE_USER}}@{{.REMOTE_HOST}}
|
||||
|
||||
btop:
|
||||
cmds:
|
||||
- ssh {{.REMOTE_USER}}@{{.REMOTE_HOST}} -t btop
|
||||
|
||||
encrypt:
|
||||
cmds:
|
||||
- ansible-vault encrypt {{.CLI_ARGS}}
|
||||
|
||||
decrypt:
|
||||
cmds:
|
||||
- ansible-vault decrypt {{.CLI_ARGS}}
|
||||
|
||||
authelia-cli:
|
||||
cmds:
|
||||
- "{{.AUTHELIA_DOCKER}} {{.CLI_ARGS}}"
|
||||
|
||||
authelia-validate-config:
|
||||
vars:
|
||||
DEST_FILE: "temp/configuration.yml"
|
||||
cmds:
|
||||
- >
|
||||
ansible localhost
|
||||
--module-name template
|
||||
--args "src=files/authelia/configuration.template.yml dest={{.DEST_FILE}}"
|
||||
--extra-vars "@vars/secrets.yml"
|
||||
--extra-vars "@files/authelia/secrets.yml"
|
||||
- defer: rm -f {{.DEST_FILE}}
|
||||
- >
|
||||
{{.AUTHELIA_DOCKER}}
|
||||
validate-config --config /data/{{.DEST_FILE}}
|
||||
|
||||
authelia-gen-random-string:
|
||||
summary: |
|
||||
Generate random string.
|
||||
Usage example:
|
||||
task authelia-gen-random-string LEN=64
|
||||
vars:
|
||||
LEN: '{{ .LEN | default 10 }}'
|
||||
cmds:
|
||||
- >
|
||||
{{.AUTHELIA_DOCKER}}
|
||||
crypto rand --length {{.LEN}} --charset alphanumeric
|
||||
|
||||
authelia-gen-secret-and-hash:
|
||||
vars:
|
||||
LEN: '{{ .LEN | default 72 }}'
|
||||
cmds:
|
||||
- >
|
||||
{{.AUTHELIA_DOCKER}}
|
||||
crypto hash generate pbkdf2 --variant sha512 --random --random.length {{.LEN}} --random.charset rfc3986
|
||||
|
||||
format-py-files:
|
||||
cmds:
|
||||
- >-
|
||||
docker run --rm -u {{.USER_ID}}:{{.GROUP_ID}} -v $PWD:/app -w /app pyfound/black:latest_release black .
|
||||
@@ -0,0 +1,54 @@
|
||||
# Вести историю решений в виде ADR
|
||||
|
||||
- Дата: 0000-00-00
|
||||
|
||||
> Основополагающая запись о самом процессе ADR. Дата-сентинел
|
||||
> `0000-00-00` (фактически создана 2026-05-24) — исключение: так запись
|
||||
> всегда остаётся в самом низу списка и не путается с реальными
|
||||
> изменениями.
|
||||
|
||||
## Контекст
|
||||
|
||||
Сервер развивается итеративно: меняются прокси, схема бэкапов, набор
|
||||
сервисов, провайдер хостинга. Решения принимаются по одному, часто с
|
||||
неочевидными компромиссами под ресурсы сервера и стоимость. Через
|
||||
несколько месяцев мотивация забывается, и возникает риск «переоткрыть»
|
||||
уже отвергнутый вариант или сломать то, что было сделано осознанно.
|
||||
|
||||
Журналы в `docs/drafts/` фиксируют хронологию и черновики, но не
|
||||
обоснование выбора — по ним не видно, какие альтернативы отвергнуты и
|
||||
почему.
|
||||
|
||||
## Рассмотренные варианты
|
||||
|
||||
- **ADR (отдельный файл на решение)** — стандартный формат, каждая
|
||||
запись неизменяема, видно эволюцию через накопление и замену записей.
|
||||
- **Один changelog-файл** — проще вести, но правки затирают историю
|
||||
рассуждений, и формат расплывается со временем.
|
||||
- **Ничего, держать в голове / в git-сообщениях** — нулевые затраты,
|
||||
но обоснование теряется, а git-история не отвечает на вопрос «почему».
|
||||
|
||||
## Решение
|
||||
|
||||
Заводим каталог `docs/adr/` с записями в формате ADR (Nygard + блок
|
||||
«Рассмотренные варианты»). Идентификатор записи — датовый,
|
||||
`ADR-ГГГГ-ММ-ДД-slug`: дата (когда изменение реально сделано) сразу
|
||||
видна в списке и позволяет оформлять записи задним числом. ADR пишем
|
||||
постфактум, поэтому жизненный цикл сведён к двум статусам у потерявших
|
||||
силу записей — `заменено на` и `устарело`; идеи и планы остаются в
|
||||
`docs/drafts/`.
|
||||
|
||||
Формат и процесс описаны в [`README.md`](README.md), шаблон — в
|
||||
[`template.md`](template.md). Для единообразия заполнение
|
||||
автоматизировано скиллом `adr` (`.claude/skills/adr/`).
|
||||
|
||||
## Последствия
|
||||
|
||||
- `+` Сохраняется обоснование решений и отвергнутые альтернативы.
|
||||
- `+` Датовый ID даёт хронологию «из коробки» и не мешает оформлять
|
||||
записи задним числом.
|
||||
- `+` Единый формат: записи делает человек или агент по одному шаблону.
|
||||
- `-` Небольшая дисциплина: сделав значимое изменение, нужно не забыть
|
||||
оформить ADR.
|
||||
- Скилл `adr` берёт на себя имя файла, шаблон и обновление индекса в
|
||||
`README.md`, снижая трение.
|
||||
@@ -0,0 +1,50 @@
|
||||
# Authelia вместо Keycloak для SSO
|
||||
|
||||
- Дата: 2025-05-07
|
||||
|
||||
## Контекст
|
||||
|
||||
Для SSO/OIDC на сервере стоял Keycloak (заведён годом ранее,
|
||||
2024-05-25). Проблема — ресурсы: Keycloak съедал больше 500 МБ RAM, что
|
||||
тяжело для личного сервера с ограниченной оперативной памятью. При этом вся его мощь
|
||||
избыточна: пользователей меньше десяти, realms / federation / тяжёлый
|
||||
корпоративный стек не нужны. Изначально взял Keycloak, потому что нужен был
|
||||
OIDC-сервер для настройки Outline; на тот момент было понятное
|
||||
руководство по связке OIDC и Keycloak.
|
||||
|
||||
Требовался лёгкий по памяти SSO-провайдер с хорошей документацией,
|
||||
желательно на Go/Rust.
|
||||
|
||||
## Рассмотренные варианты
|
||||
|
||||
- **Оставить Keycloak.** Отвергнуто: > 500 МБ RAM ради < 10
|
||||
пользователей, функционал избыточен для личного сервера.
|
||||
- **Authelia** (выбран). Лёгкая (Go), малое потребление памяти, хорошая
|
||||
документация. Умеет и OIDC, и forward-auth.
|
||||
|
||||
Критерии отбора замены: минимальный расход RAM, хорошая документация,
|
||||
стек Go/Rust.
|
||||
|
||||
## Решение
|
||||
|
||||
Заменили Keycloak на Authelia как провайдер аутентификации
|
||||
(коммиты `a77fefc`, `d1500ea`, `3a23c08`). Authelia используется в трёх
|
||||
режимах:
|
||||
|
||||
- **OIDC** для приложений, которым он нужен (например, Outline).
|
||||
- **Forward-auth** агент в Caddy — удобно там, где полноценный OIDC
|
||||
избыточен.
|
||||
- **Закрытие чувствительных приложений** за единым логином. Раньше для
|
||||
этого использовался basic auth в Caddy.
|
||||
|
||||
## Последствия
|
||||
|
||||
- `+` Резко меньше потребление RAM — критично для сервера с дефицитом
|
||||
памяти.
|
||||
- `+` Forward-auth закрывает приложения без OIDC проще, чем поднимать
|
||||
отдельный OIDC-клиент под каждое.
|
||||
- `+` Единая точка аутентификации вместо разрозненного basic auth в
|
||||
Caddy.
|
||||
- `-` Authelia беднее Keycloak по возможностям (нет полноценного интерфейса
|
||||
управления пользователями, realms, federation) — но для < 10
|
||||
пользователей это не нужно.
|
||||
@@ -0,0 +1,42 @@
|
||||
# Данные приложений на отдельном диске
|
||||
|
||||
- Дата: 2025-12-07
|
||||
|
||||
## Контекст
|
||||
|
||||
Исторически данные приложений лежали прямо в домашних директориях их
|
||||
системных пользователей (`/home/<app-user>/…`), то есть на системном
|
||||
диске рядом с ОС. В конце 2025 встал вопрос обновления ОС (Ubuntu 22.04
|
||||
уже устарела), и стало ясно: пока данные привязаны к системному диску,
|
||||
любое обновление или пересборка системы рискует этими данными и тяжело
|
||||
откатывается.
|
||||
|
||||
Возникла мысль развязать данные приложений и жизненный цикл ОС.
|
||||
|
||||
## Рассмотренные варианты
|
||||
|
||||
- **Данные на системном диске** (как было). Просто, но данные связаны с
|
||||
ОС: обновление/пересборка системы затрагивает и их.
|
||||
- **Отдельный диск под данные** (выбран). Данные переживают пересборку
|
||||
ОС, диск можно отцепить от одного сервера и прицепить к другому.
|
||||
|
||||
## Решение
|
||||
|
||||
Вынесли все данные приложений на отдельный диск, смонтированный в
|
||||
`/mnt/applications`; каждое приложение держит там свои `data` / `config`
|
||||
/ `backups`, а `base_dir`/`data_dir` указывают на этот путь
|
||||
(коммиты `47a6320`, `7e67409`, `ae7c20a`, `8dfd061`).
|
||||
|
||||
## Последствия
|
||||
|
||||
- `+` Данные развязаны с жизненным циклом ОС — систему можно обновлять
|
||||
и пересобирать, не трогая данные.
|
||||
- `+` Диск можно отцепить от старого сервера и прицепить к новому. Это
|
||||
легло в основу метода обновления ОС
|
||||
([ADR-2025-12-13](ADR-2025-12-13-os-upgrade-via-server-rebuild.md)).
|
||||
- `-` Появилась зависимость от монтирования внешнего диска (UUID,
|
||||
mount-конфигурация): если диск не смонтирован, приложения не
|
||||
поднимутся. Позже, при переезде в Timeweb, монтирование пришлось
|
||||
сделать опциональным
|
||||
([ADR-2026-05-23](ADR-2026-05-23-migrate-to-timeweb.md), фаза 1 — один
|
||||
диск, фаза 2 — отдельный «холодный» диск под крупные данные).
|
||||
@@ -0,0 +1,53 @@
|
||||
# Обновление ОС пересборкой на свежем сервере
|
||||
|
||||
- Дата: 2025-12-13
|
||||
|
||||
## Контекст
|
||||
|
||||
На сервере стояла Ubuntu 22.04, и к концу 2025 пора было обновляться.
|
||||
Обновлять живую боевую систему «на месте» (`do-release-upgrade`) не
|
||||
хотелось — это рискованно и тяжело откатывается, если что-то пойдёт не
|
||||
так на работающем сервере.
|
||||
|
||||
## Рассмотренные варианты
|
||||
|
||||
- **Обновление «на месте»** (`do-release-upgrade` на живой системе).
|
||||
Отвергнуто: риск сломать рабочий сервер, нет простого отката.
|
||||
- **Пересборка на свежем сервере** (выбран). Поднять новый сервер с
|
||||
целевой ОС, накатать ansible, прицепить диск с данными, развернуть
|
||||
приложения — старый сервер остаётся нетронутым как точка отката.
|
||||
Заодно — почистить мусор, накопившийся за время работы прошлого сервера.
|
||||
|
||||
## Решение
|
||||
|
||||
Обновляем ОС через пересборку на свежем сервере. Метод опирается на три
|
||||
предпосылки:
|
||||
|
||||
- **Деплой без запуска контейнеров.** Сводные плейбуки
|
||||
(`playbook-all-setup`, `playbook-all-applications`) и тег `run-app`
|
||||
позволяют раскатать пользователей, каталоги и конфиги, но НЕ запускать
|
||||
приложения (`--skip-tags run-app`) — данные переносятся в «тихую»
|
||||
систему (коммиты `5b53cb3`, `48bb8c9`, `67df03e`).
|
||||
- **Данные на отдельном диске**
|
||||
([ADR-2025-12-07](ADR-2025-12-07-app-data-on-separate-disk.md)) — диск
|
||||
с данными прицепляется к новому серверу.
|
||||
- **Фиксированные uid/gid.** Заранее закрепили uid/gid всех
|
||||
пользователей приложений (роль `owner`, коммит `c2ea2cd`). Это
|
||||
критично: иначе при пересоздании пользователей на новом сервере
|
||||
uid/gid могли бы сдвинуться, и данные приложений на отдельном диске
|
||||
оказались бы с чужим владельцем.
|
||||
|
||||
Порядок: сначала вся подготовка (отдельный диск, перенос данных на него,
|
||||
фиксация uid/gid), затем пересборка на новом обновлённом сервере. Перенос
|
||||
прошёл без проблем.
|
||||
|
||||
## Последствия
|
||||
|
||||
- `+` Обновление ОС без риска для живой системы; откат = вернуться на
|
||||
старый сервер.
|
||||
- `+` Получился воспроизводимый процесс миграции — позже переиспользован
|
||||
при переезде в Timeweb как «холодное переключение» (cold cutover)
|
||||
([ADR-2026-05-23](ADR-2026-05-23-migrate-to-timeweb.md)).
|
||||
- `+` Фиксация uid/gid стала постоянным инвариантом проекта.
|
||||
- `-` Метод требует заранее подготовленных предпосылок (фикс uid/gid +
|
||||
данные на отдельном диске); без них он не работает.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Apprise как шлюз уведомлений
|
||||
|
||||
- Дата: 2026-04-04
|
||||
|
||||
## Контекст
|
||||
|
||||
В первую очередь нужны были уведомления о бэкапах — знать, что ночной
|
||||
прогон отработал и не сломался. Уведомления слались напрямую в конкретный
|
||||
канал, привязка была зашита в каждом источнике. Хотелось единый слой,
|
||||
который абстрагирует каналы доставки — чтобы добавлять или менять канал в
|
||||
одном месте, а не править каждый источник.
|
||||
|
||||
## Рассмотренные варианты
|
||||
|
||||
- **Прямая интеграция с каждым каналом** (как было). Каждый источник знает про
|
||||
конкретный канал; смена канала — правки во многих местах.
|
||||
- **Apprise** (выбран). Смотрел разные self-hosted шлюзы уведомлений;
|
||||
apprise выиграл зрелостью и числом готовых интеграций (десятки каналов
|
||||
из коробки).
|
||||
|
||||
## Решение
|
||||
|
||||
Подняли apprise отдельным сервисом-шлюзом: источники шлют уведомление по
|
||||
HTTP в apprise, а он разводит его по настроенным каналам (коммиты
|
||||
`a0543e1`, `5f619ea`, `6bfb362`). Под ограниченную память сервера apprise
|
||||
запущен в один воркер (`5e6df11`).
|
||||
|
||||
## Последствия
|
||||
|
||||
- `+` Каналы доставки абстрагированы за единым шлюзом — добавить или
|
||||
сменить канал можно в одном месте, не трогая источники.
|
||||
- `+` Доступ к десяткам интеграций apprise без отдельного кода под
|
||||
каждую.
|
||||
- `-` Ещё один сервис в обслуживании (контейнер, память).
|
||||
- Окупилось при переезде в Timeweb: провайдер заблокировал Telegram, и
|
||||
переключение уведомлений (сейчас почта, в планах Matrix) локализовано в
|
||||
шлюзе ([ADR-2026-05-23](ADR-2026-05-23-migrate-to-timeweb.md)).
|
||||
@@ -0,0 +1,82 @@
|
||||
# Переезд сервера с Yandex Cloud на Timeweb VPS
|
||||
|
||||
- Дата: 2026-05-23
|
||||
|
||||
## Контекст
|
||||
|
||||
`rivendell-v2` жил на виртуальной машине в Yandex Cloud. Одновременно копились три
|
||||
проблемы:
|
||||
|
||||
- **Цена.** ≈ 2 887 ₽/мес за конфигурацию, которую другие провайдеры
|
||||
дают дешевле и мощнее.
|
||||
- **Потолок RAM.** 4 ГБ, ≈ 80 % заняты на штатной нагрузке. Любой
|
||||
всплеск (миграции БД, индексация Outline, restic) — конкуренция за
|
||||
память и риск OOM. Расти на этом тарифе дальше — только заметно
|
||||
дороже.
|
||||
- **Медленный диск.** Чтобы сдержать цену в YC, использовался дешёвый
|
||||
HDD вместо SSD/NVMe — страдала отзывчивость (Gitea, Outline, тёплый
|
||||
старт контейнеров, restic check/forget).
|
||||
|
||||
Это личный сервер — допустимы небольшие простои.
|
||||
|
||||
## Рассмотренные варианты
|
||||
|
||||
- **Остаться в YC, поднять тариф** (больше RAM/SSD на месте). Отвергнуто:
|
||||
YC уже дороже альтернатив, апгрейд поднимает цену непропорционально
|
||||
приросту — те же три проблемы решаются дороже, чем переездом.
|
||||
- **Свой / домашний сервер** (железо под контролем, без ежемесячной
|
||||
аренды). Отвергнуто: дома нет надёжного аптайма 24/7 (питание,
|
||||
интернет-канал, железо), а сервисы должны быть всегда доступны.
|
||||
- **Переезд на Timeweb Cloud VPS** — выбранный вариант.
|
||||
|
||||
## Решение
|
||||
|
||||
Переносим на Timeweb Cloud VPS: ≈ 1 980 ₽/мес, 8 ГБ RAM (×2), 4 ядра
|
||||
(×2, гарантия CPU 100 % вместо 50 %), 80 ГБ NVMe вместо 120 ГБ HDD.
|
||||
Один переезд закрывает все три причины сразу.
|
||||
|
||||
Рамки решения:
|
||||
|
||||
- Переезжает **только сам сервер с приложениями** (compute). S3 (restic, бэкапы),
|
||||
Container Registry, Postbox SMTP и DNS-зона `vakhrushev.me` остаются
|
||||
в Yandex и используются с новой машины.
|
||||
- Стратегия — **холодное переключение** (cold cutover): погасить сервисы
|
||||
на источнике, раскатать
|
||||
ansible на новом сервере без запуска приложений (сохраняя uid/gid), перенести
|
||||
данные `rsync`'ом, запустить, переключить DNS.
|
||||
- Диск: фаза 1 — один 80 ГБ NVMe (всего 22 ГБ данных + 17 ГБ системных, влезает с
|
||||
запасом). «Холодный» второй диск под крупные данные — отдельная
|
||||
фаза 2, не на критическом пути.
|
||||
- Источник не удаляется сразу после переключения: держим «холодным
|
||||
запасным»
|
||||
пару недель ради отката.
|
||||
|
||||
Детальный план — [`../drafts/timeweb.md`](../drafts/timeweb.md),
|
||||
фактическое выполнение —
|
||||
[`../drafts/timeweb-migration-log.md`](../drafts/timeweb-migration-log.md).
|
||||
|
||||
## Последствия
|
||||
|
||||
- `+` −907 ₽/мес (≈ −31 %) при вдвое большем RAM и CPU и NVMe-диске —
|
||||
закрыты все три исходные проблемы.
|
||||
- `+` Запас по RAM убирает OOM-риск при всплесках нагрузки.
|
||||
- `+` Диверсификация по облакам: раньше сервер и данные были в одном
|
||||
аккаунте Yandex Cloud, теперь сам сервер в Timeweb, а бэкапы (S3) — в
|
||||
Yandex. Если заблокируют или потеряем доступ к одному провайдеру,
|
||||
данные остаются доступны через другой.
|
||||
- `-` Диск меньше (80 ГБ NVMe против 120 ГБ HDD), но сейчас занят
|
||||
примерно наполовину — запас есть, фаза 2 с холодным диском не срочная.
|
||||
- `-` Сохраняется зависимость от Yandex Cloud (S3, Container Registry,
|
||||
Postbox SMTP, DNS) — переезд её не устраняет.
|
||||
- `-` Timeweb активно блокирует Telegram (в отличие от YC) — интеграция
|
||||
отвалилась. Затронуты `transcriber`, `remembos` и уведомления о
|
||||
бэкапах. Ожидаемо; уведомления остались через почту, второй канал
|
||||
рассматривается через Matrix.
|
||||
- `-` Из-за тех же блокировок Timeweb перестали обновляться некоторые
|
||||
RSS-фиды в `miniflux`.
|
||||
- `-` Для доступа к `cr.yandex` вне YC появился долгоживущий OAuth-токен
|
||||
Яндекса в vault (`yc_oauth_token`): при утечке он открывает доступ ко
|
||||
всему аккаунту Яндекса. Сузить можно IAM-ключом сервисного аккаунта —
|
||||
отдельной итерацией.
|
||||
- Инвентарь временно раздвоен (`production.yml` + `timeweb.yml`); после
|
||||
стабилизации источник удаляется, `timeweb.yml` → `production.yml`.
|
||||
@@ -0,0 +1,71 @@
|
||||
# Architecture Decision Records (ADR)
|
||||
|
||||
Журнал значимых архитектурных и инфраструктурных решений по серверу.
|
||||
Одна запись — одно решение. ADR пишем **постфактум**, когда изменение
|
||||
уже сделано: идеи, планы и обсуждения живут в [`../drafts/`](../drafts),
|
||||
а в ADR попадает только то, что реализовано. Записи **неизменяемы**:
|
||||
передумали → не правим старую, а заводим новую и помечаем старую.
|
||||
|
||||
Чем ADR отличается от журналов в `../drafts/`: drafts — оперативная
|
||||
хроника и черновики («что делаю / собираюсь сделать»), ADR — фиксация
|
||||
выбора и его обоснования («почему сделал так, а не иначе»).
|
||||
|
||||
Главная ценность записи — сохранить **почему**: намерение и причинность.
|
||||
Это важнее аккуратности оформления и полноты остальных секций.
|
||||
|
||||
## Когда заводить ADR
|
||||
|
||||
- Выбор технологии или инструмента (Caddy vs Nginx, restic vs borg).
|
||||
- Структурные решения (схема бэкапов, организация плейбуков, сеть).
|
||||
- Решения с долгосрочными последствиями или дорогим откатом.
|
||||
- **Намеренный отказ** от очевидного подхода — чтобы потом не
|
||||
переоткрывать «а почему мы не сделали X».
|
||||
|
||||
Не заводить для рутины (бамп версии образа, добавление сервиса по
|
||||
накатанной схеме) и того, что и так видно из кода и git.
|
||||
|
||||
## Соглашения
|
||||
|
||||
- **Имя файла = идентификатор:** `ADR-ГГГГ-ММ-ДД-kebab-slug.md`.
|
||||
Идентификатор — имя без `.md` (например `ADR-2026-05-24-adr-process`).
|
||||
Slug — латиницей.
|
||||
- **Дата** — когда изменение реально сделано (можно задним числом, а не
|
||||
дата оформления записи).
|
||||
- Несколько ADR за один день различаются по slug.
|
||||
- **Заголовок в файле:** `# Человеческий заголовок` (без даты и ID — они
|
||||
в имени файла и в строке «Дата»).
|
||||
- Секция **«Рассмотренные варианты» — опциональна**: оставляй её, только
|
||||
если альтернативы реально рассматривались.
|
||||
- Шаблон новой записи — [`template.md`](template.md).
|
||||
- Исключение: основополагающая мета-запись о самом процессе ADR
|
||||
использует дату-сентинел `0000-00-00`, чтобы всегда оставаться в самом
|
||||
низу списка. Реальные записи такую дату не используют.
|
||||
|
||||
## Статусы
|
||||
|
||||
Активная запись статуса **не имеет**. Статус появляется, только когда
|
||||
запись теряет силу, и значений всего два:
|
||||
|
||||
- `заменено на ADR-ГГГГ-ММ-ДД-slug` — решение пересмотрено новой ADR.
|
||||
- `устарело` — решение потеряло смысл и замены нет.
|
||||
|
||||
## Замена и устаревание
|
||||
|
||||
1. Заводим новую ADR; в её «Контексте» — строка
|
||||
«Заменяет ADR-ГГГГ-ММ-ДД-slug».
|
||||
2. В старой ADR добавляем строку `- Статус: заменено на ADR-…` сразу под
|
||||
датой. Тело не трогаем — это часть истории.
|
||||
3. Обновляем статус старой записи в индексе ниже.
|
||||
|
||||
## Список записей
|
||||
|
||||
Новые сверху.
|
||||
|
||||
| Дата | Запись | Статус |
|
||||
| ---------- | ---------------------------------------------------------------------------------------------- | ------ |
|
||||
| 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) | — |
|
||||
| 2025-12-13 | [Обновление ОС пересборкой на свежем сервере](ADR-2025-12-13-os-upgrade-via-server-rebuild.md) | — |
|
||||
| 2025-12-07 | [Данные приложений на отдельном диске](ADR-2025-12-07-app-data-on-separate-disk.md) | — |
|
||||
| 2025-05-07 | [Authelia вместо Keycloak для SSO](ADR-2025-05-07-authelia-sso.md) | — |
|
||||
| 0000-00-00 | [Вести историю решений в виде ADR](ADR-0000-00-00-record-architecture-decisions.md) | — |
|
||||
@@ -0,0 +1,37 @@
|
||||
# Краткий заголовок решения
|
||||
|
||||
- Дата: ГГГГ-ММ-ДД
|
||||
<!-- Строку статуса добавляют позже, только если запись потеряла силу:
|
||||
- Статус: заменено на ADR-ГГГГ-ММ-ДД-slug
|
||||
- Статус: устарело
|
||||
У активной записи строки статуса нет. -->
|
||||
|
||||
## Контекст
|
||||
|
||||
Что вынудило сделать изменение: проблема, силы и ограничения (ресурсы
|
||||
сервера, стоимость, время на поддержку, существующая архитектура).
|
||||
Пиши так, чтобы через год было понятно «почему это вообще делалось»
|
||||
без чтения переписки.
|
||||
|
||||
## Рассмотренные варианты
|
||||
|
||||
<!-- Опциональная секция. Оставь, только если варианты реально
|
||||
рассматривались. Если решение было единственным очевидным —
|
||||
удали её, а причину объясни в «Решении». -->
|
||||
|
||||
- **Вариант A** — суть, плюсы и минусы.
|
||||
- **Вариант B** — суть, плюсы и минусы.
|
||||
- **Вариант C** — если отвергнут сразу, коротко почему.
|
||||
|
||||
## Решение
|
||||
|
||||
Что именно сделано и — главное — **почему**: какое намерение и какая
|
||||
причина за этим стоят. Если варианты рассматривались — почему выбран
|
||||
этот, а не остальные.
|
||||
|
||||
## Последствия
|
||||
|
||||
- `+` что стало лучше, какие возможности открылись.
|
||||
- `-` чем платим: новые ограничения, риски, регулярная нагрузка на
|
||||
поддержку.
|
||||
- Что нужно сделать как следствие (если есть).
|
||||
@@ -0,0 +1,82 @@
|
||||
# Алерты на проблемные контейнеры
|
||||
|
||||
## Контекст
|
||||
|
||||
Случай с wakapi: при старте упали миграции, контейнер встал в restart-loop и
|
||||
несколько дней крутился по кругу — никто не узнал. Из этого две проблемы:
|
||||
|
||||
1. Контейнеры могут бесконечно перезапускаться при ошибке.
|
||||
2. Нет алертов о таких ситуациях.
|
||||
|
||||
## Что есть и что использовать
|
||||
|
||||
- **Netdata** — Docker-collector через cgroups + Docker API: состояние,
|
||||
restart count, healthcheck status. Алерты в `health.d/*.conf`, нотификации
|
||||
через `health_alarm_notify.conf` (Telegram/Discord/email/ntfy).
|
||||
- **Dozzle** — только для просмотра логов после факта, нормальных алертов нет.
|
||||
- **Caddy** — мог бы участвовать в healthcheck снаружи, но это отдельный слой.
|
||||
|
||||
## План — три слоя
|
||||
|
||||
### 1. Healthchecks в compose (фундамент)
|
||||
|
||||
Без них Docker считает контейнер «running», пока процесс жив, — wakapi с
|
||||
падающими миграциями этому условию удовлетворял. Добавить в каждый
|
||||
`docker-compose.yml.j2`:
|
||||
|
||||
```yaml
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O-", "http://localhost:PORT/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 60s # окно на миграции — failed-проверки до истечения не считаются
|
||||
```
|
||||
|
||||
`start_period` — ключевая штука для случая wakapi: даём миграциям отработать,
|
||||
до его истечения healthcheck не убивает контейнер.
|
||||
|
||||
### 2. Алерты через Netdata (главное)
|
||||
|
||||
Два разных сигнала:
|
||||
|
||||
- **Restart loop** — алерт на `docker.container_state` или счётчик
|
||||
перезапусков (растёт > N за M минут). Это и есть «контейнер крутится по
|
||||
кругу».
|
||||
- **Unhealthy** — после healthcheck выше алерт на
|
||||
`docker.container_health_status != healthy` дольше M минут.
|
||||
|
||||
Канал нотификаций: один, проще всего Telegram-бот. Настройка в
|
||||
`health_alarm_notify.conf`.
|
||||
|
||||
### 3. Restart policy — что менять (или не менять)
|
||||
|
||||
Скорее **оставить `unless-stopped`**. Альтернативы и их минусы:
|
||||
|
||||
- `on-failure:5` — Docker сам остановит после 5 попыток. Минус: после ребута
|
||||
сервера сервис не поднимется (только `always`/`unless-stopped` встают на
|
||||
старте докера). Серьёзный регресс для домашнего сервера.
|
||||
- Внешний sidecar, слушающий `docker events` и останавливающий контейнер
|
||||
после N рестартов в окне — лишняя сложность ради того, что уже сделает
|
||||
алерт.
|
||||
|
||||
Лучше: алерт пришёл → решаем вручную, остановить или чинить.
|
||||
|
||||
## Опционально (вне netdata)
|
||||
|
||||
- **Uptime Kuma** — внешний HTTP-чек по публичным URL. Ловит случаи, когда
|
||||
контейнер «здоров», но прокся/DNS/Caddy сломаны. Свои нотификации, дашборд.
|
||||
Не дублирует netdata, проверяет с другой стороны.
|
||||
|
||||
## Шаги при реализации
|
||||
|
||||
1. Добавить healthcheck + start_period в compose-шаблоны (начать с wakapi,
|
||||
потом по списку).
|
||||
2. Проверить, что netdata собирает Docker-метрики (collector включён).
|
||||
3. Настроить один канал нотификаций (Telegram/ntfy/email — выбрать).
|
||||
4. Написать пару алертов: restart-loop и unhealthy.
|
||||
|
||||
## Открытые вопросы
|
||||
|
||||
- Какой канал нотификаций использовать.
|
||||
- Добавлять ли Uptime Kuma сразу или потом.
|
||||
@@ -0,0 +1,150 @@
|
||||
# Ревью плейбуков: best practices и конвенции Ansible
|
||||
|
||||
Дата: 2026-05-25. Статус: черновик (заметки по итогам ревью, не план работ).
|
||||
|
||||
Проанализированы инвентарь, `ansible.cfg`, роли (`owner`, `eget`, `secrets`) и
|
||||
репрезентативная выборка плейбуков: `gitea`, `memos`, `wanderer`, `backups`,
|
||||
`system`, `caddyproxy`, `authelia`, `netdata`, `docker`, `eget`, `all-*`.
|
||||
Находки отсортированы по влиянию.
|
||||
|
||||
## Договорённость по структуре (важно для контекста)
|
||||
|
||||
Изначальная рекомендация «вынести общий деплой в одну generic-роль `docker_app`»
|
||||
**отклонена осознанно** и не должна предлагаться снова:
|
||||
|
||||
- приложения реально разные, мелкие отличия больно загонять в единую абстракцию;
|
||||
- catch-all роль обрастает флагами `when:` и читается хуже, чем N честных плейбуков;
|
||||
- per-playbook дублирование даёт locality of behavior и возможность обкатать новый
|
||||
подход на одном сервисе, затем раскатать на остальные.
|
||||
|
||||
Правильное направление — **набор маленьких composable-ролей на инвариантных швах**
|
||||
(как уже сделано с `owner`), а не одна роль на всё. Per-app конфиг остаётся локально
|
||||
в плейбуке сервиса.
|
||||
|
||||
## 1. Extraction только на чистых швах (не мега-роль)
|
||||
|
||||
Per-app конфиг (каталоги, шаблоны, env, порты, особенности compose) — оставляем в
|
||||
плейбуке сервиса. Выносим лишь то, что реально инвариантно и повторилось многократно:
|
||||
|
||||
- **Бэкап** — самый чистый шов: `gobackup.yml` + `backup.sh` + `backup-targets` +
|
||||
интеграция с restic. Механизм одинаков у всех, различается только список целей.
|
||||
Роль `backup` с параметром «список targets» не трогает индивидуальность сервиса.
|
||||
- `owner` уже сделан как отдельная composable-роль — это правильный размер абстракции.
|
||||
|
||||
## 2. `vars_files` в каждом плейбуке → `group_vars/all/`
|
||||
|
||||
В каждом плейбуке повторяется:
|
||||
|
||||
```yaml
|
||||
vars_files:
|
||||
- vars/secrets.yml
|
||||
- vars/vars.yml
|
||||
```
|
||||
|
||||
Ansible автоматически подхватывает `group_vars/all.yml` и `group_vars/all/secrets.yml`
|
||||
(vault) для группы `all`. Перенос `vars/vars.yml` → `group_vars/all/main.yml` и
|
||||
`vars/secrets.yml` → `group_vars/all/vault.yml` убирает boilerplate из всех плейбуков.
|
||||
Адаптируется по одному плейбуку за раз.
|
||||
|
||||
## 3. Нет handlers — `state: restarted` безусловный
|
||||
|
||||
Ни в одном плейбуке нет `handlers:`. Вместо этого:
|
||||
|
||||
- `playbook-caddyproxy.yml:106`, `playbook-netdata.yml:143`, `playbook-authelia.yml:92` —
|
||||
задача `state: restarted` выполняется **всегда**, рестартит контейнер на каждом
|
||||
прогоне даже без изменений (не идемпотентно, лишний downtime);
|
||||
- `playbook-gitea.yml` — рестарта нет вовсе (несогласованность).
|
||||
|
||||
Канонический паттерн: шаблон конфига `notify`-ит handler, который рестартит только при
|
||||
реальном изменении.
|
||||
|
||||
```yaml
|
||||
- name: "Copy docker compose file"
|
||||
ansible.builtin.template: { ... }
|
||||
notify: Restart app
|
||||
|
||||
handlers:
|
||||
- name: Restart app
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: "{{ base_dir }}"
|
||||
state: restarted
|
||||
```
|
||||
|
||||
Связанное: в `playbook-memos.yml:76` результат шаблона регистрируется в
|
||||
`docker_compose_file_result`, но нигде не используется — задумывалось под `when`/`notify`,
|
||||
не доведено.
|
||||
|
||||
Внедряется инкрементально, по одному сервису.
|
||||
|
||||
## 4. Идемпотентность и `changed_when`
|
||||
|
||||
- **`playbook-netdata.yml:118-125`** — `changed_when: netdata_docker_group_output.rc != 0`
|
||||
для read-only запроса лишено смысла (помечает «changed» только при ошибке). Должно быть
|
||||
`changed_when: false`. Лучше заменить `shell: grep docker /etc/group | cut ...` на модуль:
|
||||
|
||||
```yaml
|
||||
- ansible.builtin.getent:
|
||||
database: group
|
||||
key: docker
|
||||
# далее: getent_group['docker'][1]
|
||||
```
|
||||
|
||||
Уйдёт и `set -o pipefail`, и хрупкий парсинг.
|
||||
|
||||
- **`playbook-eget.yml:23-78`** — восемь `command` помечены `changed_when: false`, хотя
|
||||
реально ставят/обновляют бинарники. Прогон всегда рапортует «ok» — теряется честность
|
||||
`--diff`. Сама роль `eget` делает корректную проверку версии; те же инсталляции через
|
||||
неё или через проверку версии были бы идемпотентны по-настоящему.
|
||||
|
||||
- **`playbook-memos.yml:57-67`** (и аналоги) — сборка `backup-targets` через `lineinfile`
|
||||
в цикле не удаляет устаревшие строки при изменении списка, а `mode: "0750"` на
|
||||
файле-списке выглядит как copy-paste. Чище — `template`/`copy: content` со всем списком.
|
||||
|
||||
## 5. Роль `owner` — несогласованность с ролью `eget`
|
||||
|
||||
- **`roles/owner/tasks/main.yml:2-10`** — валидация аргументов через `fail` + `when`,
|
||||
причём две задачи с **идентичным именем**. Роль `eget` для того же делает `assert`
|
||||
(`roles/eget/tasks/main.yml:15`). Привести к одному стилю — `assert` либо современный
|
||||
`meta/argument_specs.yml` (декларативная валидация).
|
||||
- **`roles/owner/tasks/main.yml:32,53`** — `with_items`/`with_dict` устарели; конвенция —
|
||||
`loop`: `loop: "{{ owner_ssh_keys }}"`, `loop: "{{ owner_env_dict | dict2items }}"`.
|
||||
- У `owner` нет `meta/main.yml` и README, тогда как у `eget` и `secrets` они есть.
|
||||
- Имена задач в `owner` с точкой на конце (`"Prepare env variables."`), в остальных без —
|
||||
ansible-lint в строгом профиле это ловит.
|
||||
|
||||
## 6. Инвентарь и `become`
|
||||
|
||||
- **`production.yml` и `timeweb.yml`** оба объявляют хост с именем `server` под ключом
|
||||
`ungrouped:`. Хост-специфичные данные (`application_dir`, `mount_external_storage`,
|
||||
`ansible_host`, `ansible_user`) вписаны инлайн. Конвенциональнее — `host_vars/server.yml`,
|
||||
хосты в именованной группе. Два инвентаря с одинаковым именем хоста + `hosts: all` =
|
||||
ошибка `-i` молча уедет не туда.
|
||||
- `ansible_become: true` глобально в инвентаре — всё бежит под root. Для личного сервера
|
||||
прагматично; точечный `become`/`become_user` ближе к наименьшим привилегиям. Низкий приоритет.
|
||||
|
||||
## 7. Конкретный баг
|
||||
|
||||
- **`playbook-wanderer.yml:2`** — `name: "Configure gramps application"`, хотя
|
||||
`app_name: "wanderer"`. Копипаст из gramps, поправить имя play.
|
||||
|
||||
## 8. Мелочи стиля и конфигурации
|
||||
|
||||
- **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`.
|
||||
- Несогласованные кавычки и пути: `'directory'` vs `"directory"`, `src: "./files/..."` vs
|
||||
`src: "files/..."`, одинарные кавычки в `playbook-all-setup.yml` против двойных в остальных.
|
||||
- `playbook-system.yml:24` — `apt` без `cache_valid_time`, обновляет кэш каждый прогон.
|
||||
|
||||
## Приоритеты
|
||||
|
||||
1. **#3 handlers** — убирает безусловный рестарт; внедряется по одному сервису.
|
||||
2. **#1 роль `backup`** — самый чистый шов для extraction; обкатать на одном сервисе.
|
||||
3. **#4, #7** — быстрые точечные фиксы без структурных изменений.
|
||||
4. **#2 group_vars** — убирает boilerplate; низкий риск.
|
||||
5. **#5, #6, #8** — фоновая зачистка стиля и структуры.
|
||||
@@ -0,0 +1,285 @@
|
||||
# Gitea runner on-demand в Yandex Cloud
|
||||
|
||||
## Контекст
|
||||
|
||||
В YC планируется развернуть self-hosted раннер для Gitea Actions. Сборки —
|
||||
несколько раз в неделю, в среднем ~10 в неделю по ~5 минут. ВМ 24/7 даёт
|
||||
утилизацию в районе 1%, остальное оплачивается впустую.
|
||||
|
||||
Цель — раннер активен только во время сборки и небольшого окна простоя
|
||||
после, без ручных команд от разработчика.
|
||||
|
||||
## Архитектура
|
||||
|
||||
```
|
||||
push → Gitea ──webhook──► Cloud Function ──Compute API──► ВМ (раннер)
|
||||
(HMAC validate, │
|
||||
start logic) ▼
|
||||
act_runner (docker)
|
||||
probe + decide
|
||||
│
|
||||
└─REST self-stop
|
||||
```
|
||||
|
||||
Без API Gateway: функция публикуется напрямую через свой HTTPS-эндпоинт
|
||||
`https://functions.yandexcloud.net/<id>`. Этот URL вписывается в Gitea
|
||||
webhook. Аутентификация — HMAC-SHA256 в заголовке `X-Gitea-Signature`,
|
||||
проверяется внутри функции.
|
||||
|
||||
Поток событий:
|
||||
|
||||
1. Push в Gitea → System Webhook на URL функции.
|
||||
2. Функция валидирует HMAC, читает state ВМ, действует по стейт-машине
|
||||
(см. ниже).
|
||||
3. ВМ стартует, docker поднимает контейнер `act_runner`, тот подключается
|
||||
к Gitea и забирает джобу.
|
||||
4. На ВМ работают probe (раз в 30 сек собирает телеметрию) и decide
|
||||
(раз в 1 мин принимает решение).
|
||||
5. После idle-окна decide дёргает Compute REST API на gas самой себя.
|
||||
|
||||
## Cloud-side
|
||||
|
||||
### Ресурсы в YC
|
||||
|
||||
- Один фолдер на старте — общий с Gitea-сервером. Принятый риск: SA
|
||||
самогашения формально может остановить любую ВМ в фолдере. Перенос в
|
||||
отдельный фолдер — миграция на потом.
|
||||
- Два сервисных аккаунта:
|
||||
- `runner-self-stop` (привязан к ВМ): `compute.instances.stop`,
|
||||
`compute.instances.get`.
|
||||
- `runner-starter-fn` (привязан к функции): `compute.instances.start`,
|
||||
`compute.instances.get`.
|
||||
- Cloud Function `runner-starter`, runtime Python 3.11, 256 MB, timeout
|
||||
30 сек. Публичный HTTPS-эндпоинт включён.
|
||||
- Алерт Cloud Monitoring: `compute.instance.status = RUNNING` дольше 24 ч
|
||||
подряд → нотификация (канал — на этапе внедрения).
|
||||
|
||||
### Bootstrap-скрипты
|
||||
|
||||
```
|
||||
scripts/
|
||||
├── runner-starter/ # код Cloud Function
|
||||
│ ├── handler.py # webhook → start, стейт-машина
|
||||
│ └── requirements.txt
|
||||
├── runner_bootstrap.py # one-time: создать SA, ВМ, функцию, алерт
|
||||
└── runner_deploy_function.py # обновить версию функции (yc CLI)
|
||||
```
|
||||
|
||||
Скрипты на Python поверх `yc` CLI (через `subprocess`). Идемпотентность —
|
||||
проверкой существования ресурсов перед созданием. Terraform не вводим:
|
||||
ресурсов мало, оверкилл.
|
||||
|
||||
### Стейт-машина функции
|
||||
|
||||
| State ВМ | Действие |
|
||||
| ------------------------------------- | ------------------------------------------------------- |
|
||||
| `RUNNING`, `STARTING`, `RESTARTING` | 200, ничего не делаем |
|
||||
| `STOPPED` | `instances:start` → 200 |
|
||||
| `STOPPING` | poll до `STOPPED` (до 25 сек), затем `start` → 200 |
|
||||
| `PROVISIONING`, `UPDATING` | 503 (временное состояние, retry клиентом) |
|
||||
| `ERROR`, `CRASHED` | 500 + лог ошибки (нужен человек) |
|
||||
| `DELETING`, `DELETED` | 500 + лог ошибки (что-то очень не так) |
|
||||
|
||||
## Host-side
|
||||
|
||||
### ВМ
|
||||
|
||||
- 2 vCPU (100%), 4 GB RAM, 25 GB network-hdd.
|
||||
- Ubuntu 22.04 LTS.
|
||||
- Без публичного IP при возможности (все исходящие к Gitea — через NAT
|
||||
или внутренний адрес).
|
||||
- Привязан SA `runner-self-stop`.
|
||||
- Регистрация в Gitea Actions делается **один раз** при первой настройке.
|
||||
Registration token берётся в Site Admin → Actions → Runners, кладётся
|
||||
в Vault. Плейбук проверяет наличие `.runner` файла на ВМ; если есть —
|
||||
пропускает регистрацию.
|
||||
|
||||
### Плейбук `playbook-gitea-runner.yml`
|
||||
|
||||
Стандартная структура проекта:
|
||||
|
||||
- `roles/owner` — пользователь `gitea-runner` (uid/gid выделить
|
||||
отдельные, в группе `docker`).
|
||||
- `files/gitea-runner/`:
|
||||
- `docker-compose.template.yml` — `act_runner` в docker
|
||||
(`gitea/act_runner:<pinned>`), `restart: unless-stopped`, mount
|
||||
docker socket для запуска job-контейнеров.
|
||||
- `act-runner-config.template.yaml` — конфиг раннера.
|
||||
- `runner-probe.template.py` + `runner-probe.template.service` +
|
||||
`runner-probe.template.timer`.
|
||||
- `runner-decide.template.py` + `runner-decide.template.service` +
|
||||
`runner-decide.template.timer`.
|
||||
- `samples-logrotate.template.conf` — ротация `samples.log`.
|
||||
|
||||
Расширения шаблонов — `.template.<ext>`, не `.j2` (соглашение проекта).
|
||||
|
||||
### Раннер в docker
|
||||
|
||||
`act_runner` стартует через `docker compose up -d` под пользователем
|
||||
`gitea-runner`. Поскольку `restart: unless-stopped`, дополнительный
|
||||
systemd-юнит для самого раннера не нужен — после `Start` ВМ docker
|
||||
поднимет контейнер автоматически.
|
||||
|
||||
Идентификатор контейнера фиксированный (`gitea_runner_app`), чтобы probe
|
||||
мог исключать его из счёта.
|
||||
|
||||
### Probe и decide
|
||||
|
||||
Два независимых юнита, телеметрия — append-only лог.
|
||||
|
||||
`runner-probe` (timer раз в 30 сек):
|
||||
|
||||
```bash
|
||||
# pseudocode
|
||||
busy_count=$(docker ps -q | grep -v <runner_container_id> | wc -l)
|
||||
state=$([ "$busy_count" -gt 0 ] && echo busy || echo idle)
|
||||
echo "$(date -u +%FT%TZ) $state containers=$busy_count" \
|
||||
>> /var/lib/runner-idle/samples.log
|
||||
```
|
||||
|
||||
В реальной реализации — Python, фильтр по docker SDK или по результату
|
||||
`docker ps --format '{{.Names}}'`.
|
||||
|
||||
`runner-decide` (timer раз в 1 мин):
|
||||
|
||||
1. Читает хвост `samples.log`.
|
||||
2. Находит `last_busy_at` — timestamp последней `busy`-строки.
|
||||
3. Находит `last_sample_at` — timestamp последней любой строки.
|
||||
4. Логика:
|
||||
- `now - last_sample_at > STALE_THRESHOLD` (5 мин) → **probe сломан**,
|
||||
не гасим, логируем error. Алерт CM поймает по uptime.
|
||||
- `now - last_busy_at > IDLE_THRESHOLD` (10 мин) → `instances:stop`
|
||||
через REST.
|
||||
- Иначе → ничего.
|
||||
|
||||
Параметры (`IDLE_THRESHOLD`, `STALE_THRESHOLD`) — переменные в шаблоне,
|
||||
тюнятся по эксплуатации.
|
||||
|
||||
### Самогашение через REST
|
||||
|
||||
Без `yc` CLI. Decide-скрипт получает IAM-токен из metadata-сервиса и
|
||||
дёргает Compute REST:
|
||||
|
||||
```python
|
||||
TOKEN_URL = "http://169.254.169.254/computeMetadata/v1/instance/" \
|
||||
"service-accounts/default/token"
|
||||
ID_URL = "http://169.254.169.254/computeMetadata/v1/instance/id"
|
||||
HEADERS = {"Metadata-Flavor": "Google"}
|
||||
|
||||
token = requests.get(TOKEN_URL, headers=HEADERS).json()["access_token"]
|
||||
instance_id = requests.get(ID_URL, headers=HEADERS).text
|
||||
requests.post(
|
||||
f"https://compute.api.cloud.yandex.net/compute/v1/instances/{instance_id}:stop",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
```
|
||||
|
||||
Никаких файлов с SA-key, никаких зависимостей сверх `python3 +
|
||||
requests`.
|
||||
|
||||
## Страховки от зависшей ВМ
|
||||
|
||||
Главный failure mode — probe или decide молча сломались, ВМ работает 24/7.
|
||||
|
||||
Слой 1 — soft idle-stop в decide. Нормальная работа.
|
||||
|
||||
Слой 2 — probe-staleness в decide. Если `samples.log` не обновляется
|
||||
дольше `STALE_THRESHOLD` — логируем error, **не гасим** (мог идти
|
||||
длинный билд). Полагаемся на слой 3.
|
||||
|
||||
Слой 3 — внешний алерт через Cloud Monitoring на uptime ВМ > 24 ч. Не
|
||||
дёргает остановку, только нотификация. Порог высокий, чтобы дни активной
|
||||
отладки не триггерили его. Если фактически висит сутки — это сигнал
|
||||
смотреть руками.
|
||||
|
||||
Hard-cap по uptime внутри decide **не делаем**: ломает кейс «активно
|
||||
тестирую несколько часов подряд», когда busy-сэмплы есть и логика идёт
|
||||
правильно.
|
||||
|
||||
## Секреты (Vault, `vars/secrets.yml`)
|
||||
|
||||
| Ключ | Назначение |
|
||||
| --------------------------------- | ----------------------------------------------------- |
|
||||
| `gitea_runner_registration_token` | одноразовый токен для `act_runner register` |
|
||||
| `gitea_webhook_secret` | общий с функцией HMAC-секрет для webhook |
|
||||
| `yc_runner_folder_id` | в каком фолдере живёт ВМ |
|
||||
| `yc_runner_instance_id` | ID ВМ (заполняется после bootstrap) |
|
||||
| `yc_runner_function_url` | URL функции для webhook (заполняется после bootstrap) |
|
||||
|
||||
## invoke-таски
|
||||
|
||||
| Таск | Что делает |
|
||||
| ----------------------------- | ----------------------------------------------------------------------------- |
|
||||
| `inv runner-bootstrap` | one-time: создаёт SA, ВМ, функцию, алерт. Идемпотентен. |
|
||||
| `inv runner-deploy-function` | заливает новую версию `runner-starter`. |
|
||||
| `inv runner-pl` | up → `ansible-playbook playbook-gitea-runner.yml` → down. С `try/finally`. |
|
||||
| `inv runner-up` / `down` | ручной старт/стоп ВМ для дебага. |
|
||||
| `inv runner-status` | state ВМ + хвост `samples.log` (через ssh). |
|
||||
| `inv runner-ssh` | ssh на ВМ, поднимает её при необходимости. |
|
||||
|
||||
`runner-pl` — основной таск, единственный «штатный» путь обновления
|
||||
конфига ВМ. Если плейбук падает посередине, `finally` всё равно гасит ВМ
|
||||
(idle-watch её и так бы погасил, но явное лучше).
|
||||
|
||||
## Стоимость
|
||||
|
||||
Базовая ставка YC (USD, после повышения 1 мая 2026): vCPU 100% =
|
||||
$0.010164/ч, RAM = $0.002705/ГБ·ч, network-hdd = $0.0000356/ГБ·ч.
|
||||
|
||||
Профиль: 10,75 ч активной ВМ в месяц.
|
||||
|
||||
| Конфиг (2 vCPU 100%, 4 GB RAM, 25 GB HDD) | $/мес |
|
||||
| ----------------------------------------- | ----- |
|
||||
| Compute (vCPU + RAM) при 10,75 ч | ~0.33 |
|
||||
| Disk (HDD, 24/7) | ~0.64 |
|
||||
| Cloud Function, Monitoring | 0.00 |
|
||||
| **Итого** | **~1.0** |
|
||||
|
||||
Сравнение: эта же ВМ в режиме 24/7 ≈ $23/мес. Экономия — порядка 95%.
|
||||
|
||||
Дальнейшая оптимизация — диск (15 GB вместо 25, ещё ~$0.25/мес). Делать
|
||||
не сейчас.
|
||||
|
||||
## Принятые риски
|
||||
|
||||
- **Общий фолдер с другими ВМ.** SA `runner-self-stop` теоретически
|
||||
может погасить и Gitea-сервер, если тот переедет в YC рядом. Митигация
|
||||
при появлении такой ВМ — перенос в отдельный фолдер.
|
||||
- **Холодный старт ~60 сек.** Дизайн заявляет 40, реальность ближе к
|
||||
60 (Ubuntu boot + docker pull + act_runner connect). Документируем как
|
||||
«нормальная задержка первой джобы».
|
||||
- **Регистрационный токен утерян.** При пересоздании диска ВМ нужен
|
||||
новый токен из Gitea UI. Документируем процесс. Раз в годы — терпимо.
|
||||
- **Probe сломан, ВМ висит.** Поймает алерт CM, ручное расследование.
|
||||
|
||||
## План внедрения
|
||||
|
||||
1. Создать в YC: 2 SA, ВМ, дисковый ресурс. Через `inv
|
||||
runner-bootstrap` или вручную через консоль (выбираем по желанию на
|
||||
этапе реализации).
|
||||
2. Прогнать `inv runner-pl` на свежесозданной ВМ. С временно
|
||||
уменьшенным `IDLE_THRESHOLD` (2 мин вместо 10) — чтобы тестировать
|
||||
гашение быстро.
|
||||
3. Зарегистрировать раннер в Gitea руками: получить registration token,
|
||||
положить в Vault, повторить `inv runner-pl`.
|
||||
4. Проверить, что раннер появился в Gitea UI и забирает тестовую джобу.
|
||||
5. Проверить idle-watch: дать ВМ постоять, убедиться, что гасится.
|
||||
6. Создать функцию `runner-starter` через `inv runner-deploy-function`.
|
||||
Проверить ручным `yc serverless function invoke`.
|
||||
7. Прописать System Webhook в Gitea на URL функции, секрет совпадает с
|
||||
Vault.
|
||||
8. Тестовый push → end-to-end проверка.
|
||||
9. Поднять `IDLE_THRESHOLD` обратно до 10 мин.
|
||||
10. Настроить алерт Cloud Monitoring на uptime > 24 ч.
|
||||
11. Неделя наблюдения: лог функции, samples.log, uptime ВМ, счёт.
|
||||
|
||||
## Открытые вопросы
|
||||
|
||||
- **Канал нотификаций** для алерта Cloud Monitoring (Telegram, ntfy,
|
||||
email) — выбрать на этапе настройки.
|
||||
- **Тип executor** в act_runner — docker (по умолчанию) или host. Ходим
|
||||
через docker, host-executor пока не обсуждается.
|
||||
- **Webhook на pull request** — нужно или только push? По умолчанию
|
||||
только push. Расширим, если возникнет PR-flow.
|
||||
- **Перенос ВМ в отдельный фолдер** — когда в общем появится вторая
|
||||
ВМ. Пока не критично.
|
||||
@@ -0,0 +1,692 @@
|
||||
# Журнал миграции в Timeweb
|
||||
|
||||
Хронология фактического переезда. План и архитектурные решения —
|
||||
в [timeweb.md](timeweb.md). Здесь только то, что реально сделано,
|
||||
с датами.
|
||||
|
||||
Новые записи — сверху.
|
||||
|
||||
---
|
||||
|
||||
## Шаг 14 — VM в YC остановлена (2026-05-23, выполнено)
|
||||
|
||||
Через несколько часов после cutover'а — выключил VM `rivendell-v2` в
|
||||
панели Yandex Cloud (stop, не delete). Источник перешёл в состояние
|
||||
«холодного запасного».
|
||||
|
||||
Формально план рекомендовал держать источник в живых ≥24 часа перед
|
||||
остановкой (`timeweb.md:464`), но:
|
||||
|
||||
- docker и cron на источнике остановлены и `disable`нуты ещё на
|
||||
Шаге 11 — VM работала вхолостую.
|
||||
- Ключевые приложения проверены в браузере на target (см. Шаг 13).
|
||||
- **Stop, не destroy** — состояние VM и диск сохраняются, при
|
||||
необходимости отката достаточно `Start` в панели + `systemctl
|
||||
enable --now docker cron` + откат DNS. Прирост к рекавери ~1-2 мин
|
||||
по сравнению со running idle.
|
||||
|
||||
Compute снят со счёта (Timeweb-VM теперь единственный источник
|
||||
расходов). S3-бакет с restic-бэкапами и Container Registry в YC
|
||||
**не трогаем** — продолжают использоваться с Timeweb.
|
||||
|
||||
### Что осталось
|
||||
|
||||
Через неделю-две, если ничего не всплыло:
|
||||
|
||||
- Удалить VM `rivendell-v2` и связанные compute-ресурсы (только
|
||||
compute! S3 и CR — оставляем).
|
||||
- Удалить `production.yml`, переименовать `timeweb.yml` →
|
||||
`production.yml`, откатить `HOSTS_FILE` в `tasks.py`. Закоммитить.
|
||||
- Перенести `timeweb.md` и `timeweb-migration-log.md` из
|
||||
`docs/drafts/` куда-нибудь в архив или удалить — план выполнен,
|
||||
журнал теряет актуальность.
|
||||
|
||||
---
|
||||
|
||||
## Шаг 13 — приложения подняты на target, cutover завершён (2026-05-23, выполнено)
|
||||
|
||||
После rsync'а (Шаг 12) — финальный прогон ансибла без `--skip-tags`,
|
||||
поэтапно по приложениям. К ~16:30 DNS уже указывал на target (Шаг
|
||||
переключения 15:45 + TTL 20 мин, пропагация подтверждена в 16:20),
|
||||
так что Caddy при старте сразу пошёл за LE-сертификатами без задержек.
|
||||
|
||||
Прогоны делал поштучно через `inv pl -- <app>` (после Шага
|
||||
переключения `HOSTS_FILE = "timeweb.yml"` в `tasks.py`), не всем
|
||||
сразу — чтобы видеть каждый плейбук чисто.
|
||||
|
||||
### Что подтверждено работающим в браузере
|
||||
|
||||
- `vakhrushev.me` — homepage отдаёт страницу.
|
||||
- `auth.vakhrushev.me` — Authelia, логин работает.
|
||||
- `matrix.vakhrushev.me` — Tuwunel поднялся, Element подключается.
|
||||
- `git.vakhrushev.me` — Gitea, репозитории и issue tracker на месте.
|
||||
- `outline.vakhrushev.me` — документы видны.
|
||||
- `gramps.vakhrushev.me` — генеалогическое дерево открывается.
|
||||
- `wakapi.vakhrushev.me` — статистика времени видна.
|
||||
- `status.vakhrushev.me` — Netdata собирает и рисует метрики.
|
||||
|
||||
Точечно зашёл в outline / gramps / wakapi / gitea — данные на месте,
|
||||
ничего не потерялось при rsync'е.
|
||||
|
||||
### Отложенные на «потом по ходу дела» проверки
|
||||
|
||||
- `miniflux`, `memos`, `remembos`, `wanderer`, `calibre`, `rssbridge`,
|
||||
`dozzle`, `goaccess` — открыть и убедиться, что отдают свои данные.
|
||||
- **SMTP-test** — reset-password из gitea/authelia. Проверит, что
|
||||
Postbox после разблокировки в панели Timeweb принимает наши письма.
|
||||
- **Backup-cron в 1:00** — самый поздний smoke-тест системы. Покажет,
|
||||
что `backup-all.py` отработал на target, restic пишет в S3 с новым
|
||||
`host_name`, apprise шлёт уведомление.
|
||||
- `docker pull cr.yandex/...` руками — повторная проверка
|
||||
OAuth-аутентификации.
|
||||
|
||||
### Отклонения от плана сегодня
|
||||
|
||||
1. **VPS пересоздан в СПб** (Шаг 8) — первая выдача попала на
|
||||
гипервизор с битой сетью.
|
||||
2. **Docker Hub rate limit** на pull'е netdata — anonymous лимит
|
||||
подсети Timeweb уже выбран соседями. Лечится ручным
|
||||
`sudo docker login` на target (через free-аккаунт + PAT).
|
||||
**Backlog:** добавить `community.docker.docker_login` для
|
||||
`docker.io` в `playbook-docker.yml`, по аналогии с cr.yandex (Шаг
|
||||
3). Креды в vault как `dockerhub_username` / `dockerhub_token`.
|
||||
3. **Postbox SMTP не доступен извне YC** — оказалось, что в плане
|
||||
(`timeweb.md:81`) предпосылка «Postbox доступен извне YC по тем же
|
||||
credentials» неверна. Yandex Cloud Postbox дропает SMTP от не-YC
|
||||
источников; 443 при этом отвечает. Дополнительно Timeweb по
|
||||
умолчанию **сам** блокирует egress SMTP (25/465/587) — toggle в
|
||||
панели Timeweb снимает блок, после чего Postbox отвечает баннером.
|
||||
Authelia в exit-loop'е поднялась после рестарта. Запись в auto-
|
||||
memory `project_timeweb_smtp_block.md` — пригодится при следующих
|
||||
миграциях.
|
||||
4. **Bug ordering в `playbook-goaccess.yml`** (см. Шаг 9, фикс
|
||||
зашит) — латентный bug, проявившийся только на чистой машине.
|
||||
|
||||
### Что осталось до полной заморозки
|
||||
|
||||
По плану (`timeweb.md:464-473`):
|
||||
|
||||
- **≥ 24 часа** держим источник в выключенном состоянии (docker уже
|
||||
остановлен, daemon отключён через `disable`), как горячее запасное.
|
||||
- Если за сутки ничего не всплыло — выключить VM в YC.
|
||||
- Подождать ещё неделю-две — на всякий случай.
|
||||
- Удалить VM и связанные compute-ресурсы. **S3-бакет с
|
||||
restic-бэкапами и Container Registry — оставляем**, они продолжают
|
||||
использоваться.
|
||||
- Удалить `production.yml`, переименовать `timeweb.yml` →
|
||||
`production.yml`, откатить `HOSTS_FILE = "production.yml"` в
|
||||
`tasks.py`. Закоммитить.
|
||||
|
||||
---
|
||||
|
||||
## Шаг 12 — rsync данных с источника на target (2026-05-23, выполнено)
|
||||
|
||||
Перенос `/mnt/applications/` на YC → `/srv/applications/` на Timeweb
|
||||
после заморозки источника (Шаг 11). Это финальный канал переноса
|
||||
данных — основной для всех приложений, единственный для `caddyproxy`,
|
||||
`remembos`, `transcriber` (у которых нет backup-механизма, см. Шаг 7b).
|
||||
|
||||
### Пилотный прогон на remembos
|
||||
|
||||
Прежде чем гнать всё дерево, проверил рецепт на самом маленьком
|
||||
приложении (~35 КБ всего):
|
||||
|
||||
```bash
|
||||
sudo -E rsync -aAX --info=progress2 --delete --rsync-path="sudo rsync" \
|
||||
-e "ssh -o StrictHostKeyChecking=accept-new" \
|
||||
major@158.160.46.255:/mnt/applications/remembos/ \
|
||||
/srv/applications/remembos/
|
||||
```
|
||||
|
||||
Проверка после прогона:
|
||||
|
||||
```
|
||||
$ sudo ls -la /srv/applications/remembos/
|
||||
drwxr-x--- 4 remembos remembos 4096 Apr 30 13:22 .
|
||||
drwxr-x--- 2 remembos remembos 4096 Feb 12 17:22 config
|
||||
drwxr-x--- 2 remembos remembos 4096 May 23 12:41 data
|
||||
-rw-r----- 1 remembos remembos 494 Apr 30 13:22 docker-compose.yml
|
||||
```
|
||||
|
||||
Owner отрисован именами (`remembos:remembos`, не numeric `1103:1103`)
|
||||
— значит на обеих сторонах ансибл создал юзера с одним и тем же uid,
|
||||
mapping сошёлся. Mode (750) и mtime сохранены.
|
||||
|
||||
### Засада с agent-forwarding'ом под sudo
|
||||
|
||||
Первая попытка упала с `Permission denied (publickey)`. Причина:
|
||||
rsync запускается через `sudo` на target, а sudo по дефолту чистит
|
||||
`SSH_AUTH_SOCK` из env (`Defaults env_reset` в /etc/sudoers) — ssh
|
||||
внутри sudo не видит проброшенный agent, пытается парольную
|
||||
аутентификацию, проваливается.
|
||||
|
||||
Лечится разрешением sudo проносить именно эту переменную:
|
||||
|
||||
```bash
|
||||
echo 'Defaults env_keep += "SSH_AUTH_SOCK"' | sudo tee -a /etc/sudoers.d/major
|
||||
sudo visudo -cf /etc/sudoers.d/major
|
||||
```
|
||||
|
||||
Безопасно: сокет агента принадлежит `major`, root к нему имеет доступ
|
||||
по определению; мы просто говорим sudo не вычищать переменную с путём
|
||||
к нему. После этого `sudo -E rsync …` отрабатывает.
|
||||
|
||||
### Полный прогон по всем приложениям
|
||||
|
||||
```bash
|
||||
sudo -E rsync -aAX --info=progress2 --delete --exclude='lost+found' \
|
||||
--rsync-path="sudo rsync" \
|
||||
-e "ssh -o StrictHostKeyChecking=accept-new" \
|
||||
major@158.160.46.255:/mnt/applications/ \
|
||||
/srv/applications/
|
||||
```
|
||||
|
||||
### Что делает каждый флаг
|
||||
|
||||
- **`sudo -E`** — локальный rsync на target запускается под root
|
||||
(нужно, чтобы писать файлы с любым owner'ом / mode); `-E` сохраняет
|
||||
env, в первую очередь `SSH_AUTH_SOCK` для agent forwarding.
|
||||
- **`-a`** (`--archive`) — собирательный флаг `-rlptgoD`: recursive +
|
||||
symlinks как symlinks + permissions + times + group + owner +
|
||||
special files. Базовое «копировать всё как есть».
|
||||
- **`-A`** — сохранить POSIX ACL.
|
||||
- **`-X`** — сохранить extended attributes (xattrs), включая
|
||||
security-атрибуты типа capabilities или SELinux-меток.
|
||||
- **`--info=progress2`** — совокупный прогресс по всему transfer'у,
|
||||
а не per-file (для больших деревьев читабельнее).
|
||||
- **`--delete`** — стереть на target всё, чего нет на источнике.
|
||||
Безопасно в нашем случае: после rsync'а прогоняем ансибл, он
|
||||
перерендерит конфиги и пересоздаст любые отсутствующие структурные
|
||||
каталоги. Стирается, по сути, только содержимое, отрендеренное
|
||||
плейбуком на Шаге 9 без `run-app`.
|
||||
- **`--exclude='lost+found'`** — на YC `/mnt/applications/` это mount
|
||||
point внешнего диска, в его корне может лежать системный
|
||||
`lost+found`. Нам он не нужен и на target такого монтирования
|
||||
больше нет (`mount_external_storage: false`).
|
||||
- **`--rsync-path="sudo rsync"`** — критично: на удалённой стороне
|
||||
(источнике) rsync запускается через sudo. Иначе он стартует под
|
||||
`major`, у которого нет прав читать чужие `/mnt/applications/<app>/`
|
||||
(mode 750, owner — приложение). У `major` на источнике NOPASSWD
|
||||
sudo, так что sudo прокатывает молча.
|
||||
- **`-e "ssh -o StrictHostKeyChecking=accept-new"`** — кастомная
|
||||
команда транспорта. По умолчанию rsync запускает чистый `ssh`; мы
|
||||
добавляем флаг для автопринятия host key источника (на target
|
||||
`known_hosts` ещё пустой).
|
||||
- **`major@158.160.46.255:/mnt/applications/`** — источник. Trailing
|
||||
slash важен: «копировать содержимое каталога», а не сам каталог.
|
||||
Без слэша получили бы `/srv/applications/applications/...`.
|
||||
- **`/srv/applications/`** — назначение. Trailing slash для
|
||||
симметрии — содержимое кладётся в существующий каталог,
|
||||
созданный ансиблом на Шаге 9.
|
||||
|
||||
### Результат
|
||||
|
||||
```
|
||||
22,613,081,829 99% 7.11MB/s 0:50:34 (xfr#21837, to-chk=0/31024)
|
||||
```
|
||||
|
||||
- Объём — ~22.6 ГБ, файлов — 31 024.
|
||||
- Длительность — 50 минут 34 секунды, средняя скорость ~7 МБ/с
|
||||
(предсказуемо для YC↔Timeweb).
|
||||
- `du -s` после прогона: источник 22 088 224 КБ, target 22 164 172 КБ
|
||||
— разница ~76 МБ (0.34%). Это не рассинхрон данных, а разница в
|
||||
аллокации блоков ФС и метаданных между источником и target (разные
|
||||
inode-таблицы, journal, group descriptors). Содержимое файлов
|
||||
совпадает — rsync'у на это указали checksum'ы, errors не было.
|
||||
|
||||
Окно даунтайма с момента стопа docker'а (Шаг 11) до конца rsync'а —
|
||||
около часа. С учётом параллельно запущенного DNS-переключения
|
||||
(Шаг между 11 и 12, 15:45) к моменту запуска приложений на target
|
||||
пропагация уже прошла (16:20).
|
||||
|
||||
---
|
||||
|
||||
## Шаг 11 — источник заморожен (docker + cron остановлены) (2026-05-23, выполнено)
|
||||
|
||||
Сразу после финального бэкапа (Шаг 10) — отключил docker и cron на
|
||||
источнике, чтобы зафиксировать состояние данных перед rsync'ом и
|
||||
исключить случайные записи в `/mnt/applications/` во время переноса.
|
||||
|
||||
```bash
|
||||
sudo systemctl stop docker.service docker.socket
|
||||
sudo systemctl disable docker.service docker.socket
|
||||
sudo systemctl stop cron
|
||||
```
|
||||
|
||||
`disable` — страховка от автостарта docker'а при возможной
|
||||
перезагрузке источника (если вернёмся для отката или проверки).
|
||||
`cron stop` — чтобы ночной `backup-all.py` не запустился впустую без
|
||||
работающего daemon'а.
|
||||
|
||||
С этого момента источник «мёртв» для пользователей — окно даунтайма
|
||||
открыто. Следующий шаг — переключить DNS и параллельно гнать rsync.
|
||||
|
||||
---
|
||||
|
||||
## Шаг 10 — финальный бэкап на источнике (2026-05-23, выполнено)
|
||||
|
||||
Прогнал `backup-all.py` на источнике, пока docker ещё жив (он нужен
|
||||
для `pg_dump` и других in-container backup-команд внутри
|
||||
`backup.sh`-скриптов отдельных приложений).
|
||||
|
||||
```bash
|
||||
sudo /usr/local/sbin/backup-all.py 2>&1 | tee /tmp/final-backup.log
|
||||
```
|
||||
|
||||
Свежий restic-снапшот в `yandex_cloud_s3` зафиксирован — страховочный
|
||||
канал на случай, если rsync пойдёт криво (для приложений с
|
||||
`backup.sh` можно будет восстановить из S3; для `caddyproxy`,
|
||||
`remembos`, `transcriber` страховки нет, для них только rsync).
|
||||
|
||||
После прогона можно гасить docker без риска потерять backup-окно.
|
||||
|
||||
---
|
||||
|
||||
## Шаг 9 — раскатана база и приложения без запуска (2026-05-23, выполнено)
|
||||
|
||||
На свежей Timeweb-машине прогнаны два плейбука без даунтайма источника
|
||||
(контейнеры на target не запускались).
|
||||
|
||||
### 9a. Системная база
|
||||
|
||||
```bash
|
||||
uv run ansible -i timeweb.yml -m ping server # pong
|
||||
uv run ansible-playbook -i timeweb.yml --diff playbook-all-setup.yml
|
||||
```
|
||||
|
||||
После прогона на target поднято: apt-пакеты (`geerlingguy.security`),
|
||||
docker + сети (`web_proxy_network`, `monitoring_network`), eget с
|
||||
инструментами (restic, rclone, btop, zellij и др.), ufw (порты 22,
|
||||
2222, 80, 443), fail2ban, backup-инфра (`backup-all.py`,
|
||||
resticprofile, cron).
|
||||
|
||||
Заодно `geerlingguy.security` отключил root по SSH и
|
||||
`PasswordAuthentication` — root-канал закрыт, доступ только через
|
||||
`major` + ключ. Перепроверено `ssh major@<новый-ip>` — работает.
|
||||
|
||||
### 9b. Application-плейбуки без запуска контейнеров
|
||||
|
||||
```bash
|
||||
uv run ansible-playbook -i timeweb.yml --diff \
|
||||
--skip-tags run-app \
|
||||
playbook-all-applications.yml
|
||||
```
|
||||
|
||||
На target созданы все `<app>`-пользователи с правильными uid/gid
|
||||
(совпадают с источником, см. таблицу в [плане](timeweb.md)), каталоги
|
||||
`/srv/applications/<app>/{data,config,backups}`, отрендерены
|
||||
`docker-compose.yml` и application-конфиги. Контейнеры **не**
|
||||
запускались — это шаг 5 cutover'а (после rsync'а данных).
|
||||
|
||||
OAuth-аутентификация в `cr.yandex` (из Шага 3) сработала с
|
||||
Timeweb-айпишника без замечаний — `community.docker.docker_login` в
|
||||
плейбуках homepage и transcriber прошёл.
|
||||
|
||||
### Обнаруженный латентный bug ordering'а в goaccess
|
||||
|
||||
На fresh-install упала задача
|
||||
`playbook-goaccess.yml:55 «Ensure caddy access log exists before
|
||||
goaccess starts»` — пыталась туч'ить файл в `/var/log/caddy/`, который
|
||||
к этому моменту не существовал. Причина: каталог создаётся в
|
||||
`playbook-caddyproxy.yml`, а в `playbook-all-applications.yml`
|
||||
goaccess идёт **раньше** caddyproxy (caddyproxy специально последний,
|
||||
чтобы стартовать после backends). На предыдущем сервере не проявлялось
|
||||
— каталог уже существовал от прошлых прогонов.
|
||||
|
||||
Фикс: добавил в `playbook-goaccess.yml` явное создание
|
||||
`caddy_logs_dir` перед touch'ем `access.log`. Owner/mode выставит
|
||||
caddyproxy при своём прогоне, идемпотентность сохранена.
|
||||
|
||||
**Backlog (после миграции):** `caddy_logs_dir` — shared-ресурс между
|
||||
плеями (caddyproxy пишет, goaccess читает), концептуально это
|
||||
provisioning-time забота. Вынести его создание в `playbook-system.yml`
|
||||
(или в отдельный shared-resources плей в `playbook-all-setup.yml`) и
|
||||
убрать дубль из goaccess/caddyproxy. Делать после переезда отдельным
|
||||
PR, не во время миграции.
|
||||
|
||||
---
|
||||
|
||||
## Шаг 8 — VPS заказан, пользователь `major` создан (2026-05-23, выполнено)
|
||||
|
||||
Заказан Cloud VPS в Timeweb по тарифу из плана (4 × 3.3 ГГц, 8 ГБ RAM,
|
||||
80 ГБ NVMe, Ubuntu 24.04 LTS), ДЦ Санкт-Петербург.
|
||||
|
||||
Первая выданная VPS попала на гипервизор с битой сетью: TCP-handshake
|
||||
проходил нормально, но первый data-сегмент в любой TCP-сессии не
|
||||
доставлялся ни в одну сторону. Подтверждено:
|
||||
|
||||
- `nc -l 12345` на сервере не получал данные от клиента, при этом
|
||||
клиент видел `Connection succeeded`;
|
||||
- strace зависшего `sshd: [accepted]`-child показывал
|
||||
`read(socket, ..., 1) = ERESTARTSYS`, далее `SIGALRM` через 120 сек
|
||||
по `LoginGraceTime` → exit (т.е. sshd ушёл в `read()` за клиентским
|
||||
баннером и не дождался);
|
||||
- `iptables -S` / `nft list ruleset` / `ufw status` — пусто, локального
|
||||
firewall нет;
|
||||
- исходящие соединения с VM (`curl http://example.com`) работали
|
||||
штатно — ломались только входящие data-сегменты после handshake.
|
||||
|
||||
Ребут и переустановка ОС из панели не помогли. Пересоздал VPS в ДЦ СПб
|
||||
с новым IP — заработало с первой попытки. Потеря времени ~1 час; на
|
||||
будущее: при таком паттерне сразу пересоздаём в другом ДЦ, глубже
|
||||
диагностику не ведём (это однозначно проблема сети провайдера).
|
||||
|
||||
### Bootstrap пользователя `major`
|
||||
|
||||
На свежей VPS только root по SSH-ключу. Поднял пользователя
|
||||
аналогично YC-серверу — sudo через NOPASSWD, вход только по ключу.
|
||||
Дальше `geerlingguy.security` + `roles/owner` пересоздадут пользователя
|
||||
идемпотентно с теми же uid/gid и приклеят политику sshd при первом
|
||||
прогоне ансибла.
|
||||
|
||||
```bash
|
||||
# 1. Создать пользователя с home и bash, добавить в sudo
|
||||
useradd -m -s /bin/bash major
|
||||
usermod -aG sudo major
|
||||
|
||||
# 2. NOPASSWD-политика sudo
|
||||
echo 'major ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/major
|
||||
chmod 0440 /etc/sudoers.d/major
|
||||
visudo -cf /etc/sudoers.d/major # должно сказать "parsed OK"
|
||||
|
||||
# 3. SSH-ключ (тот же, что залит для root при создании VPS)
|
||||
install -d -m 700 -o major -g major /home/major/.ssh
|
||||
install -m 600 -o major -g major \
|
||||
/root/.ssh/authorized_keys \
|
||||
/home/major/.ssh/authorized_keys
|
||||
```
|
||||
|
||||
Проверка с локальной машины:
|
||||
|
||||
```bash
|
||||
ssh major@<новый-ip>
|
||||
sudo whoami # root, без пароля
|
||||
```
|
||||
|
||||
Прошло. Root-доступ по SSH пока оставлен как резервный канал — первый
|
||||
прогон ансибла отключит его через `geerlingguy.security`
|
||||
(`PermitRootLogin no`, `PasswordAuthentication no`).
|
||||
|
||||
---
|
||||
|
||||
## Шаг 7 — `run-app` тег + унификация registry (2026-05-22, выполнено)
|
||||
|
||||
По итогам аудита подготовительных задач выявлены и закрыты две
|
||||
несостыковки:
|
||||
|
||||
### 7a. Пропущенный `run-app` тег в remembos
|
||||
|
||||
В `playbook-remembos.yml:73` была задача
|
||||
`Restart docker compose services if config changed but not
|
||||
docker-compose.yml` (условный рестарт через `state: restarted`,
|
||||
триггер — изменение `config.toml` без изменения `docker-compose.yml`),
|
||||
у неё не было тега `run-app`. На cutover'е при
|
||||
`--skip-tags run-app` основной запуск пропустился бы (правильно), а
|
||||
эта условная задача всё равно сработала бы (потому что её `when:`
|
||||
истинно при первом деплое — конфиг создаётся), попыталась бы
|
||||
рестартануть несуществующий compose-стек и упала. Тег добавлен.
|
||||
|
||||
### 7b. Унификация `registry_url` в docker_login
|
||||
|
||||
`playbook-homepage.yml` и `playbook-transcriber.yml` использовали
|
||||
хардкод `registry_url: "cr.yandex"`, а `playbook-remembos.yml` —
|
||||
`'{{ yc_container_registry }}'` из vault. Привёл к одному виду:
|
||||
теперь во всех трёх — `"{{ yc_container_registry }}"` из vault.
|
||||
|
||||
`docker_registry_prefix` в `vars/homepage.yml` и `vars/transcriber.yml`
|
||||
не трогал — там полный image-prefix вида `cr.yandex/<org-id>`,
|
||||
это отдельная концепция (есть отдельный vault-var
|
||||
`yc_container_registry_repository`, используемый в
|
||||
`files/remembos/docker-compose.template.yml`). Если позже захочется
|
||||
унифицировать целиком — это отдельная итерация.
|
||||
|
||||
### Аудит бэкапов: gap'ы по `caddyproxy`, `remembos`, `transcriber`
|
||||
|
||||
Эти три приложения имеют состояние в `data_dir`, но не имеют ни
|
||||
`backup.template.sh`, ни ansible-генерируемого `backup-targets`.
|
||||
Для миграции это закрывается через **rsync** на cutover'е — данные
|
||||
переносятся напрямую, без зависимости от restic-снапшотов:
|
||||
|
||||
- `caddyproxy/data/` — TLS-сертификаты Let's Encrypt (важно, чтобы
|
||||
не упереться в rate-limit LE при перевыпуске ~17 сертов).
|
||||
- `remembos/data/` — user data (memos-токен, telegram tokens).
|
||||
- `transcriber/data/` — пользовательские транскрипции.
|
||||
|
||||
Это означает: на этапе rsync (шаг 4 cutover'а в плане) **нельзя**
|
||||
полагаться только на restic-restore — для этих трёх апов rsync —
|
||||
единственный канал. Для остальных приложений (которые имеют
|
||||
`backup.sh` или `backup-targets`) можно при необходимости использовать
|
||||
restic как фолбэк, но rsync всё равно остаётся основным методом.
|
||||
|
||||
Долгосрочно — добавить им backup-механизм отдельной итерацией после
|
||||
миграции. Сейчас это сверх сферы.
|
||||
|
||||
---
|
||||
|
||||
## Шаг 6 — `vars/vars.yml` загружается во всех плейбуках (2026-05-22, выполнено)
|
||||
|
||||
Сегодняшний коммит `8378f0e` («Migration: expose some public vars»)
|
||||
вынес общие переменные (`application_dir`, `host_name`, `primary_user`,
|
||||
`primary_user_uid`, `primary_user_gid`, `bin_prefix`,
|
||||
`apprise_external_port`, `apprise_external_url`, `caddy_logs_dir`) из
|
||||
vault в `vars/vars.yml`. Но большая часть плейбуков загружала только
|
||||
`vars/secrets.yml` — на текущем сервере они работали лишь потому, что
|
||||
inventory дублирует `application_dir` как override. На чистом
|
||||
Timeweb-инвентаре без override они бы упали с undefined.
|
||||
|
||||
Прошёлся по всем плейбукам, добавил `- vars/vars.yml` сразу после
|
||||
`- vars/secrets.yml`:
|
||||
|
||||
```
|
||||
playbook-authelia.yml playbook-netdata.yml
|
||||
playbook-calibre.yml playbook-outline.yml
|
||||
playbook-docker.yml playbook-remembos.yml
|
||||
playbook-dozzle.yml playbook-rssbridge.yml
|
||||
playbook-eget.yml playbook-transcriber.yml
|
||||
playbook-gitea.yml playbook-transcriber-registry.yml
|
||||
playbook-gramps.yml playbook-tuwunel.yml
|
||||
playbook-homepage.yml playbook-ufw.yml
|
||||
playbook-homepage-registry.yml playbook-upgrade.yml
|
||||
playbook-memos.yml playbook-wakapi.yml
|
||||
playbook-miniflux.yml playbook-wanderer.yml
|
||||
```
|
||||
|
||||
(21 файл — все «обычные» плейбуки, которые ещё не подключали vars.yml.)
|
||||
|
||||
Aggregator'ы `playbook-all-applications.yml` и `playbook-all-setup.yml`
|
||||
не трогал — у них нет собственных `vars_files`, они используют
|
||||
`import_playbook`, каждый импортируемый плейбук уже сам подключает
|
||||
`vars.yml`.
|
||||
|
||||
`yamllint` чист. Идемпотентность проверить отдельным прогоном.
|
||||
|
||||
Проверить прогоном `inv pl -- all-applications` (или хотя бы
|
||||
`inv pl -- gitea outline miniflux`) на текущем сервере — diff
|
||||
ожидается пустой.
|
||||
|
||||
---
|
||||
|
||||
## Шаг 5 — переезд default application_dir на /srv (2026-05-22, выполнено)
|
||||
|
||||
`/mnt` по FHS — место для точек монтирования внешних дисков; на
|
||||
системном диске Timeweb (фаза 1) это семантически неверно. Поменяли
|
||||
дефолт на `/srv/applications` (FHS: «data for services provided by
|
||||
this system»), для текущего YC-сервера сделали override в инвентаре.
|
||||
|
||||
Изменения:
|
||||
|
||||
- `vars/vars.yml` — `application_dir: "/srv/applications"`
|
||||
(комментарий обновлён).
|
||||
- `production.yml` — у хоста `server` добавлен override
|
||||
`application_dir: "/mnt/applications"`.
|
||||
- `playbook-system.yml` — добавлен `vars/vars.yml` в `vars_files`,
|
||||
захардкоженный `/mnt/applications` в задачах
|
||||
`Create directory for mount` и `Mount external storages` заменён
|
||||
на `{{ application_dir }}`.
|
||||
- `playbook-remove-user-and-app.yml` — то же самое (`vars/vars.yml`
|
||||
в `vars_files` + `{{ (application_dir, user_name) | path_join }}`).
|
||||
- `tasks.py` — новый helper `_application_dir()` читает значение
|
||||
сначала из inventory (override), затем из `vars/vars.yml`. `login_as_app`
|
||||
больше не содержит `/mnt/applications`.
|
||||
|
||||
Что остаётся хардкодом — только `/mnt/applications` в `production.yml`
|
||||
как override, и это правильно.
|
||||
|
||||
На Timeweb-инвентаре (когда появится) можно либо не задавать
|
||||
`application_dir` вовсе (применится дефолт `/srv/applications`), либо
|
||||
задать явно — для читаемости.
|
||||
|
||||
Проверить прогоном `inv pl -- system` на текущем сервере (Yandex
|
||||
Cloud) — ничего не должно поменяться, потому что inventory override
|
||||
возвращает `/mnt/applications` и mount всё ещё включён. Diff ожидается
|
||||
пустой.
|
||||
|
||||
### Восстановление restic-снапшотов после смены путей
|
||||
|
||||
Старые снапшоты записаны с путями `/mnt/applications/<app>`. На
|
||||
Timeweb данные должны лежать в `/srv/applications/<app>`. У restic
|
||||
нет встроенного «remap path» при restore, поэтому делается в два
|
||||
шага: восстановить во временный каталог, затем `rsync` на новое
|
||||
место с сохранением uid/gid (приложения уже созданы playbook'ом с
|
||||
теми же uid/gid, см. шаг про подготовку target).
|
||||
|
||||
Пример — восстановить gitea на Timeweb-машине:
|
||||
|
||||
```bash
|
||||
sudo /usr/local/sbin/restic-shell.sh
|
||||
|
||||
# Распакуем нужную поддиректорию во временный каталог
|
||||
restic restore latest \
|
||||
--target /tmp/restic-restore \
|
||||
--include /mnt/applications/gitea
|
||||
|
||||
# Перенесём данные на новый путь, сохранив владельца/группу/ACL/xattr
|
||||
sudo rsync -aAX --info=progress2 \
|
||||
/tmp/restic-restore/mnt/applications/gitea/ \
|
||||
/srv/applications/gitea/
|
||||
|
||||
sudo rm -rf /tmp/restic-restore
|
||||
```
|
||||
|
||||
Несколько приложений за один проход:
|
||||
|
||||
```bash
|
||||
restic restore latest \
|
||||
--target /tmp/restic-restore \
|
||||
--include /mnt/applications/gitea \
|
||||
--include /mnt/applications/outline \
|
||||
--include /mnt/applications/miniflux
|
||||
|
||||
for app in gitea outline miniflux; do
|
||||
sudo rsync -aAX --info=progress2 \
|
||||
"/tmp/restic-restore/mnt/applications/$app/" \
|
||||
"/srv/applications/$app/"
|
||||
done
|
||||
sudo rm -rf /tmp/restic-restore
|
||||
```
|
||||
|
||||
Альтернатива через `restic mount` (если не хочется промежуточной
|
||||
копии — данные мапятся как FUSE-FS):
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /mnt/restic-snapshots
|
||||
restic mount /mnt/restic-snapshots &
|
||||
sudo rsync -aAX \
|
||||
/mnt/restic-snapshots/snapshots/latest/mnt/applications/gitea/ \
|
||||
/srv/applications/gitea/
|
||||
sudo fusermount -u /mnt/restic-snapshots
|
||||
```
|
||||
|
||||
После переезда новые снапшоты будут записываться уже с путями
|
||||
`/srv/applications/<app>` — никаких трюков для текущих бэкапов не
|
||||
нужно.
|
||||
|
||||
---
|
||||
|
||||
## Шаг 4 — условное монтирование внешнего диска (2026-05-22, выполнено)
|
||||
|
||||
Задача `Mount external storages` в `playbook-system.yml` теперь
|
||||
выполняется только при включённом флаге `mount_external_storage`
|
||||
(default `false`). Сам UUID диска оставлен захардкоженным в
|
||||
плейбуке — параметризовать не стали, потому что для Timeweb (фаза 1)
|
||||
монтирование вообще не нужно, а для фазы 2 пока неизвестно, какой
|
||||
UUID получится у второго диска.
|
||||
|
||||
Изменения:
|
||||
|
||||
- `playbook-system.yml` — у задачи mount добавлен
|
||||
`when: mount_external_storage | default(false) | bool`.
|
||||
- `production.yml` (инвентарь YC) — у хоста `server` добавлен
|
||||
`mount_external_storage: true`, чтобы текущее поведение
|
||||
сохранилось.
|
||||
|
||||
В будущем `timeweb.yml` просто не будет задавать эту переменную —
|
||||
mount пропустится, `/mnt/applications` останется обычной директорией
|
||||
на системном диске.
|
||||
|
||||
На фазе 2 (подключение медленного диска в Timeweb) UUID в
|
||||
`playbook-system.yml` придётся поменять и включить флаг — это
|
||||
осознанный шаг, не автоматизировано.
|
||||
|
||||
Проверено прогоном `inv pl -- system` на текущем сервере (Yandex
|
||||
Cloud) — задача mount по-прежнему выполняется, `/mnt/applications`
|
||||
смонтирован, изменений нет.
|
||||
|
||||
---
|
||||
|
||||
## Шаг 3 — переключение auth на cr.yandex (2026-05-22, выполнено)
|
||||
|
||||
Заменена аутентификация в Yandex Container Registry с YC-metadata
|
||||
service на OAuth-token из vault.
|
||||
|
||||
Изменения:
|
||||
|
||||
- `files/yandex-docker-registry-auth.sh` — **удалён**.
|
||||
- `playbook-homepage.yml` — задача `ansible.builtin.script:
|
||||
yandex-docker-registry-auth.sh` заменена на
|
||||
`community.docker.docker_login` с `username: oauth`, `password:
|
||||
"{{ yc_oauth_token }}"`.
|
||||
- `playbook-transcriber.yml` — то же самое.
|
||||
|
||||
Локальные push-плейбуки (`playbook-homepage-registry.yml`,
|
||||
`playbook-transcriber-registry.yml`) не трогал — там нет auth-задачи
|
||||
в принципе, локальный docker аутентифицируется вручную
|
||||
(`yc container registry configure-docker` или `docker login`).
|
||||
Если позже захочется унифицировать — можно добавить тот же
|
||||
`docker_login` с `delegate_to: 127.0.0.1`.
|
||||
|
||||
Проверено прогоном `inv pl -- homepage` и `inv pl -- transcriber` на
|
||||
текущем сервере (Yandex Cloud) — ошибок нет, контейнеры работают.
|
||||
Значит и на Timeweb заработает (единственная разница — исходящий IP,
|
||||
а OAuth-токен в YC принимается извне).
|
||||
|
||||
---
|
||||
|
||||
## Шаг 2 — OAuth-token для cr.yandex (2026-05-22, выполнено)
|
||||
|
||||
В `vars/secrets.yml` добавлена (или обновлена) переменная
|
||||
`yc_oauth_token` со свежим OAuth-токеном Яндекса. Токен будет
|
||||
использоваться для логина в `cr.yandex` с новой машины Timeweb
|
||||
(вместо текущего скрипта `files/yandex-docker-registry-auth.sh`,
|
||||
который завязан на YC metadata service `169.254.169.254` и
|
||||
работает только внутри YC).
|
||||
|
||||
Сам код переключения на `community.docker.docker_login` пока не
|
||||
вносится — это следующая итерация. Сейчас токен просто положен в
|
||||
vault, чтобы не делать этого в день cutover'а под прессом.
|
||||
|
||||
---
|
||||
|
||||
## Шаг 1 — снижение TTL DNS (2026-05-22, выполнено)
|
||||
|
||||
В админке Yandex 360 для зоны `vakhrushev.me` уменьшен TTL
|
||||
A-записей с **21 600 с (6 ч)** до **1 200 с (20 мин)**. Это даёт
|
||||
запас по времени на распространение изменений после смены IP в
|
||||
день cutover'а — старые кэширующие резолверы перестанут отдавать
|
||||
старый адрес максимум через 20 минут (вместо 6 часов).
|
||||
|
||||
Делается **заранее**, потому что само снижение TTL тоже
|
||||
распространяется по кэшам по правилам старого TTL — то есть после
|
||||
правки нужно подождать ≥ 6 часов, чтобы новое значение TTL само
|
||||
успело прижиться. Раньше cutover'а нужно сделать с большим
|
||||
запасом — день в день не сработает.
|
||||
@@ -0,0 +1,553 @@
|
||||
# Миграция сервера в Timeweb
|
||||
|
||||
## Контекст и цели
|
||||
|
||||
Сервер `rivendell-v2` переезжает с виртуальной машины в Yandex Cloud
|
||||
(`158.160.46.255`) на VPS в Timeweb.
|
||||
|
||||
### Причины переезда
|
||||
|
||||
1. **Высокая стоимость.** Тариф в Yandex Cloud обходится в ≈ 2 900 ₽/мес
|
||||
за конфигурацию, которая в Timeweb стоит ≈ 2 000 ₽/мес и при этом
|
||||
мощнее по всем параметрам (см. сравнение ниже).
|
||||
2. **Упор в потолок RAM.** Текущий сервер уже использует ≈ 80 %
|
||||
доступной памяти на штатной нагрузке (см.
|
||||
`project_server_specs`). Любой всплеск (миграции БД, индексация
|
||||
в Outline, бэкап с restic) — и приложения начинают конкурировать
|
||||
за память, появляются OOM-риски. Дальше расти на этом тарифе
|
||||
некуда без значительного увеличения цены.
|
||||
3. **Медленные диски.** Из-за высокой стоимости в YC приходится
|
||||
использовать дешёвый HDD-том вместо SSD/NVMe — это заметно
|
||||
снижает отзывчивость приложений (особенно Gitea, Outline,
|
||||
тёплый старт контейнеров, рестики check/forget). На Timeweb за
|
||||
меньшие деньги получаем NVMe.
|
||||
|
||||
Переезд решает все три проблемы одновременно: дешевле, больше
|
||||
RAM, быстрее диск.
|
||||
|
||||
### Сравнение тарифов
|
||||
|
||||
| Параметр | Yandex Cloud | Timeweb Cloud VPS |
|
||||
| -------------- | ----------------------------------------- | ------------------ |
|
||||
| CPU | Intel Cascade Lake, vCPU 2, гарантия 50 % | 4 × 3.3 ГГц |
|
||||
| RAM | 4 ГБ | 8 ГБ |
|
||||
| Диск | 120 ГБ HDD | 80 ГБ NVMe |
|
||||
| Публичный IP | да | да |
|
||||
| **Цена/месяц** | **2 887 ₽** | **1 980 ₽** |
|
||||
|
||||
Итого: **−907 ₽/мес (≈ −31 %)**, при этом **×2 RAM** (закрывает
|
||||
причину 2), **×2 ядер**, гарантия CPU 100 % вместо 50 %,
|
||||
**NVMe вместо HDD** (закрывает причину 3). Минус — диск меньше
|
||||
(80 ГБ против 120 ГБ HDD), что и стало основанием для фазы 2 с
|
||||
подключением второго «холодного» диска под крупные данные.
|
||||
|
||||
Переезжает **только compute** (VM с приложениями). Остальные сервисы
|
||||
Yandex Cloud остаются на месте и продолжают использоваться с новой
|
||||
машины:
|
||||
|
||||
- **Container Registry** — `cr.yandex/crplfk0168i4o8kd7ade` для образов
|
||||
`homepage-nginx` и `transcriber`.
|
||||
- **Object Storage (S3)** — restic-репозиторий `yandex_cloud_s3`.
|
||||
- **Postbox SMTP** — `postbox.cloud.yandex.net` (gitea, gramps, wakapi,
|
||||
outline, authelia, apprise).
|
||||
- **Yandex 360 / DNS-зона** `vakhrushev.me` — там же управляются записи
|
||||
и почтовый домен.
|
||||
|
||||
Параметры даунтайма — мягкие, это личная машина. Стратегия — «cold
|
||||
cutover»: остановить сервисы на источнике, раскатать ansible на
|
||||
target без запуска приложений, перенести данные с сохранением
|
||||
uid/gid, запустить сервисы на target, переключить DNS.
|
||||
|
||||
Конфигурация target — Cloud VPS Timeweb с одним диском **80 ГБ** на
|
||||
первой фазе. Позднее (отдельной фазой) будет подключён второй
|
||||
«медленный» диск под крупные данные (`calibre`, бэкапы, возможно
|
||||
`outline`).
|
||||
|
||||
---
|
||||
|
||||
> Фактическое выполнение переезда — в отдельном файле
|
||||
> [timeweb-migration-log.md](timeweb-migration-log.md). Здесь только
|
||||
> план и архитектурные решения.
|
||||
|
||||
---
|
||||
|
||||
## Инвентаризация YC-зависимостей в коде
|
||||
|
||||
| Компонент | Где | Что делать при переезде |
|
||||
| --- | --- | --- |
|
||||
| `production.yml` | `ansible_host: 158.160.46.255`, `ansible_user: major` | Заменить на новый IP/пользователя Timeweb |
|
||||
| `files/yandex-docker-registry-auth.sh` | Логин в `cr.yandex` через **YC metadata service** (`169.254.169.254`) | **Не работает вне YC.** Перейти на static OAuth-token / IAM-token (новый скрипт + секрет в vault) |
|
||||
| `playbook-system.yml` (mount-storage) | UUID `3942bffd-…` монтируется в `/mnt/applications` | Фаза 1: отключить mount или сделать UUID переменной vault. Фаза 2 (после подключения медленного диска): включить заново с новым UUID |
|
||||
| `files/backups/config.template.toml` | `[storage.yandex_cloud_s3]` + `AWS_*` ключи | **Не меняем.** Тот же бакет/ключи продолжают работать. Меняется только `host_name` (для подписи снапшотов и нотификаций) — он уже шаблонится |
|
||||
| SMTP (`postbox_host/port/user/pass`) | gitea, gramps, wakapi, outline, authelia, apprise | **Не меняем.** Postbox SMTP доступен извне YC по тем же credentials |
|
||||
| `files/backups/rclone.template.conf` (`pr86keedav`) | WebDAV-копия restic — внешний сервис | **Не меняем** |
|
||||
| Caddy `tls anwinged@ya.ru` | ACME | Не меняется, ACME перевыпустит сертификаты после смены IP |
|
||||
|
||||
Никаких других hardcoded YC-эндпоинтов в плейбуках / шаблонах нет —
|
||||
SSH, ufw, fail2ban, docker, eget, restic, Caddy полностью переносимы.
|
||||
|
||||
---
|
||||
|
||||
## UID / GID — критично для rsync
|
||||
|
||||
UID/GID каждого приложения зафиксированы в плейбуках и в
|
||||
`vars/homepage.yml` / `vars/transcriber.yml`. Роль `owner` создаёт
|
||||
группы и пользователей **с явно указанными gid/uid**
|
||||
(`roles/owner/tasks/main.yml`). Это значит:
|
||||
|
||||
- Если на новой машине **сначала** раскатать все плейбуки (без
|
||||
запуска приложений), пользователи получатся с теми же uid/gid.
|
||||
- Тогда `rsync -aAX` (с сохранением owner) корректно ляжет на target.
|
||||
- Дополнительный maping uid не нужен.
|
||||
|
||||
Список приложений с uid/gid (для сверки и для документации):
|
||||
|
||||
```
|
||||
caddyproxy 1010 / 1011
|
||||
authelia 1011 / 1012
|
||||
netdata 1012 / 1013
|
||||
miniflux 1013 / 1014
|
||||
rssbridge 1014 / 1015
|
||||
wakapi 1015 / 1016
|
||||
dozzle 1016 / 1017
|
||||
transcriber 1017 / 1018
|
||||
wanderer 1018 / 1019
|
||||
memos 1019 / 1020
|
||||
gitea 1005 / 1006
|
||||
outline 1007 / 1008
|
||||
homepage 1008 / 1009
|
||||
gramps 1009 / 1010
|
||||
calibre 1102 / 1102
|
||||
remembos 1103 / 1103
|
||||
apprise 1104 / 1104
|
||||
tuwunel 1105 / 1105
|
||||
goaccess 1106 / 1106
|
||||
```
|
||||
|
||||
(Возможные пересечения uid одного приложения и gid другого
|
||||
существуют, но Linux держит их в разных пространствах имён — не
|
||||
страшно.)
|
||||
|
||||
---
|
||||
|
||||
## Подготовка кода проекта
|
||||
|
||||
Делается **до** аренды Timeweb-машины, отдельным PR (или сериями
|
||||
коммитов на отдельной ветке). Цель — чтобы тот же ansible
|
||||
работал и на источнике, и на target без условных хаков.
|
||||
|
||||
### 1. Заменить YC-specific docker registry auth
|
||||
|
||||
`files/yandex-docker-registry-auth.sh` сейчас использует metadata
|
||||
service (`169.254.169.254`). Это работает только внутри YC VM,
|
||||
поэтому на Timeweb его надо заменить.
|
||||
|
||||
**Решение — OAuth-token Яндекса.** Простой и достаточный для
|
||||
домашнего сервера механизм:
|
||||
|
||||
1. Получить OAuth-token в кабинете Яндекса:
|
||||
<https://oauth.yandex.ru/authorize?response_type=token&client_id=1a6990aa636648e9b2ef855fa7bec2fb>
|
||||
(стандартный client_id для `yc` CLI, токен с правом доступа к
|
||||
Container Registry).
|
||||
2. Положить в `vars/secrets.yml` как `yc_oauth_token` (vault).
|
||||
3. Переписать `files/yandex-docker-registry-auth.sh` как шаблон
|
||||
(`.template.sh`) и рендерить через `ansible.builtin.template`
|
||||
вместо `script:`. Скрипт сводится к:
|
||||
```sh
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
echo "{{ yc_oauth_token }}" | \
|
||||
docker login --username oauth --password-stdin cr.yandex
|
||||
```
|
||||
Альтернатива — не рендерить, а передавать токен в скрипт
|
||||
аргументом или через переменную окружения, чтобы не светить его
|
||||
в системе.
|
||||
4. В `playbook-homepage.yml` и `playbook-transcriber.yml` поменять
|
||||
`ansible.builtin.script:` на `ansible.builtin.template:` +
|
||||
`ansible.builtin.command:` (либо использовать модуль
|
||||
`community.docker.docker_login` напрямую с `username: oauth`,
|
||||
`password: "{{ yc_oauth_token }}"` — это самый чистый вариант,
|
||||
тогда отдельный скрипт вообще не нужен).
|
||||
5. То же самое — для локальных push-плейбуков
|
||||
`playbook-homepage-registry.yml` и
|
||||
`playbook-transcriber-registry.yml`.
|
||||
|
||||
Рекомендую вариант с `community.docker.docker_login` — это убирает
|
||||
shell-скрипт целиком и сильно проще.
|
||||
|
||||
Минусы OAuth-token: токен живёт долго и даёт доступ ко всему
|
||||
аккаунту Яндекса. Для личного сервера приемлемо; если позже
|
||||
захочется минимизировать blast radius — заменить на IAM-key
|
||||
сервисного аккаунта (отдельная итерация после миграции).
|
||||
|
||||
Затронутые места: `files/yandex-docker-registry-auth.sh` (удалить
|
||||
или переписать), `playbook-homepage.yml`, `playbook-transcriber.yml`,
|
||||
`playbook-homepage-registry.yml`, `playbook-transcriber-registry.yml`,
|
||||
`vars/secrets.yml` (новый ключ `yc_oauth_token`).
|
||||
|
||||
### 2. Сделать опциональным монтирование внешнего диска
|
||||
|
||||
Сейчас `playbook-system.yml` жёстко монтирует UUID `3942bffd-…` в
|
||||
`/mnt/applications`. На Timeweb этого диска нет.
|
||||
|
||||
Минимальная правка — вытащить UUID в переменную (`storage_uuid`) и
|
||||
обернуть mount-задачу `when: storage_uuid is defined`. В
|
||||
`vars/secrets.yml` или `vars/vars.yml` для текущего сервера задать
|
||||
UUID, для Timeweb (фаза 1) — не задавать. На фазе 2 (когда придёт
|
||||
медленный диск) — задать новый UUID.
|
||||
|
||||
Альтернатива: вынести параметры в инвентарь
|
||||
(`production.yml` → `host_vars/server.yml`).
|
||||
|
||||
При этом сама директория `/mnt/applications` должна создаваться в
|
||||
любом случае — playbook уже это делает, надо лишь убедиться, что
|
||||
задача «Create directory for mount» не зависит от mount-задачи.
|
||||
|
||||
### 3. Параметризовать инвентарь
|
||||
|
||||
На время перехода — **два отдельных файла**: текущий
|
||||
`production.yml` остаётся как есть, рядом появляется новый
|
||||
`timeweb.yml` с настройками Timeweb-машины. Все ansible-команды
|
||||
во время миграции явно указывают `-i timeweb.yml`. После того, как
|
||||
переезд закончен и старая машина выключена — `production.yml`
|
||||
просто удаляется, `timeweb.yml` переименовывается в
|
||||
`production.yml`.
|
||||
|
||||
`tasks.py` использует `yq` для извлечения `ansible_host` / `ansible_user`
|
||||
из инвентаря (`_yq(".ungrouped.hosts.server…")`) — путь к файлу
|
||||
зашит константой `HOSTS_FILE = "production.yml"`. Варианты:
|
||||
|
||||
- На время миграции временно поменять `HOSTS_FILE = "timeweb.yml"`
|
||||
в локальном коммите (или через env override), потом откатить — после
|
||||
переименования всё снова работает.
|
||||
- Принять, что `inv ssh / zj / btop / login` работают только с
|
||||
активным сервером (тем, что в `production.yml`), а к старой
|
||||
машине во время миграции ходим напрямую через `ssh
|
||||
major@158.160.46.255`.
|
||||
|
||||
Первый вариант чище. Достаточно одной строчки правки.
|
||||
|
||||
### 4. Прочее
|
||||
|
||||
- `README.md` — обновить инструкцию по DNS и упомянуть Timeweb.
|
||||
- Удалить (или пометить deprecated) yandex-метаданные в комментариях
|
||||
`yandex-docker-registry-auth.sh`.
|
||||
- Проверить, что у всех application-плейбуков задача с
|
||||
`community.docker.docker_compose_v2: state: present` помечена
|
||||
тегом `run-app` — это позволит раскатывать `--skip-tags run-app`
|
||||
для подготовки target без запуска контейнеров. Сейчас тег `run-app`
|
||||
есть в большинстве плейбуков, но надо пройтись и убедиться, что
|
||||
он покрывает **все** контейнеры (включая calibre, dozzle,
|
||||
remembos, transcriber, tuwunel, wanderer, memos).
|
||||
|
||||
---
|
||||
|
||||
## Подготовка target-машины
|
||||
|
||||
1. Заказать Cloud VPS в Timeweb:
|
||||
- Ubuntu LTS (та же мажорная версия, что и сейчас — упростит
|
||||
совместимость пакетов).
|
||||
- 4 GB RAM (текущий лимит ≈ 3.8 GiB, см. `project_server_specs`),
|
||||
можно взять чуть с запасом — 4–6 GB, иначе netdata + tuwunel +
|
||||
outline начнут давить.
|
||||
- 2 vCPU.
|
||||
- SSD 80 ГБ.
|
||||
- Снять/настроить firewall провайдера (или отключить, т.к. у нас
|
||||
свой ufw).
|
||||
|
||||
2. Создать пользователя с правами sudo (аналог `major`), залить
|
||||
свой SSH-ключ.
|
||||
|
||||
3. Добавить хост в инвентарь как `server` (или временный
|
||||
`timeweb`), убедиться, что `ansible -m ping` отвечает.
|
||||
|
||||
4. Снизить TTL DNS-записей в Yandex 360 до 60–300 секунд **за
|
||||
~24–48 часов** до cutover.
|
||||
|
||||
---
|
||||
|
||||
## Cutover (план дня X)
|
||||
|
||||
Предусловия: код выкатан, target-машина пингуется по ansible, TTL
|
||||
DNS снижены.
|
||||
|
||||
### Шаг 1. Финальный бэкап на источнике
|
||||
|
||||
```bash
|
||||
inv ssh
|
||||
sudo /usr/local/sbin/backup-all.py 2>&1 | tee /tmp/final-backup.log
|
||||
```
|
||||
|
||||
Убедиться, что в логе все приложения отработали успешно и в S3
|
||||
появился свежий restic-snapshot (на случай отката или потери
|
||||
данных при rsync).
|
||||
|
||||
### Шаг 2. Остановить все приложения на источнике
|
||||
|
||||
Останавливаем docker-демон целиком — это атомарно гасит все
|
||||
контейнеры за один вызов, не зависит от текущего списка приложений
|
||||
и шлёт корректный SIGTERM (с грейс-периодом ~15 сек) каждому, что
|
||||
функционально эквивалентно `docker compose down` по всем стекам.
|
||||
|
||||
```bash
|
||||
inv ssh
|
||||
sudo systemctl stop docker.service docker.socket
|
||||
sudo systemctl disable docker.service docker.socket # страховка от автостарта при ребуте
|
||||
sudo systemctl stop cron # чтобы ночной backup-cron не побежал
|
||||
```
|
||||
|
||||
Финальный бэкап (шаг 1) **обязательно** должен пройти до этого
|
||||
момента — `backup-all.py` запускает скрипты приложений, которые
|
||||
делают `docker compose exec ... pg_dump ...`; без работающего
|
||||
daemon это сломается.
|
||||
|
||||
`disable` — страховка: если по какой-то причине старая машина
|
||||
перезагрузится во время rsync (или мы вернёмся на источник для
|
||||
проверки/отката), docker не поднимется автоматически и сервисы
|
||||
не начнут писать в данные, которые мы уже считаем «фиксированной
|
||||
копией». В случае отката — `enable` + `start` обратно.
|
||||
|
||||
Проверить, что `docker ps` сейчас отвечает «daemon not running»
|
||||
(или вернёт пустой список — зависит от того, как `inv ssh` пройдёт
|
||||
до/после стопа). Если нужно убедиться, что контейнеры реально
|
||||
ушли — `ps auxf | grep -E "containerd|docker" | grep -v grep`.
|
||||
|
||||
### Шаг 3. Раскатать инфраструктуру на target БЕЗ запуска приложений
|
||||
|
||||
```bash
|
||||
# 1) системная база
|
||||
uv run ansible-playbook -i timeweb.yml --diff playbook-all-setup.yml
|
||||
|
||||
# 2) приложения (создаём пользователей, каталоги, конфиги,
|
||||
# но НЕ запускаем контейнеры)
|
||||
uv run ansible-playbook -i timeweb.yml --diff \
|
||||
--skip-tags run-app \
|
||||
playbook-all-applications.yml
|
||||
```
|
||||
|
||||
Цель — после этого на target есть:
|
||||
|
||||
- Корректные uid/gid для всех приложений.
|
||||
- Каталоги `/srv/applications/<app>/{data,config,backups}` (на
|
||||
Timeweb дефолт изменён с `/mnt/applications`; см.
|
||||
[журнал шаг 5](timeweb-migration-log.md)).
|
||||
- Шаблоны `docker-compose.yml` и application-конфиги — отрендерены
|
||||
и лежат на месте.
|
||||
- Docker и сети созданы.
|
||||
- ufw настроен, fail2ban работает.
|
||||
|
||||
### Шаг 4. Перенос данных
|
||||
|
||||
Пути меняются: на YC данные лежат в `/mnt/applications/<app>`, на
|
||||
Timeweb — в `/srv/applications/<app>`. Rsync делает remap сам
|
||||
(потому что мы указываем источник и приёмник явно). Для трёх
|
||||
приложений без backup-механизма (`caddyproxy`, `remembos`,
|
||||
`transcriber`) rsync — **единственный** канал переноса, restic
|
||||
для них не альтернатива.
|
||||
|
||||
**Вариант A — rsync напрямую (основной путь).** С target-машины
|
||||
тянем данные со старой:
|
||||
|
||||
```bash
|
||||
sudo rsync -aAX --info=progress2 --delete \
|
||||
--exclude='lost+found' \
|
||||
major@158.160.46.255:/mnt/applications/ \
|
||||
/srv/applications/
|
||||
```
|
||||
|
||||
`-aAX` сохраняет ACL/xattrs и uid/gid (численные значения).
|
||||
Численные uid/gid на target совпадают с источником, потому что
|
||||
плейбуки на обеих машинах создают пользователей с одинаковыми
|
||||
явно заданными `app_owner_uid`/`gid`.
|
||||
|
||||
Каждое приложение можно тянуть отдельно — удобнее наблюдать
|
||||
прогресс и можно частично пересинхронизировать в случае ошибок:
|
||||
|
||||
```bash
|
||||
sudo rsync -aAX --info=progress2 --delete \
|
||||
major@158.160.46.255:/mnt/applications/gitea/ \
|
||||
/srv/applications/gitea/
|
||||
```
|
||||
|
||||
**Вариант B — restore из restic (страховка).** Если по сети
|
||||
источник недоступен или хочется проверить, что бэкапы вообще
|
||||
рабочие. Подробный пример (с учётом смены `/mnt` → `/srv`) — в
|
||||
[журнале миграции, шаг 5](timeweb-migration-log.md).
|
||||
|
||||
Для `caddyproxy`, `remembos`, `transcriber` использовать B
|
||||
**нельзя** — у них нет архивации, в restic-снапшоте данных просто
|
||||
нет. Только A.
|
||||
|
||||
Рекомендую **A как основной метод**, B держим как страховку
|
||||
для приложений, у которых есть восстановимый снапшот.
|
||||
|
||||
### Шаг 5. Запуск приложений на target
|
||||
|
||||
Раскатываем application-плейбуки ещё раз — теперь без `--skip-tags`:
|
||||
|
||||
```bash
|
||||
uv run ansible-playbook -i timeweb.yml --diff \
|
||||
playbook-all-applications.yml
|
||||
```
|
||||
|
||||
Этот же запуск проверит идемпотентность шаблонов (не должно быть
|
||||
diff'ов кроме docker-up).
|
||||
|
||||
После старта — проверить:
|
||||
|
||||
- `docker ps` — все контейнеры в healthy.
|
||||
- Локально (по IP) `curl http://<target-ip>` — Caddy отвечает (на
|
||||
редирект, т.к. сертификаты ещё не выпущены под этим IP).
|
||||
- Логи Caddy — выпуск сертификатов запустится после смены DNS, не
|
||||
раньше. Это нормально.
|
||||
|
||||
### Шаг 6. Переключение DNS
|
||||
|
||||
В Yandex 360 admin (`admin.yandex.ru/domains/vakhrushev.me`)
|
||||
поменять A-записи для всех subdomain'ов на новый IP. Перечень
|
||||
поддоменов (из `Caddyfile.template`):
|
||||
|
||||
```
|
||||
vakhrushev.me (apex)
|
||||
matrix.vakhrushev.me
|
||||
auth.vakhrushev.me
|
||||
status.vakhrushev.me
|
||||
git.vakhrushev.me
|
||||
outline.vakhrushev.me
|
||||
gramps.vakhrushev.me
|
||||
miniflux.vakhrushev.me
|
||||
wakapi.vakhrushev.me
|
||||
wanderer.vakhrushev.me
|
||||
memos.vakhrushev.me
|
||||
remembos.vakhrushev.me
|
||||
calibre.vakhrushev.me
|
||||
wanderbase.vakhrushev.me
|
||||
rssbridge.vakhrushev.me
|
||||
dozzle.vakhrushev.me
|
||||
goaccess.vakhrushev.me
|
||||
```
|
||||
|
||||
После смены — подождать пока TTL разойдётся, проверить через
|
||||
`dig +short <hostname>` с независимой машины.
|
||||
|
||||
Caddy сам пойдёт за сертификатами Let's Encrypt — следить за его
|
||||
логами (`docker logs caddyproxy_app -f`).
|
||||
|
||||
### Шаг 7. Проверка после cutover
|
||||
|
||||
Чеклист (примерно по приоритету):
|
||||
|
||||
- [ ] `vakhrushev.me` отвечает 200, отдаёт homepage.
|
||||
- [ ] `auth.vakhrushev.me` — Authelia, можно залогиниться.
|
||||
- [ ] `git.vakhrushev.me` — Gitea, репозитории на месте, ssh-доступ
|
||||
(порт 2222 в ufw уже открыт).
|
||||
- [ ] `outline.vakhrushev.me` — открывается, документы на месте.
|
||||
- [ ] `matrix.vakhrushev.me` — Tuwunel/Element подключается;
|
||||
federation проверяется через
|
||||
<https://federationtester.matrix.org/>.
|
||||
- [ ] `miniflux.vakhrushev.me`, `wakapi.vakhrushev.me`,
|
||||
`memos.vakhrushev.me`, `gramps.vakhrushev.me`,
|
||||
`remembos.vakhrushev.me`, `wanderer.vakhrushev.me`,
|
||||
`calibre.vakhrushev.me`, `rssbridge.vakhrushev.me`,
|
||||
`dozzle.vakhrushev.me`, `goaccess.vakhrushev.me` —
|
||||
открываются, данные на месте.
|
||||
- [ ] Netdata `status.vakhrushev.me` — собирает метрики.
|
||||
- [ ] Backup-cron — следующий запуск (1:00) проходит успешно,
|
||||
приходит уведомление в apprise.
|
||||
- [ ] SMTP — отправить тестовое письмо из gitea/authelia (триггер
|
||||
reset password).
|
||||
- [ ] Container Registry — `docker pull cr.yandex/...` на новой
|
||||
машине проходит (это значит, что наша новая аутентификация
|
||||
через OAuth/IAM работает).
|
||||
|
||||
### Шаг 8. Заморозка источника
|
||||
|
||||
Когда всё подтверждено стабильным (≥ 24 часа):
|
||||
|
||||
- Остановить и выключить старую VM в YC.
|
||||
- Подождать неделю-две на случай отката.
|
||||
- Удалить VM и связанные ресурсы (только compute! S3-бакет с
|
||||
restic-бэкапами и Container Registry **остаются**).
|
||||
- Удалить `production.yml`, переименовать `timeweb.yml` →
|
||||
`production.yml`, откатить временную правку `HOSTS_FILE` в
|
||||
`tasks.py` (теперь снова `production.yml`). Закоммитить.
|
||||
|
||||
---
|
||||
|
||||
## Фаза 2: подключение медленного диска
|
||||
|
||||
После того как Timeweb-сервер стабилен:
|
||||
|
||||
1. Заказать дополнительный «холодный» диск в Timeweb, прицепить
|
||||
к VPS.
|
||||
2. Узнать UUID нового устройства (`lsblk -f`).
|
||||
3. Решить, куда монтировать — варианты:
|
||||
- Сохранить текущую схему (`/mnt/applications` на медленном
|
||||
диске целиком). Минус: всё IO приложений уходит на медленный
|
||||
диск.
|
||||
- **Лучше:** оставить `/mnt/applications` на быстром SSD,
|
||||
медленный смонтировать как `/mnt/cold` и под calibre/большие
|
||||
бэкапы делать bind-mount или поменять `data_dir` у нужных
|
||||
приложений.
|
||||
4. Восстановить в `playbook-system.yml` mount-задачу с новым
|
||||
UUID (через переменную, заведённую на фазе 1).
|
||||
5. Прогнать `inv pl -- system` с тегом `mount-storage`.
|
||||
6. Переехать на холодный диск только большие данные. Для calibre
|
||||
это означает остановить контейнер, `rsync` библиотеки книг,
|
||||
поправить `data_dir` в `vars`, запустить.
|
||||
|
||||
---
|
||||
|
||||
## Что НЕ менять во время миграции
|
||||
|
||||
Чтобы не накапливать изменения в одном переезде:
|
||||
|
||||
- Версии docker-образов всех приложений — те же, что в источнике.
|
||||
- Конфиги приложений — без правок.
|
||||
- Restic snapshot policy.
|
||||
- Apprise/notification каналы.
|
||||
|
||||
Любые улучшения (healthchecks из `docs/drafts/alerts.md`,
|
||||
gitea runner и т.п.) — отдельным циклом после миграции.
|
||||
|
||||
---
|
||||
|
||||
## Откат
|
||||
|
||||
Если на target что-то критично сломалось:
|
||||
|
||||
1. DNS возвращаем обратно на старый IP.
|
||||
2. Старая VM в YC жива и заглушена → стартуем её, поднимаем
|
||||
сервисы (`docker compose up -d` под каждым пользователем).
|
||||
3. Изучаем, в чём дело на target, лечим, повторяем cutover.
|
||||
|
||||
Поэтому шаг «Заморозка источника» отделён от «удаления» — у нас
|
||||
есть «горячее запасное» как минимум на пару дней.
|
||||
|
||||
---
|
||||
|
||||
## Открытые вопросы
|
||||
|
||||
На текущей итерации — нет, все ключевые развилки закрыты:
|
||||
|
||||
- ~~Auth для cr.yandex~~ → OAuth-token Яндекса (`yc_oauth_token` в
|
||||
vault, `community.docker.docker_login` в плейбуках).
|
||||
- ~~Инвентарь~~ → два отдельных файла, после cutover `timeweb.yml`
|
||||
переименовывается в `production.yml`.
|
||||
- ~~Регион/TZ Timeweb~~ → совпадает с текущим.
|
||||
- ~~IP-whitelist в конфигах~~ → отсутствует, смена IP безопасна.
|
||||
- ~~Объём данных vs 80 ГБ~~ → 22 ГБ всего, из них calibre 16 ГБ;
|
||||
с запасом влезает в фазе 1, второй диск не на критическом пути.
|
||||
|
||||
Возможные вопросы по ходу реализации (выяснятся в процессе):
|
||||
|
||||
- Конкретная процедура получения OAuth-token Яндекса (через
|
||||
`oauth.yandex.ru` или через `yc` CLI).
|
||||
- Поведение Caddy при первом выпуске сертификатов после смены DNS —
|
||||
убедиться, что rate-limit Let's Encrypt не упрётся (≈ 17
|
||||
поддоменов выпускаются сразу, лимит LE — 50 сертификатов в неделю
|
||||
на registered domain, запас есть).
|
||||
- Federation Matrix после смены IP — обычно достаточно того, что
|
||||
apex `vakhrushev.me` отдаёт `.well-known/matrix/server`, но
|
||||
стоит проверить через `federationtester.matrix.org` сразу после
|
||||
cutover.
|
||||
@@ -0,0 +1,23 @@
|
||||
services:
|
||||
|
||||
apprise_app:
|
||||
image: caronc/apprise:v1.3.3
|
||||
container_name: apprise_app
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:{{ apprise_external_port }}:8000"
|
||||
networks:
|
||||
web_proxy_network:
|
||||
aliases:
|
||||
- "apprise"
|
||||
volumes:
|
||||
- "{{ config_dir }}:/config"
|
||||
environment:
|
||||
PUID: "{{ owner_create_result.uid }}"
|
||||
PGID: "{{ owner_create_result.group }}"
|
||||
APPRISE_STATEFUL_MODE: simple
|
||||
APPRISE_WORKER_COUNT: 1
|
||||
|
||||
networks:
|
||||
web_proxy_network:
|
||||
external: true
|
||||
@@ -0,0 +1,2 @@
|
||||
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 }}
|
||||
@@ -104,7 +104,7 @@ server:
|
||||
## Configure the authz endpoints.
|
||||
authz:
|
||||
forward-auth:
|
||||
implementation: 'ForwardAuth'
|
||||
implementation: "ForwardAuth"
|
||||
# authn_strategies: []
|
||||
# ext-authz:
|
||||
# implementation: 'ExtAuthz'
|
||||
@@ -121,10 +121,10 @@ server:
|
||||
##
|
||||
log:
|
||||
## Level of verbosity for logs: info, debug, trace.
|
||||
level: 'debug'
|
||||
level: "debug"
|
||||
|
||||
## Format the logs are written as: json, text.
|
||||
format: 'json'
|
||||
format: "json"
|
||||
|
||||
## File path where the logs will be written. If not set logs are written to stdout.
|
||||
# file_path: '/config/authelia.log'
|
||||
@@ -136,7 +136,6 @@ log:
|
||||
## Telemetry Configuration
|
||||
##
|
||||
telemetry:
|
||||
|
||||
##
|
||||
## Metrics Configuration
|
||||
##
|
||||
@@ -151,7 +150,7 @@ telemetry:
|
||||
## Square brackets indicate optional portions of the format. Scheme must be 'tcp', 'tcp4', 'tcp6', 'unix', or 'fd'.
|
||||
## The default scheme is 'unix' if the address is an absolute path otherwise it's 'tcp'. The default port is '9959'.
|
||||
## If the path is not specified it defaults to `/metrics`.
|
||||
address: 'tcp://:9959/metrics'
|
||||
address: "tcp://:9959/metrics"
|
||||
|
||||
## Metrics Server Buffers configuration.
|
||||
# buffers:
|
||||
@@ -320,7 +319,6 @@ telemetry:
|
||||
##
|
||||
## This configuration tunes the identity validation flows.
|
||||
identity_validation:
|
||||
|
||||
## Reset Password flow. Adjusts how the reset password flow operates.
|
||||
reset_password:
|
||||
## Maximum allowed time before the JWT is generated and when the user uses it in the duration common syntax.
|
||||
@@ -330,7 +328,7 @@ identity_validation:
|
||||
# jwt_algorithm: 'HS256'
|
||||
|
||||
## The secret key used to sign and verify the JWT.
|
||||
jwt_secret: '{{ identity_validation__jwt_secret }}'
|
||||
jwt_secret: "{{ identity_validation__jwt_secret }}"
|
||||
|
||||
## Elevated Session flows. Adjusts the flow which require elevated sessions for example managing credentials, adding,
|
||||
## removing, etc.
|
||||
@@ -408,7 +406,6 @@ identity_validation:
|
||||
##
|
||||
## The available providers are: `file`, `ldap`. You must use only one of these providers.
|
||||
authentication_backend:
|
||||
|
||||
## Password Change Options.
|
||||
password_change:
|
||||
## Disable both the HTML element and the API for password change functionality.
|
||||
@@ -606,7 +603,7 @@ authentication_backend:
|
||||
## Important: Kubernetes (or HA) users must read https://www.authelia.com/t/statelessness
|
||||
##
|
||||
file:
|
||||
path: '/config/users.yml'
|
||||
path: "/config/users.yml"
|
||||
# watch: false
|
||||
# search:
|
||||
# email: false
|
||||
@@ -719,25 +716,33 @@ authentication_backend:
|
||||
access_control:
|
||||
## Default policy can either be 'bypass', 'one_factor', 'two_factor' or 'deny'. It is the policy applied to any
|
||||
## resource if there is no policy to be applied to the user.
|
||||
default_policy: 'deny'
|
||||
default_policy: "deny"
|
||||
|
||||
rules:
|
||||
## Rules applied to everyone
|
||||
- domain: 'status.vakhrushev.me'
|
||||
subject: 'group:admins'
|
||||
policy: 'two_factor'
|
||||
- domain: "status.vakhrushev.me"
|
||||
subject: "group:admins"
|
||||
policy: "two_factor"
|
||||
|
||||
- domain: 'dozzle.vakhrushev.me'
|
||||
subject: 'group:admins'
|
||||
policy: 'two_factor'
|
||||
- domain: "dozzle.vakhrushev.me"
|
||||
subject: "group:admins"
|
||||
policy: "two_factor"
|
||||
|
||||
- domain: 'wanderbase.vakhrushev.me'
|
||||
subject: 'group:admins'
|
||||
policy: 'two_factor'
|
||||
- domain: "goaccess.vakhrushev.me"
|
||||
subject: "group:admins"
|
||||
policy: "two_factor"
|
||||
|
||||
- domain: 'rssbridge.vakhrushev.me'
|
||||
subject: 'group:admins'
|
||||
policy: 'one_factor'
|
||||
- domain: "wanderbase.vakhrushev.me"
|
||||
subject: "group:admins"
|
||||
policy: "two_factor"
|
||||
|
||||
- domain: "remembos.vakhrushev.me"
|
||||
subject: "group:admins"
|
||||
policy: "two_factor"
|
||||
|
||||
- domain: "rssbridge.vakhrushev.me"
|
||||
subject: "group:admins"
|
||||
policy: "one_factor"
|
||||
|
||||
## Domain Regex examples. Generally we recommend just using a standard domain.
|
||||
# - domain_regex: '^(?P<User>\w+)\.example\.com$'
|
||||
@@ -818,18 +823,17 @@ access_control:
|
||||
session:
|
||||
## The secret to encrypt the session data. This is only used with Redis / Redis Sentinel.
|
||||
## Secret can also be set using a secret: https://www.authelia.com/c/secrets
|
||||
secret: '{{ session__secret }}'
|
||||
secret: "{{ session__secret }}"
|
||||
|
||||
## Cookies configures the list of allowed cookie domains for sessions to be created on.
|
||||
## Undefined values will default to the values below.
|
||||
cookies:
|
||||
-
|
||||
## The name of the session cookie.
|
||||
name: 'authelia_session'
|
||||
- ## The name of the session cookie.
|
||||
name: "authelia_session"
|
||||
|
||||
## The domain to protect.
|
||||
## Note: the Authelia portal must also be in that domain.
|
||||
domain: 'vakhrushev.me'
|
||||
domain: "vakhrushev.me"
|
||||
|
||||
## Required. The fully qualified URI of the portal to redirect users to on proxies that support redirections.
|
||||
## Rules:
|
||||
@@ -837,7 +841,7 @@ session:
|
||||
## - The above 'domain' option MUST either:
|
||||
## - Match the host portion of this URI.
|
||||
## - Match the suffix of the host portion when prefixed with '.'.
|
||||
authelia_url: 'https://auth.vakhrushev.me'
|
||||
authelia_url: "https://auth.vakhrushev.me"
|
||||
|
||||
## Optional. The fully qualified URI used as the redirection location if the portal is accessed directly. Not
|
||||
## configuring this option disables the automatic redirection behaviour.
|
||||
@@ -896,7 +900,7 @@ session:
|
||||
## Important: Kubernetes (or HA) users must read https://www.authelia.com/t/statelessness
|
||||
##
|
||||
redis:
|
||||
host: 'authelia_redis'
|
||||
host: "authelia_redis"
|
||||
port: 6379
|
||||
## Use a unix socket instead
|
||||
# host: '/var/run/redis/redis.sock'
|
||||
@@ -1014,7 +1018,7 @@ storage:
|
||||
## The encryption key that is used to encrypt sensitive information in the database. Must be a string with a minimum
|
||||
## length of 20. Please see the docs if you configure this with an undesirable key and need to change it, you MUST use
|
||||
## the CLI to change this in the database if you want to change it from a previously configured value.
|
||||
encryption_key: '{{ storage__encryption_key }}'
|
||||
encryption_key: "{{ storage__encryption_key }}"
|
||||
|
||||
##
|
||||
## Local (Storage Provider)
|
||||
@@ -1026,7 +1030,7 @@ storage:
|
||||
##
|
||||
local:
|
||||
## Path to the SQLite3 Database.
|
||||
path: '/data/authelia_storage.sqlite3'
|
||||
path: "/data/authelia_storage.sqlite3"
|
||||
|
||||
##
|
||||
## MySQL / MariaDB (Storage Provider)
|
||||
@@ -1204,22 +1208,22 @@ notifier:
|
||||
## (configure in tls section)
|
||||
smtp:
|
||||
## The address of the SMTP server to connect to in the address common syntax.
|
||||
address: 'smtp://{{ postbox_host }}:{{ postbox_port }}'
|
||||
address: "smtp://{{ postbox_host }}:{{ postbox_port }}"
|
||||
|
||||
## The connection timeout in the duration common syntax.
|
||||
# timeout: '5 seconds'
|
||||
|
||||
## The username used for SMTP authentication.
|
||||
username: '{{ postbox_user }}'
|
||||
username: "{{ postbox_user }}"
|
||||
|
||||
## The password used for SMTP authentication.
|
||||
## Can also be set using a secret: https://www.authelia.com/c/secrets
|
||||
password: '{{ postbox_pass }}'
|
||||
password: "{{ postbox_pass }}"
|
||||
|
||||
## The sender is used to is used for the MAIL FROM command and the FROM header.
|
||||
## If this is not defined and the username is an email, we use the username as this value. This can either be just
|
||||
## an email address or the RFC5322 'Name <email address>' format.
|
||||
sender: 'Authelia <authelia@vakhrushev.me>'
|
||||
sender: "Authelia <authelia@vakhrushev.me>"
|
||||
|
||||
## HELO/EHLO Identifier. Some SMTP Servers may reject the default of localhost.
|
||||
# identifier: 'localhost'
|
||||
@@ -1229,7 +1233,7 @@ notifier:
|
||||
|
||||
## This address is used during the startup check to verify the email configuration is correct.
|
||||
## It's not important what it is except if your email server only allows local delivery.
|
||||
startup_check_address: '{{ smtp__startup_check_address }}'
|
||||
# startup_check_address: '{{ smtp__startup_check_address }}'
|
||||
|
||||
## By default we require some form of TLS. This disables this check though is not advised.
|
||||
# disable_require_tls: false
|
||||
@@ -1277,7 +1281,6 @@ notifier:
|
||||
## Identity Providers
|
||||
##
|
||||
identity_providers:
|
||||
|
||||
##
|
||||
## OpenID Connect (Identity Provider)
|
||||
##
|
||||
@@ -1286,13 +1289,12 @@ identity_providers:
|
||||
oidc:
|
||||
## The hmac_secret is used to sign OAuth2 tokens (authorization code, access tokens and refresh tokens).
|
||||
## HMAC Secret can also be set using a secret: https://www.authelia.com/c/secrets
|
||||
hmac_secret: '{{ oidc__hmac_secret }}'
|
||||
hmac_secret: "{{ oidc__hmac_secret }}"
|
||||
|
||||
## The JWK's issuer option configures multiple JSON Web Keys. It's required that at least one of the JWK's
|
||||
## configured has the RS256 algorithm. For RSA keys (RS or PS) the minimum is a 2048 bit key.
|
||||
jwks:
|
||||
-
|
||||
## Key ID embedded into the JWT header for key matching.
|
||||
- ## Key ID embedded into the JWT header for key matching.
|
||||
## Must be an alphanumeric string with 7 or less characters.
|
||||
## This value is automatically generated if not provided. It's recommended to not configure this.
|
||||
# key_id: 'example'
|
||||
@@ -1344,8 +1346,8 @@ identity_providers:
|
||||
authorization_policies:
|
||||
outline_policy:
|
||||
rules:
|
||||
- policy: 'one_factor'
|
||||
subject: 'group:outline'
|
||||
- policy: "one_factor"
|
||||
subject: "group:outline"
|
||||
|
||||
## 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.
|
||||
@@ -1382,34 +1384,32 @@ identity_providers:
|
||||
## It's recommended you read the documentation before configuration of a registered client.
|
||||
## See: https://www.authelia.com/c/oidc/registered-clients
|
||||
clients:
|
||||
-
|
||||
client_name: 'Miniflux'
|
||||
client_id: '{{ oidc__miniflux__client_id }}'
|
||||
client_secret: '{{ oidc__miniflux__client_secret }}'
|
||||
- client_name: "Miniflux"
|
||||
client_id: "{{ oidc__miniflux__client_id }}"
|
||||
client_secret: "{{ oidc__miniflux__client_secret }}"
|
||||
redirect_uris:
|
||||
- 'https://miniflux.vakhrushev.me/oauth2/oidc/callback'
|
||||
- "https://miniflux.vakhrushev.me/oauth2/oidc/callback"
|
||||
scopes:
|
||||
- 'openid'
|
||||
- 'profile'
|
||||
- 'email'
|
||||
- "openid"
|
||||
- "profile"
|
||||
- "email"
|
||||
response_types:
|
||||
- 'code'
|
||||
- "code"
|
||||
grant_types:
|
||||
- 'authorization_code'
|
||||
access_token_signed_response_alg: 'none'
|
||||
userinfo_signed_response_alg: 'none'
|
||||
token_endpoint_auth_method: 'client_secret_basic'
|
||||
- "authorization_code"
|
||||
access_token_signed_response_alg: "none"
|
||||
userinfo_signed_response_alg: "none"
|
||||
token_endpoint_auth_method: "client_secret_basic"
|
||||
|
||||
-
|
||||
client_name: 'Wakapi'
|
||||
client_id: '{{ oidc__wakapi__client_id }}'
|
||||
client_secret: '{{ oidc__wakapi__client_secret }}'
|
||||
- client_name: "Wakapi"
|
||||
client_id: "{{ oidc__wakapi__client_id }}"
|
||||
client_secret: "{{ oidc__wakapi__client_secret }}"
|
||||
redirect_uris:
|
||||
- 'https://wakapi.vakhrushev.me/oidc/authelia/callback'
|
||||
- "https://wakapi.vakhrushev.me/oidc/authelia/callback"
|
||||
scopes:
|
||||
- 'openid'
|
||||
- 'profile'
|
||||
- 'email'
|
||||
- "openid"
|
||||
- "profile"
|
||||
- "email"
|
||||
# response_types:
|
||||
# - 'code'
|
||||
# grant_types:
|
||||
@@ -1417,18 +1417,16 @@ identity_providers:
|
||||
# access_token_signed_response_alg: 'none'
|
||||
# userinfo_signed_response_alg: 'none'
|
||||
# token_endpoint_auth_method: 'client_secret_basic'
|
||||
|
||||
-
|
||||
## The description to show to users when they end up on the consent screen. Defaults to the ID above.
|
||||
client_name: 'Outline'
|
||||
- ## The description to show to users when they end up on the consent screen. Defaults to the ID above.
|
||||
client_name: "Outline"
|
||||
|
||||
## The Client ID is the OAuth 2.0 and OpenID Connect 1.0 Client ID which is used to link an application to a
|
||||
## configuration.
|
||||
client_id: '{{ oidc__outline__client_id }}'
|
||||
client_id: "{{ oidc__outline__client_id }}"
|
||||
|
||||
## The client secret is a shared secret between Authelia and the consumer of this client.
|
||||
# yamllint disable-line rule:line-length
|
||||
client_secret: '{{ oidc__outline__client_secret }}'
|
||||
client_secret: "{{ oidc__outline__client_secret }}"
|
||||
|
||||
## Sector Identifiers are occasionally used to generate pairwise subject identifiers. In most cases this is not
|
||||
## necessary. It is critical to read the documentation for more information.
|
||||
@@ -1439,7 +1437,7 @@ identity_providers:
|
||||
|
||||
## Redirect URI's specifies a list of valid case-sensitive callbacks for this client.
|
||||
redirect_uris:
|
||||
- 'https://outline.vakhrushev.me/auth/oidc.callback'
|
||||
- "https://outline.vakhrushev.me/auth/oidc.callback"
|
||||
|
||||
## Request URI's specifies a list of valid case-sensitive TLS-secured URIs for this client for use as
|
||||
## URIs to fetch Request Objects.
|
||||
@@ -1451,9 +1449,9 @@ identity_providers:
|
||||
|
||||
## Scopes this client is allowed to request.
|
||||
scopes:
|
||||
- 'openid'
|
||||
- 'profile'
|
||||
- 'email'
|
||||
- "openid"
|
||||
- "profile"
|
||||
- "email"
|
||||
|
||||
## Grant Types configures which grants this client can obtain.
|
||||
## It's not recommended to define this unless you know what you're doing.
|
||||
@@ -1472,7 +1470,7 @@ identity_providers:
|
||||
|
||||
## The policy to require for this client; one_factor or two_factor. Can also be the key names for the
|
||||
## authorization policies section.
|
||||
authorization_policy: 'outline_policy'
|
||||
authorization_policy: "outline_policy"
|
||||
|
||||
## 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.
|
||||
@@ -1573,7 +1571,7 @@ identity_providers:
|
||||
## The signing algorithm used for signing the User Info Request responses.
|
||||
## Please read the documentation before adjusting this option.
|
||||
## See: https://www.authelia.com/c/oidc/registered-clients#userinfo_signed_response_alg
|
||||
userinfo_signed_response_alg: 'none'
|
||||
userinfo_signed_response_alg: "none"
|
||||
|
||||
## The signing key id used for signing the User Info Request responses.
|
||||
## Please read the documentation before adjusting this option.
|
||||
@@ -1637,7 +1635,7 @@ identity_providers:
|
||||
## The permitted client authentication method for the Token Endpoint for this client.
|
||||
## For confidential client types this value defaults to 'client_secret_basic' and for the public client types it
|
||||
## defaults to 'none' per the specifications.
|
||||
token_endpoint_auth_method: 'client_secret_post'
|
||||
token_endpoint_auth_method: "client_secret_post"
|
||||
|
||||
## The permitted client authentication signing algorithm for the Token Endpoint for this client when using
|
||||
## the 'client_secret_jwt' or 'private_key_jwt' token_endpoint_auth_method.
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
services:
|
||||
|
||||
authelia_app:
|
||||
container_name: 'authelia_app'
|
||||
image: 'docker.io/authelia/authelia:4.39.14'
|
||||
user: '{{ user_create_result.uid }}:{{ user_create_result.group }}'
|
||||
restart: 'unless-stopped'
|
||||
container_name: "authelia_app"
|
||||
image: "docker.io/authelia/authelia:4.39.20"
|
||||
user: "{{ owner_create_result.uid }}:{{ owner_create_result.group }}"
|
||||
restart: "unless-stopped"
|
||||
networks:
|
||||
- "web_proxy_network"
|
||||
- "monitoring_network"
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
$ANSIBLE_VAULT;1.1;AES256
|
||||
39343035656562656632323766356561386665373036383564616331333333613765353737663632
|
||||
3531663835303562393063343231623464663232333532380a663838663938316566616532623065
|
||||
66336463643862626538366462346231386333366464323131363836326436373563623164336632
|
||||
6234353437383432380a396136653136616335343936343335633236373363353766666539396334
|
||||
36613836663831333838633231363731323234323761306630646632616238363662376462333039
|
||||
32373938343562313064663334383766653161613032623936646361316561666532356465623133
|
||||
32303663313834663834366363383265653939316336356239313364623366386631626536643439
|
||||
31333362353961353434333636343336323239363461663937313931616262316330376165393263
|
||||
63366665396431323034383939633365316134356564656136393032393864393636616234316231
|
||||
37616336396435626264643232343766616364306264376338313238356261653863336535363237
|
||||
34653638316161636431653465343536323331656230633332333139386132653433626662343837
|
||||
35396437633233363637376561303338386432643039626336376366373334613463663465613637
|
||||
36643734626163623738336435383032353837366532316566613864306430653336616637383262
|
||||
65646131643533323563393133373964633863636666633338616236386531323064396137376232
|
||||
37653333666566386563383235356232663338643161313635643661326339333661393135643030
|
||||
62356662623365376662646166316262353964383936373463393339623961376232653664306439
|
||||
36336231393434356661316336653033346430386366663138323832613532303265343136373836
|
||||
64666561616535623732326464643831363866326265343165356330646561653066393764336134
|
||||
30326436663066633163393163306265383834306634663639336437303965373063323335333537
|
||||
38643234623061376565636536323563623739313165343464316466363364613963636437363830
|
||||
33306632313839373132636130326331363538323763326333316165363633336561373030373963
|
||||
38313135343464303331343866646634393162393361333962356133376163393865373239323763
|
||||
31303336613937303031343532333036653133363439643864663661373639646566643831313662
|
||||
35613430333861376565
|
||||
+307
-247
@@ -4,18 +4,21 @@ Backup script for all applications
|
||||
Automatically discovers and runs backup scripts for all users,
|
||||
then creates restic backups and sends notifications.
|
||||
"""
|
||||
|
||||
import itertools
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import logging
|
||||
import os
|
||||
import pwd
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import tomllib
|
||||
from abc import ABC
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Any
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import requests
|
||||
import tomllib
|
||||
|
||||
# Default config path
|
||||
CONFIG_PATH = Path("/etc/backup/config.toml")
|
||||
@@ -42,17 +45,45 @@ logger = logging.getLogger(__name__)
|
||||
@dataclass
|
||||
class Config:
|
||||
host_name: str
|
||||
roots: List[Path]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Application:
|
||||
path: Path
|
||||
owner: str
|
||||
backup_script: Optional[Path]
|
||||
backup_targets: List[Path]
|
||||
|
||||
|
||||
@dataclass
|
||||
class BackupResult:
|
||||
success: bool
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class StorageRunResult:
|
||||
name: str
|
||||
success: bool
|
||||
duration: float
|
||||
|
||||
|
||||
def format_duration(seconds: float) -> str:
|
||||
if seconds < 60:
|
||||
return f"{seconds:.1f}s"
|
||||
minutes = int(seconds // 60)
|
||||
secs = int(seconds % 60)
|
||||
if minutes < 60:
|
||||
return f"{minutes}m{secs:02d}s"
|
||||
hours = minutes // 60
|
||||
minutes = minutes % 60
|
||||
return f"{hours}h{minutes:02d}m{secs:02d}s"
|
||||
|
||||
|
||||
class Storage(ABC):
|
||||
def backup(self, backup_dirs: List[str]) -> bool:
|
||||
name: str
|
||||
|
||||
def backup(self, backup_dirs: List[str]) -> BackupResult:
|
||||
"""Backup directories"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@@ -64,67 +95,45 @@ class ResticStorage(Storage):
|
||||
self.name = name
|
||||
self.restic_repository = str(params.get("restic_repository", ""))
|
||||
self.restic_password = str(params.get("restic_password", ""))
|
||||
self.aws_access_key_id = str(params.get("aws_access_key_id", ""))
|
||||
self.aws_secret_access_key = str(params.get("aws_secret_access_key", ""))
|
||||
self.aws_default_region = str(params.get("aws_default_region", ""))
|
||||
|
||||
if not all(
|
||||
[
|
||||
self.restic_repository,
|
||||
self.restic_password,
|
||||
self.aws_access_key_id,
|
||||
self.aws_secret_access_key,
|
||||
self.aws_default_region,
|
||||
]
|
||||
):
|
||||
env_raw = params.get("env") or {}
|
||||
if not isinstance(env_raw, dict):
|
||||
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()}
|
||||
|
||||
if not self.restic_repository or not self.restic_password:
|
||||
raise ValueError(
|
||||
f"Missing storage configuration values for backend ResticStorage: '{self.name}'"
|
||||
)
|
||||
|
||||
def backup(self, backup_dirs: List[str]) -> bool:
|
||||
def backup(self, backup_dirs: List[str]) -> BackupResult:
|
||||
if not backup_dirs:
|
||||
logger.warning("No backup directories found")
|
||||
return True
|
||||
return BackupResult(success=True)
|
||||
try:
|
||||
return self.__backup_internal(backup_dirs)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("Restic backup process failed: %s", exc)
|
||||
return False
|
||||
return BackupResult(success=False, error=str(exc))
|
||||
|
||||
def __backup_internal(self, backup_dirs: List[str]) -> bool:
|
||||
logger.info("Starting restic backup")
|
||||
def __backup_internal(self, backup_dirs: List[str]) -> BackupResult:
|
||||
logger.info("Starting restic backup for storage '%s'", self.name)
|
||||
logger.info("Destination: %s", self.restic_repository)
|
||||
|
||||
env = os.environ.copy()
|
||||
env.update(
|
||||
{
|
||||
"RESTIC_REPOSITORY": self.restic_repository,
|
||||
"RESTIC_PASSWORD": self.restic_password,
|
||||
"AWS_ACCESS_KEY_ID": self.aws_access_key_id,
|
||||
"AWS_SECRET_ACCESS_KEY": self.aws_secret_access_key,
|
||||
"AWS_DEFAULT_REGION": self.aws_default_region,
|
||||
}
|
||||
)
|
||||
|
||||
backup_cmd = ["restic", "backup", "--verbose"] + backup_dirs
|
||||
result = subprocess.run(backup_cmd, env=env, capture_output=True, text=True)
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error("Restic backup failed: %s", result.stderr)
|
||||
return False
|
||||
|
||||
logger.info("Restic backup completed successfully")
|
||||
env["RESTIC_REPOSITORY"] = self.restic_repository
|
||||
env["RESTIC_PASSWORD"] = self.restic_password
|
||||
env.update(self.env)
|
||||
|
||||
check_cmd = ["restic", "check"]
|
||||
result = subprocess.run(check_cmd, env=env, capture_output=True, text=True)
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error("Restic check failed: %s", result.stderr)
|
||||
return False
|
||||
|
||||
logger.info("Restic check completed successfully")
|
||||
|
||||
forget_cmd = [
|
||||
steps = [
|
||||
("backup", ["restic", "backup", "--verbose"] + backup_dirs),
|
||||
("check", check_cmd),
|
||||
(
|
||||
"forget/prune",
|
||||
[
|
||||
"restic",
|
||||
"forget",
|
||||
"--compact",
|
||||
@@ -133,83 +142,75 @@ class ResticStorage(Storage):
|
||||
"90",
|
||||
"--keep-monthly",
|
||||
"36",
|
||||
],
|
||||
),
|
||||
("final check", check_cmd),
|
||||
]
|
||||
result = subprocess.run(forget_cmd, env=env, capture_output=True, text=True)
|
||||
|
||||
for step, cmd in steps:
|
||||
error = self.__run_step(step, cmd, env)
|
||||
if error is not None:
|
||||
return BackupResult(success=False, error=f"restic {step}: {error}")
|
||||
|
||||
return BackupResult(success=True)
|
||||
|
||||
def __run_step(
|
||||
self, step: str, cmd: List[str], env: Dict[str, str]
|
||||
) -> Optional[str]:
|
||||
"""Run a single restic command. Return None on success or error text."""
|
||||
result = subprocess.run(cmd, env=env, capture_output=True, text=True)
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error("Restic forget/prune failed: %s", result.stderr)
|
||||
return False
|
||||
error = result.stderr.strip() or result.stdout.strip() or "no output"
|
||||
logger.error("Restic %s failed: %s", step, error)
|
||||
return error
|
||||
|
||||
logger.info("Restic forget/prune completed successfully")
|
||||
|
||||
result = subprocess.run(check_cmd, env=env, capture_output=True, text=True)
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error("Final restic check failed: %s", result.stderr)
|
||||
return False
|
||||
|
||||
logger.info("Final restic check completed successfully")
|
||||
return True
|
||||
logger.info("Restic %s completed successfully", step)
|
||||
return None
|
||||
|
||||
|
||||
class Notifier(ABC):
|
||||
def send(self, html_message: str):
|
||||
def send(self, title: str, html_message: str) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
class TelegramNotifier(Notifier):
|
||||
TYPE_NAME = "telegram"
|
||||
class AppriseNotifier(Notifier):
|
||||
TYPE_NAME = "apprise"
|
||||
|
||||
def __init__(self, name: str, params: Dict[str, Any]):
|
||||
self.name = name
|
||||
self.telegram_bot_token = str(params.get("telegram_bot_token", ""))
|
||||
self.telegram_chat_id = str(params.get("telegram_chat_id", ""))
|
||||
if not all(
|
||||
[
|
||||
self.telegram_bot_token,
|
||||
self.telegram_chat_id,
|
||||
]
|
||||
):
|
||||
self.api_url = str(params.get("api_url", "")).rstrip("/")
|
||||
self.tag = str(params.get("tag", ""))
|
||||
if not self.api_url or not self.tag:
|
||||
raise ValueError(
|
||||
f"Missing notification configuration values for backend {name}"
|
||||
)
|
||||
|
||||
def send(self, html_message: str):
|
||||
url = f"https://api.telegram.org/bot{self.telegram_bot_token}/sendMessage"
|
||||
data = {
|
||||
"chat_id": self.telegram_chat_id,
|
||||
"parse_mode": "HTML",
|
||||
"text": html_message,
|
||||
def send(self, title: str, html_message: str) -> None:
|
||||
url = f"{self.api_url}/notify/{self.tag}/"
|
||||
payload = {
|
||||
"title": title,
|
||||
"body": html_message,
|
||||
"format": "html",
|
||||
}
|
||||
|
||||
response = requests.post(url, data=data, timeout=30)
|
||||
response = requests.post(url, json=payload, timeout=30)
|
||||
|
||||
if response.status_code == 200:
|
||||
logger.info("Telegram notification sent successfully")
|
||||
if response.ok:
|
||||
logger.info("Apprise notification sent successfully")
|
||||
else:
|
||||
logger.error(
|
||||
f"Failed to send Telegram notification: {response.status_code} - {response.text}"
|
||||
f"Failed to send Apprise notification: {response.status_code} - {response.text}"
|
||||
)
|
||||
|
||||
|
||||
class BackupManager:
|
||||
def __init__(
|
||||
self,
|
||||
config: Config,
|
||||
roots: List[Path],
|
||||
storages: List[Storage],
|
||||
notifiers: List[Notifier],
|
||||
):
|
||||
self.errors: List[str] = []
|
||||
class ApplicationFinder:
|
||||
def __init__(self, roots: List[Path]):
|
||||
self.roots = roots
|
||||
self.warnings: List[str] = []
|
||||
self.successful_backups: List[str] = []
|
||||
self.config = config
|
||||
self.roots: List[Path] = roots
|
||||
self.storages = storages
|
||||
self.notifiers = notifiers
|
||||
|
||||
def find_applications(self) -> List[Application]:
|
||||
"""Get all application directories and their owners."""
|
||||
"""Discover all applications with their backup scripts and targets."""
|
||||
applications: List[Application] = []
|
||||
source_dirs = itertools.chain(*(root.iterdir() for root in self.roots))
|
||||
|
||||
@@ -220,32 +221,185 @@ class BackupManager:
|
||||
try:
|
||||
stat_info = app_dir.stat()
|
||||
owner = pwd.getpwuid(stat_info.st_uid).pw_name
|
||||
applications.append(Application(path=app_dir, owner=owner))
|
||||
backup_script = self._find_backup_script(app_dir)
|
||||
backup_targets = self._find_backup_targets(app_dir)
|
||||
applications.append(
|
||||
Application(
|
||||
path=app_dir,
|
||||
owner=owner,
|
||||
backup_script=backup_script,
|
||||
backup_targets=backup_targets,
|
||||
)
|
||||
)
|
||||
except (KeyError, OSError) as e:
|
||||
logger.warning(f"Could not get owner for {app_dir}: {e}")
|
||||
|
||||
applications.sort(key=lambda app: app.path.name)
|
||||
return applications
|
||||
|
||||
def find_backup_script(self, app_dir: str) -> Optional[str]:
|
||||
"""Find backup script in user's home directory"""
|
||||
possible_scripts = [
|
||||
os.path.join(app_dir, "backup.sh"),
|
||||
os.path.join(app_dir, "backup"),
|
||||
]
|
||||
|
||||
for script_path in possible_scripts:
|
||||
if os.path.exists(script_path):
|
||||
# Check if file is executable
|
||||
def _find_backup_script(self, app_dir: Path) -> Optional[Path]:
|
||||
"""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"
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
def run_app_backup(self, script_path: str, app_dir: str, username: str) -> bool:
|
||||
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] = []
|
||||
|
||||
if targets_file.exists():
|
||||
for target_line in self._parse_targets_file(targets_file):
|
||||
target_path = Path(target_line)
|
||||
if not target_path.is_absolute():
|
||||
target_path = (app_dir / target_path).resolve()
|
||||
else:
|
||||
target_path = target_path.resolve()
|
||||
if target_path.exists():
|
||||
resolved_targets.append(target_path)
|
||||
else:
|
||||
warning_msg = (
|
||||
f"Backup target does not exist for {app_dir}: {target_path}"
|
||||
)
|
||||
logger.warning(warning_msg)
|
||||
self.warnings.append(warning_msg)
|
||||
else:
|
||||
default_target = (app_dir / BACKUP_DEFAULT_DIR).resolve()
|
||||
if default_target.exists():
|
||||
resolved_targets.append(default_target)
|
||||
else:
|
||||
warning_msg = f"Default backup path does not exist for {app_dir}: {default_target}"
|
||||
logger.warning(warning_msg)
|
||||
self.warnings.append(warning_msg)
|
||||
|
||||
return resolved_targets
|
||||
|
||||
def _parse_targets_file(self, targets_file: Path) -> List[str]:
|
||||
"""Parse backup-targets file, skipping comments and empty lines."""
|
||||
targets: List[str] = []
|
||||
try:
|
||||
for raw_line in targets_file.read_text(encoding="utf-8").splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
targets.append(line)
|
||||
except OSError as e:
|
||||
warning_msg = f"Could not read backup targets file {targets_file}: {e}"
|
||||
logger.warning(warning_msg)
|
||||
self.warnings.append(warning_msg)
|
||||
return targets
|
||||
|
||||
|
||||
class BackupManager:
|
||||
def __init__(
|
||||
self,
|
||||
config: Config,
|
||||
storages: List[Storage],
|
||||
notifiers: List[Notifier],
|
||||
):
|
||||
self.errors: List[str] = []
|
||||
self.warnings: List[str] = []
|
||||
self.successful_backups: List[str] = []
|
||||
self.config = config
|
||||
self.storages = storages
|
||||
self.notifiers = notifiers
|
||||
self.archive_duration: float = 0.0
|
||||
self.storage_results: List[StorageRunResult] = []
|
||||
|
||||
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")
|
||||
|
||||
archive_start = time.monotonic()
|
||||
# Process each user's backup
|
||||
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)
|
||||
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] = []
|
||||
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}")
|
||||
|
||||
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:
|
||||
backup_result = storage.backup(backup_dirs)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error(
|
||||
"Storage '%s' raised an unexpected error: %s", storage.name, exc
|
||||
)
|
||||
backup_result = BackupResult(success=False, error=str(exc))
|
||||
storage_duration = time.monotonic() - storage_start
|
||||
self.storage_results.append(
|
||||
StorageRunResult(
|
||||
name=storage.name,
|
||||
success=backup_result.success,
|
||||
duration=storage_duration,
|
||||
)
|
||||
)
|
||||
logger.info(
|
||||
"Storage '%s' finished in %s (success=%s)",
|
||||
storage.name,
|
||||
format_duration(storage_duration),
|
||||
backup_result.success,
|
||||
)
|
||||
if not backup_result.success:
|
||||
error_msg = f"Storage '{storage.name}' backup failed"
|
||||
if backup_result.error:
|
||||
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
|
||||
|
||||
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})")
|
||||
@@ -284,149 +438,51 @@ class BackupManager:
|
||||
self.errors.append(f"App {username}: {error_msg}")
|
||||
return False
|
||||
|
||||
def get_backup_directories(self) -> List[str]:
|
||||
"""Collect backup targets according to backup-targets rules"""
|
||||
backup_dirs: List[str] = []
|
||||
applications = self.find_applications()
|
||||
|
||||
def parse_targets_file(targets_file: Path) -> List[str]:
|
||||
"""Parse backup-targets file, skipping comments and empty lines."""
|
||||
targets: List[str] = []
|
||||
try:
|
||||
for raw_line in targets_file.read_text(encoding="utf-8").splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
targets.append(line)
|
||||
except OSError as e:
|
||||
warning_msg = f"Could not read backup targets file {targets_file}: {e}"
|
||||
logger.warning(warning_msg)
|
||||
self.warnings.append(warning_msg)
|
||||
return targets
|
||||
|
||||
for app in applications:
|
||||
app_dir = app.path
|
||||
targets_file = app_dir / BACKUP_TARGETS_FILE
|
||||
resolved_targets: List[Path] = []
|
||||
|
||||
if targets_file.exists():
|
||||
# Read custom targets defined by the application.
|
||||
for target_line in parse_targets_file(targets_file):
|
||||
target_path = Path(target_line)
|
||||
if not target_path.is_absolute():
|
||||
target_path = (app_dir / target_path).resolve()
|
||||
else:
|
||||
target_path = target_path.resolve()
|
||||
if target_path.exists():
|
||||
resolved_targets.append(target_path)
|
||||
else:
|
||||
warning_msg = (
|
||||
f"Backup target does not exist for {app_dir}: {target_path}"
|
||||
)
|
||||
logger.warning(warning_msg)
|
||||
self.warnings.append(warning_msg)
|
||||
else:
|
||||
# Fallback to default backups directory when no list is provided.
|
||||
default_target = (app_dir / BACKUP_DEFAULT_DIR).resolve()
|
||||
if default_target.exists():
|
||||
resolved_targets.append(default_target)
|
||||
else:
|
||||
warning_msg = f"Default backup path does not exist for {app_dir}: {default_target}"
|
||||
logger.warning(warning_msg)
|
||||
self.warnings.append(warning_msg)
|
||||
|
||||
for target in resolved_targets:
|
||||
target_str = str(target)
|
||||
if target_str not in backup_dirs:
|
||||
backup_dirs.append(target_str)
|
||||
|
||||
return backup_dirs
|
||||
|
||||
def send_notification(self, success: bool) -> None:
|
||||
def _send_notification(self, success: bool) -> None:
|
||||
"""Send notification to Notifiers"""
|
||||
|
||||
host = self.config.host_name
|
||||
|
||||
if success and not self.errors:
|
||||
message = f"<b>{self.config.host_name}</b>: бекап успешно завершен!"
|
||||
title = f"{host}: бекап успешно завершен"
|
||||
message = f"<p><b>{host}</b>: бекап успешно завершен!</p>"
|
||||
if self.successful_backups:
|
||||
message += f"\n\nУспешные бекапы: {', '.join(self.successful_backups)}"
|
||||
items = "".join(f"<li>{b}</li>" for b in self.successful_backups)
|
||||
message += f"<p>Успешные бекапы:</p><ul>{items}</ul>"
|
||||
else:
|
||||
message = f"<b>{self.config.host_name}</b>: бекап завершен с ошибками!"
|
||||
title = f"{host}: бекап завершен с ошибками ({len(self.errors)})"
|
||||
message = f"<p><b>{host}</b>: бекап завершен с ошибками!</p>"
|
||||
|
||||
if self.successful_backups:
|
||||
message += (
|
||||
f"\n\n✅ Успешные бекапы: {', '.join(self.successful_backups)}"
|
||||
)
|
||||
items = "".join(f"<li>{b}</li>" for b in self.successful_backups)
|
||||
message += f"<p>✅ Успешные бекапы:</p><ul>{items}</ul>"
|
||||
|
||||
if self.warnings:
|
||||
message += f"\n\n⚠️ Предупреждения:\n" + "\n".join(self.warnings)
|
||||
items = "".join(f"<li>{w}</li>" for w in self.warnings)
|
||||
message += f"<p>⚠️ Предупреждения:</p><ul>{items}</ul>"
|
||||
|
||||
if self.errors:
|
||||
message += f"\n\n❌ Ошибки:\n" + "\n".join(self.errors)
|
||||
items = "".join(f"<li>{e}</li>" for e in self.errors)
|
||||
message += f"<p>❌ Ошибки:</p><ul>{items}</ul>"
|
||||
|
||||
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>"
|
||||
|
||||
for notificator in self.notifiers:
|
||||
try:
|
||||
notificator.send(message)
|
||||
notificator.send(title, message)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send notification: {str(e)}")
|
||||
|
||||
def run_backup_process(self) -> bool:
|
||||
"""Main backup process"""
|
||||
logger.info("Starting backup process")
|
||||
|
||||
# Get all home directories
|
||||
applications = self.find_applications()
|
||||
logger.info(f"Found {len(applications)} application directories")
|
||||
|
||||
# Process each user's backup
|
||||
for app in applications:
|
||||
app_dir = str(app.path)
|
||||
username = app.owner
|
||||
logger.info(f"Processing backup for app: {app_dir} (user {username})")
|
||||
|
||||
# Find backup script
|
||||
backup_script = self.find_backup_script(app_dir)
|
||||
|
||||
if 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(backup_script, app_dir, username)
|
||||
|
||||
# Get backup directories
|
||||
backup_dirs = self.get_backup_directories()
|
||||
logger.info(f"Found backup directories: {backup_dirs}")
|
||||
|
||||
overall_success = True
|
||||
|
||||
for storage in self.storages:
|
||||
backup_result = storage.backup(backup_dirs)
|
||||
if not backup_result:
|
||||
self.errors.append("Restic backup failed")
|
||||
|
||||
# Determine overall success
|
||||
overall_success = overall_success and backup_result
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
def initialize(config_path: Path) -> BackupManager:
|
||||
def initialize(
|
||||
config_path: Path,
|
||||
) -> tuple[ApplicationFinder, BackupManager]:
|
||||
try:
|
||||
with config_path.open("rb") as config_file:
|
||||
raw_config = tomllib.load(config_file)
|
||||
@@ -458,22 +514,26 @@ def initialize(config_path: Path) -> BackupManager:
|
||||
if not isinstance(params, dict):
|
||||
raise ValueError(f"Notificator config for {name} must be a table")
|
||||
notifier_type = params.get("type", "")
|
||||
if notifier_type == TelegramNotifier.TYPE_NAME:
|
||||
notifiers.append(TelegramNotifier(name, params))
|
||||
if notifier_type == AppriseNotifier.TYPE_NAME:
|
||||
notifiers.append(AppriseNotifier(name, params))
|
||||
if not notifiers:
|
||||
raise ValueError("At least one notification backend must be configured")
|
||||
|
||||
config = Config(host_name=host_name, roots=roots)
|
||||
|
||||
return BackupManager(
|
||||
config=config, roots=roots, storages=storages, notifiers=notifiers
|
||||
config = Config(host_name=host_name)
|
||||
app_finder = ApplicationFinder(roots)
|
||||
backup_manager = BackupManager(
|
||||
config=config, storages=storages, notifiers=notifiers
|
||||
)
|
||||
|
||||
return app_finder, backup_manager
|
||||
|
||||
def main():
|
||||
|
||||
def main() -> None:
|
||||
try:
|
||||
backup_manager = initialize(CONFIG_PATH)
|
||||
success = backup_manager.run_backup_process()
|
||||
app_finder, backup_manager = initialize(CONFIG_PATH)
|
||||
applications = app_finder.find_applications()
|
||||
backup_manager.warnings.extend(app_finder.warnings)
|
||||
success = backup_manager.run_backup_process(applications)
|
||||
if not success:
|
||||
sys.exit(1)
|
||||
except KeyboardInterrupt:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
host_name = "{{ notifications_name }}"
|
||||
host_name = "{{ host_name }}"
|
||||
|
||||
roots = [
|
||||
"{{ application_dir }}"
|
||||
@@ -8,11 +8,21 @@ roots = [
|
||||
type = "restic"
|
||||
restic_repository = "{{ restic_repository }}"
|
||||
restic_password = "{{ restic_password }}"
|
||||
aws_access_key_id = "{{ restic_s3_access_key }}"
|
||||
aws_secret_access_key = "{{ restic_s3_access_secret }}"
|
||||
aws_default_region = "{{ restic_s3_region }}"
|
||||
|
||||
[notifier.server_notifications_channel]
|
||||
type = "telegram"
|
||||
telegram_bot_token = "{{ notifications_tg_bot_token }}"
|
||||
telegram_chat_id = "{{ notifications_tg_chat_id }}"
|
||||
[storage.yandex_cloud_s3.env]
|
||||
AWS_ACCESS_KEY_ID = "{{ restic_s3_access_key }}"
|
||||
AWS_SECRET_ACCESS_KEY = "{{ restic_s3_access_secret }}"
|
||||
AWS_DEFAULT_REGION = "{{ restic_s3_region }}"
|
||||
|
||||
[storage.pr86keedav]
|
||||
type = "restic"
|
||||
restic_repository = "{{ restic_pr86keedav_repository }}"
|
||||
restic_password = "{{ restic_pr86keedav_password }}"
|
||||
|
||||
[storage.pr86keedav.env]
|
||||
RCLONE_CONFIG = "{{ rclone_config_file }}"
|
||||
|
||||
[notifier.apprise]
|
||||
type = "apprise"
|
||||
api_url = "{{ apprise_external_url }}"
|
||||
tag = "server"
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
[pr86keedav]
|
||||
type = webdav
|
||||
url = {{ rclone_pr86keedav_url }}
|
||||
vendor = other
|
||||
user = {{ rclone_pr86keedav_user }}
|
||||
pass = {{ rclone_pr86keedav_password }}
|
||||
@@ -1,136 +0,0 @@
|
||||
# -------------------------------------------------------------------
|
||||
# Global options
|
||||
# -------------------------------------------------------------------
|
||||
{
|
||||
grace_period 15s
|
||||
|
||||
admin :2019
|
||||
|
||||
# Enable metrics in Prometheus format
|
||||
# https://caddyserver.com/docs/metrics
|
||||
metrics
|
||||
}
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Applications
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
vakhrushev.me {
|
||||
tls anwinged@ya.ru
|
||||
|
||||
reverse_proxy {
|
||||
to homepage_app:80
|
||||
}
|
||||
}
|
||||
|
||||
auth.vakhrushev.me {
|
||||
tls anwinged@ya.ru
|
||||
|
||||
reverse_proxy authelia_app:9091
|
||||
}
|
||||
|
||||
status.vakhrushev.me, :29999 {
|
||||
tls anwinged@ya.ru
|
||||
|
||||
forward_auth authelia_app:9091 {
|
||||
uri /api/authz/forward-auth
|
||||
copy_headers Remote-User Remote-Groups Remote-Email Remote-Name
|
||||
}
|
||||
|
||||
reverse_proxy netdata:19999
|
||||
}
|
||||
|
||||
git.vakhrushev.me {
|
||||
tls anwinged@ya.ru
|
||||
|
||||
reverse_proxy {
|
||||
to gitea_app:3000
|
||||
}
|
||||
}
|
||||
|
||||
outline.vakhrushev.me {
|
||||
tls anwinged@ya.ru
|
||||
|
||||
reverse_proxy {
|
||||
to outline_app:3000
|
||||
}
|
||||
}
|
||||
|
||||
gramps.vakhrushev.me {
|
||||
tls anwinged@ya.ru
|
||||
|
||||
reverse_proxy {
|
||||
to gramps_app:5000
|
||||
}
|
||||
}
|
||||
|
||||
miniflux.vakhrushev.me {
|
||||
tls anwinged@ya.ru
|
||||
|
||||
reverse_proxy {
|
||||
to miniflux_app:8080
|
||||
}
|
||||
}
|
||||
|
||||
wakapi.vakhrushev.me {
|
||||
tls anwinged@ya.ru
|
||||
|
||||
reverse_proxy {
|
||||
to wakapi_app:3000
|
||||
}
|
||||
}
|
||||
|
||||
wanderer.vakhrushev.me {
|
||||
tls anwinged@ya.ru
|
||||
|
||||
reverse_proxy {
|
||||
to wanderer_web:3000
|
||||
}
|
||||
}
|
||||
|
||||
memos.vakhrushev.me {
|
||||
tls anwinged@ya.ru
|
||||
|
||||
reverse_proxy {
|
||||
to memos_app:5230
|
||||
}
|
||||
}
|
||||
|
||||
wanderbase.vakhrushev.me {
|
||||
tls anwinged@ya.ru
|
||||
|
||||
forward_auth authelia_app:9091 {
|
||||
uri /api/authz/forward-auth
|
||||
copy_headers Remote-User Remote-Groups Remote-Email Remote-Name
|
||||
}
|
||||
|
||||
reverse_proxy {
|
||||
to wanderer_db:8090
|
||||
}
|
||||
}
|
||||
|
||||
rssbridge.vakhrushev.me {
|
||||
tls anwinged@ya.ru
|
||||
|
||||
forward_auth authelia_app:9091 {
|
||||
uri /api/authz/forward-auth
|
||||
copy_headers Remote-User Remote-Groups Remote-Email Remote-Name
|
||||
}
|
||||
|
||||
reverse_proxy {
|
||||
to rssbridge_app:80
|
||||
}
|
||||
}
|
||||
|
||||
dozzle.vakhrushev.me {
|
||||
tls anwinged@ya.ru
|
||||
|
||||
forward_auth authelia_app:9091 {
|
||||
uri /api/authz/forward-auth
|
||||
copy_headers Remote-User Remote-Groups Remote-Email Remote-Name Remote-Filter
|
||||
}
|
||||
|
||||
reverse_proxy dozzle_app:8080
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
# -------------------------------------------------------------------
|
||||
# Global options
|
||||
# -------------------------------------------------------------------
|
||||
{
|
||||
grace_period 15s
|
||||
|
||||
admin :2019
|
||||
|
||||
# Enable metrics in Prometheus format
|
||||
# https://caddyserver.com/docs/metrics
|
||||
metrics
|
||||
}
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Snippets
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
# Shared access log for all sites; consumed by GoAccess.
|
||||
# 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 {
|
||||
output file /var/log/caddy/access.log {
|
||||
mode 644
|
||||
roll_size 100mib
|
||||
roll_keep 10
|
||||
roll_keep_for 720h
|
||||
}
|
||||
format json
|
||||
}
|
||||
}
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Applications
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
vakhrushev.me {
|
||||
tls anwinged@ya.ru
|
||||
import access_log
|
||||
|
||||
# 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
|
||||
handle /.well-known/matrix/server {
|
||||
header Content-Type application/json
|
||||
header Access-Control-Allow-Origin *
|
||||
respond `{"m.server": "matrix.vakhrushev.me:443"}`
|
||||
}
|
||||
|
||||
handle /.well-known/matrix/client {
|
||||
header Content-Type application/json
|
||||
header Access-Control-Allow-Origin *
|
||||
respond `{"m.homeserver": {"base_url": "https://matrix.vakhrushev.me"}}`
|
||||
}
|
||||
|
||||
handle {
|
||||
reverse_proxy {
|
||||
to homepage_app:80
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
matrix.vakhrushev.me {
|
||||
tls anwinged@ya.ru
|
||||
import access_log
|
||||
|
||||
reverse_proxy {
|
||||
to tuwunel_app:6167
|
||||
}
|
||||
}
|
||||
|
||||
auth.vakhrushev.me {
|
||||
tls anwinged@ya.ru
|
||||
import access_log
|
||||
|
||||
reverse_proxy authelia_app:9091
|
||||
}
|
||||
|
||||
status.vakhrushev.me, :29999 {
|
||||
tls anwinged@ya.ru
|
||||
import access_log
|
||||
|
||||
forward_auth authelia_app:9091 {
|
||||
uri /api/authz/forward-auth
|
||||
copy_headers Remote-User Remote-Groups Remote-Email Remote-Name
|
||||
}
|
||||
|
||||
reverse_proxy netdata:19999
|
||||
}
|
||||
|
||||
git.vakhrushev.me {
|
||||
tls anwinged@ya.ru
|
||||
import access_log
|
||||
|
||||
reverse_proxy {
|
||||
to gitea_app:3000
|
||||
}
|
||||
}
|
||||
|
||||
outline.vakhrushev.me {
|
||||
tls anwinged@ya.ru
|
||||
import access_log
|
||||
|
||||
reverse_proxy {
|
||||
to outline_app:3000
|
||||
}
|
||||
}
|
||||
|
||||
gramps.vakhrushev.me {
|
||||
tls anwinged@ya.ru
|
||||
import access_log
|
||||
|
||||
reverse_proxy {
|
||||
to gramps_app:5000
|
||||
}
|
||||
}
|
||||
|
||||
miniflux.vakhrushev.me {
|
||||
tls anwinged@ya.ru
|
||||
import access_log
|
||||
|
||||
reverse_proxy {
|
||||
to miniflux_app:8080
|
||||
}
|
||||
}
|
||||
|
||||
wakapi.vakhrushev.me {
|
||||
tls anwinged@ya.ru
|
||||
import access_log
|
||||
|
||||
reverse_proxy {
|
||||
to wakapi_app:3000
|
||||
}
|
||||
}
|
||||
|
||||
wanderer.vakhrushev.me {
|
||||
tls anwinged@ya.ru
|
||||
import access_log
|
||||
|
||||
reverse_proxy {
|
||||
to wanderer_web:3000
|
||||
}
|
||||
}
|
||||
|
||||
memos.vakhrushev.me {
|
||||
tls anwinged@ya.ru
|
||||
import access_log
|
||||
|
||||
reverse_proxy {
|
||||
to memos_app:5230
|
||||
}
|
||||
}
|
||||
|
||||
remembos.vakhrushev.me {
|
||||
tls anwinged@ya.ru
|
||||
import access_log
|
||||
|
||||
forward_auth authelia_app:9091 {
|
||||
uri /api/authz/forward-auth
|
||||
copy_headers Remote-User Remote-Groups Remote-Email Remote-Name
|
||||
}
|
||||
|
||||
reverse_proxy {
|
||||
to remembos_app:8080
|
||||
}
|
||||
}
|
||||
|
||||
calibre.vakhrushev.me {
|
||||
tls anwinged@ya.ru
|
||||
import access_log
|
||||
|
||||
reverse_proxy {
|
||||
to calibre_web_app:8083
|
||||
}
|
||||
}
|
||||
|
||||
wanderbase.vakhrushev.me {
|
||||
tls anwinged@ya.ru
|
||||
import access_log
|
||||
|
||||
forward_auth authelia_app:9091 {
|
||||
uri /api/authz/forward-auth
|
||||
copy_headers Remote-User Remote-Groups Remote-Email Remote-Name
|
||||
}
|
||||
|
||||
reverse_proxy {
|
||||
to wanderer_db:8090
|
||||
}
|
||||
}
|
||||
|
||||
rssbridge.vakhrushev.me {
|
||||
tls anwinged@ya.ru
|
||||
import access_log
|
||||
|
||||
forward_auth authelia_app:9091 {
|
||||
uri /api/authz/forward-auth
|
||||
copy_headers Remote-User Remote-Groups Remote-Email Remote-Name
|
||||
}
|
||||
|
||||
reverse_proxy {
|
||||
to rssbridge_app:80
|
||||
}
|
||||
}
|
||||
|
||||
dozzle.vakhrushev.me {
|
||||
tls anwinged@ya.ru
|
||||
import access_log
|
||||
|
||||
forward_auth authelia_app:9091 {
|
||||
uri /api/authz/forward-auth
|
||||
copy_headers Remote-User Remote-Groups Remote-Email Remote-Name Remote-Filter
|
||||
}
|
||||
|
||||
reverse_proxy dozzle_app:8080
|
||||
}
|
||||
|
||||
goaccess.vakhrushev.me {
|
||||
tls anwinged@ya.ru
|
||||
import access_log
|
||||
|
||||
forward_auth authelia_app:9091 {
|
||||
uri /api/authz/forward-auth
|
||||
copy_headers Remote-User Remote-Groups Remote-Email Remote-Name
|
||||
}
|
||||
|
||||
@websocket {
|
||||
header Connection *Upgrade*
|
||||
header Upgrade websocket
|
||||
}
|
||||
reverse_proxy @websocket goaccess_processor:7890
|
||||
|
||||
reverse_proxy goaccess_app:8080
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
services:
|
||||
caddyproxy:
|
||||
image: caddy:2.11.3
|
||||
restart: unless-stopped
|
||||
container_name: "caddyproxy"
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
- "443:443/udp"
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
volumes:
|
||||
- "{{ caddy_file_dir }}:/etc/caddy"
|
||||
- "{{ data_dir }}:/data"
|
||||
- "{{ config_dir }}:/config"
|
||||
- "{{ caddy_logs_dir }}:/var/log/caddy"
|
||||
networks:
|
||||
- "web_proxy_network"
|
||||
|
||||
networks:
|
||||
web_proxy_network:
|
||||
external: true
|
||||
@@ -1,22 +0,0 @@
|
||||
services:
|
||||
|
||||
{{ service_name }}:
|
||||
image: caddy:2.10.2
|
||||
restart: unless-stopped
|
||||
container_name: {{ service_name }}
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
- "443:443/udp"
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
volumes:
|
||||
- {{ caddy_file_dir }}:/etc/caddy
|
||||
- {{ data_dir }}:/data
|
||||
- {{ config_dir }}:/config
|
||||
networks:
|
||||
- "web_proxy_network"
|
||||
|
||||
networks:
|
||||
web_proxy_network:
|
||||
external: true
|
||||
@@ -0,0 +1,23 @@
|
||||
services:
|
||||
|
||||
calibre_web_app:
|
||||
image: lscr.io/linuxserver/calibre-web:0.6.26
|
||||
container_name: calibre_web_app
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- "web_proxy_network"
|
||||
volumes:
|
||||
- "{{ config_dir }}:/config"
|
||||
- "{{ books_dir }}:/books:ro"
|
||||
environment:
|
||||
- "PUID={{ owner_create_result.uid }}"
|
||||
- "PGID={{ owner_create_result.group }}"
|
||||
- TZ=Etc/UTC
|
||||
# - DOCKER_MODS=linuxserver/mods:universal-calibre #optional
|
||||
# - OAUTHLIB_RELAX_TOKEN_SCOPE=1 #optional
|
||||
# ports:
|
||||
# - 8083:8083
|
||||
|
||||
networks:
|
||||
web_proxy_network:
|
||||
external: true
|
||||
@@ -1,7 +1,6 @@
|
||||
services:
|
||||
|
||||
dozzle_app:
|
||||
image: amir20/dozzle:v8.14.11
|
||||
image: amir20/dozzle:v10.6.2
|
||||
container_name: dozzle_app
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
|
||||
@@ -7,7 +7,7 @@ echo "Gitea: backup data with gitea dump"
|
||||
|
||||
(cd "{{ base_dir }}" && \
|
||||
docker compose exec \
|
||||
-u "{{ user_create_result.uid }}:{{ user_create_result.group }}" \
|
||||
-u "{{ owner_create_result.uid }}:{{ owner_create_result.group }}" \
|
||||
-w /backups gitea_app \
|
||||
gitea dump -c /data/gitea/conf/app.ini \
|
||||
)
|
||||
@@ -1,21 +1,20 @@
|
||||
services:
|
||||
|
||||
gitea_app:
|
||||
image: gitea/gitea:1.25.3
|
||||
image: gitea/gitea:1.26.2
|
||||
restart: unless-stopped
|
||||
container_name: gitea_app
|
||||
ports:
|
||||
- "2222:22"
|
||||
volumes:
|
||||
- {{ data_dir }}:/data
|
||||
- {{ backups_dir }}:/backups
|
||||
- /etc/timezone:/etc/timezone:ro
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- "{{ data_dir }}:/data"
|
||||
- "{{ backups_dir }}:/backups"
|
||||
- "/etc/timezone:/etc/timezone:ro"
|
||||
- "/etc/localtime:/etc/localtime:ro"
|
||||
networks:
|
||||
- "web_proxy_network"
|
||||
environment:
|
||||
- "USER_UID={{ user_create_result.uid }}"
|
||||
- "USER_GID={{ user_create_result.group }}"
|
||||
- "USER_UID={{ owner_create_result.uid }}"
|
||||
- "USER_GID={{ owner_create_result.group }}"
|
||||
- "GITEA__server__SSH_PORT=2222"
|
||||
|
||||
# Mailer
|
||||
@@ -0,0 +1,8 @@
|
||||
FROM allinurl/goaccess:1.10.2
|
||||
|
||||
RUN apk add --no-cache jq
|
||||
|
||||
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
RUN chmod 0755 /usr/local/bin/entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||
@@ -0,0 +1,40 @@
|
||||
services:
|
||||
goaccess_processor:
|
||||
build: .
|
||||
image: local/goaccess-jq:1.10.2
|
||||
container_name: goaccess_processor
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
user: "{{ app_owner_uid }}:{{ app_owner_gid }}"
|
||||
command:
|
||||
- --log-format=COMBINED
|
||||
- --enable-panel=VIRTUAL_HOSTS
|
||||
- --real-time-html
|
||||
- --port=7890
|
||||
- --ws-url=wss://goaccess.vakhrushev.me:443
|
||||
- --output=/srv/report/index.html
|
||||
- --persist
|
||||
- --restore
|
||||
- --db-path=/srv/db
|
||||
- --no-global-config
|
||||
volumes:
|
||||
- "{{ caddy_logs_dir }}:/srv/logs:ro"
|
||||
- "{{ db_dir }}:/srv/db"
|
||||
- "{{ report_dir }}:/srv/report"
|
||||
networks:
|
||||
- "web_proxy_network"
|
||||
|
||||
goaccess_app:
|
||||
image: caddy:2.11.3
|
||||
container_name: goaccess_app
|
||||
restart: unless-stopped
|
||||
user: "{{ app_owner_uid }}:{{ app_owner_gid }}"
|
||||
command: caddy file-server --listen :8080 --root /srv --browse
|
||||
volumes:
|
||||
- "{{ report_dir }}:/srv:ro"
|
||||
networks:
|
||||
- "web_proxy_network"
|
||||
|
||||
networks:
|
||||
web_proxy_network:
|
||||
external: true
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/bin/sh
|
||||
# Tail Caddy's JSON access log, transform each entry into Apache CLF
|
||||
# Combined with the virtual host glued to the request URI, and feed
|
||||
# the stream straight into goaccess via stdin. Result: every line in
|
||||
# the Requests panel renders as `host.example.com/path`.
|
||||
|
||||
set -eu
|
||||
|
||||
ACCESS_LOG="/srv/logs/access.log"
|
||||
|
||||
JQ_FILTER='
|
||||
"\(.request.remote_ip // "-") - - " +
|
||||
"[\((.ts // 0) | gmtime | strftime("%d/%b/%Y:%H:%M:%S +0000"))] " +
|
||||
"\"\(.request.method) \(.request.host)\(.request.uri) \(.request.proto)\" " +
|
||||
"\(.status) \(.size) " +
|
||||
"\"\(.request.headers.Referer[0]? // "-")\" " +
|
||||
"\"\(.request.headers["User-Agent"][0]? // "-")\""
|
||||
'
|
||||
|
||||
tail -F -n +1 "$ACCESS_LOG" \
|
||||
| jq --unbuffered -rc "$JQ_FILTER" \
|
||||
| exec goaccess - "$@"
|
||||
@@ -1,9 +1,8 @@
|
||||
# See versions: https://github.com/gramps-project/gramps-web/pkgs/container/grampsweb
|
||||
|
||||
services:
|
||||
|
||||
gramps_app: &gramps_app
|
||||
image: ghcr.io/gramps-project/grampsweb:25.11.2
|
||||
image: ghcr.io/gramps-project/grampsweb:26.6.0
|
||||
container_name: gramps_app
|
||||
depends_on:
|
||||
- gramps_redis
|
||||
|
||||
Executable → Regular
+6
-2
@@ -9,7 +9,9 @@ def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Rename Gramps document files by appending extensions from a list."
|
||||
)
|
||||
parser.add_argument("directory", type=Path, help="Directory containing hashed files")
|
||||
parser.add_argument(
|
||||
"directory", type=Path, help="Directory containing hashed files"
|
||||
)
|
||||
parser.add_argument("names_file", type=Path, help="Text file with target names")
|
||||
return parser.parse_args()
|
||||
|
||||
@@ -33,7 +35,9 @@ def rename_files(directory: Path, names: list[str]) -> None:
|
||||
for name in names:
|
||||
hash_part, dot, _ = name.partition(".")
|
||||
if not dot:
|
||||
print(f"Skipping invalid entry (missing extension): {name}", file=sys.stderr)
|
||||
print(
|
||||
f"Skipping invalid entry (missing extension): {name}", file=sys.stderr
|
||||
)
|
||||
continue
|
||||
|
||||
source = directory / hash_part
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ import os
|
||||
import argparse
|
||||
|
||||
|
||||
def main():
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Retain specified number of files in a directory sorted by name, delete others."
|
||||
)
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
services:
|
||||
|
||||
memos_app:
|
||||
image: neosmemo/memos:0.25.3
|
||||
image: neosmemo/memos:0.29.1
|
||||
container_name: memos_app
|
||||
restart: unless-stopped
|
||||
user: "{{ user_create_result.uid }}:{{ user_create_result.group }}"
|
||||
user: "{{ owner_create_result.uid }}:{{ owner_create_result.group }}"
|
||||
networks:
|
||||
- "web_proxy_network"
|
||||
volumes:
|
||||
|
||||
@@ -5,7 +5,7 @@ services:
|
||||
miniflux_app:
|
||||
image: miniflux/miniflux:2.2.10
|
||||
container_name: miniflux_app
|
||||
user: "{{ user_create_result.uid }}:{{ user_create_result.group }}"
|
||||
user: "{{ owner_create_result.uid }}:{{ owner_create_result.group }}"
|
||||
depends_on:
|
||||
miniflux_postgres:
|
||||
condition: service_healthy
|
||||
@@ -36,7 +36,7 @@ services:
|
||||
miniflux_postgres:
|
||||
image: postgres:16.3-bookworm
|
||||
container_name: miniflux_postgres
|
||||
user: "{{ user_create_result.uid }}:{{ user_create_result.group }}"
|
||||
user: "{{ owner_create_result.uid }}:{{ owner_create_result.group }}"
|
||||
restart: 'unless-stopped'
|
||||
environment:
|
||||
- POSTGRES_USER={{ miniflux_postgres_user }}
|
||||
@@ -50,7 +50,12 @@ services:
|
||||
- "{{ secrets_dir }}:/secrets:ro"
|
||||
- "{{ postgres_data_dir }}:/var/lib/postgresql/data"
|
||||
healthcheck:
|
||||
test: ["CMD", "pg_isready", "--username={{ miniflux_postgres_user }}", "--dbname={{ miniflux_postgres_database }}"]
|
||||
test: [
|
||||
"CMD",
|
||||
"pg_isready",
|
||||
"--username={{ miniflux_postgres_user }}",
|
||||
"--dbname={{ miniflux_postgres_database }}",
|
||||
]
|
||||
interval: 10s
|
||||
start_period: 30s
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
services:
|
||||
|
||||
netdata:
|
||||
image: netdata/netdata:v2.8.4
|
||||
image: netdata/netdata:v2.10.3
|
||||
container_name: netdata
|
||||
restart: unless-stopped
|
||||
cap_add:
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# Resource alerts for a low-spec home server.
|
||||
# Overrides stock alerts where thresholds differ; baseline RAM use is ~80%, so stock 80/90% would fire constantly.
|
||||
|
||||
# RAM: warn at >92%, crit at >95% — by then less than ~200 MB free.
|
||||
alarm: ram_in_use
|
||||
on: system.ram
|
||||
class: Utilization
|
||||
type: System
|
||||
component: Memory
|
||||
calc: $used * 100 / ($used + $cached + $free + $buffers)
|
||||
units: %
|
||||
every: 10s
|
||||
warn: $this > 92
|
||||
crit: $this > 95
|
||||
delay: down 5m multiplier 1.5 max 1h
|
||||
summary: System memory utilization
|
||||
info: System memory utilization (used / total, excluding reclaimable cache)
|
||||
to: sysadmin
|
||||
|
||||
# CPU: replace stock 10min_cpu_usage with two windowed alerts.
|
||||
alarm: 10min_cpu_usage
|
||||
on: system.cpu
|
||||
enabled: no
|
||||
|
||||
alarm: cpu_warn_30m
|
||||
on: system.cpu
|
||||
class: Utilization
|
||||
type: System
|
||||
component: CPU
|
||||
lookup: average -30m unaligned of user,system,softirq,irq,guest,guest_nice,nice
|
||||
units: %
|
||||
every: 1m
|
||||
warn: $this > 80
|
||||
delay: down 30m multiplier 1.5 max 2h
|
||||
summary: Sustained CPU load (30m avg)
|
||||
info: Average CPU utilization over the last 30 minutes
|
||||
to: sysadmin
|
||||
|
||||
alarm: cpu_crit_15m
|
||||
on: system.cpu
|
||||
class: Utilization
|
||||
type: System
|
||||
component: CPU
|
||||
lookup: average -15m unaligned of user,system,softirq,irq,guest,guest_nice,nice
|
||||
units: %
|
||||
every: 1m
|
||||
crit: $this > 95
|
||||
delay: down 30m multiplier 1.5 max 2h
|
||||
summary: High CPU load (15m avg)
|
||||
info: Average CPU utilization over the last 15 minutes
|
||||
to: sysadmin
|
||||
|
||||
# Disk: warn at >75%, crit at >90% on every mounted filesystem.
|
||||
template: disk_space_usage
|
||||
on: disk.space
|
||||
class: Utilization
|
||||
type: System
|
||||
component: Disk
|
||||
calc: $used * 100 / ($avail + $used)
|
||||
units: %
|
||||
every: 1m
|
||||
warn: $this > 75
|
||||
crit: $this > 90
|
||||
delay: down 15m multiplier 1.5 max 1h
|
||||
summary: Disk space utilization
|
||||
info: Disk space utilization on ${label:mount_point}
|
||||
to: sysadmin
|
||||
@@ -0,0 +1,45 @@
|
||||
# Override stock health_alarm_notify.conf — route every alert to apprise.
|
||||
# Stock conf is sourced first; this only sets what differs.
|
||||
|
||||
SEND_EMAIL="NO"
|
||||
SEND_CUSTOM="YES"
|
||||
DEFAULT_RECIPIENT_CUSTOM="server"
|
||||
|
||||
role_recipients_custom[sysadmin]="server"
|
||||
role_recipients_custom[domainadmin]="server"
|
||||
role_recipients_custom[dba]="server"
|
||||
role_recipients_custom[webmaster]="server"
|
||||
role_recipients_custom[proxyadmin]="server"
|
||||
role_recipients_custom[silent]=""
|
||||
|
||||
custom_sender() {
|
||||
local apprise_url="http://apprise:8000/notify/${1}/"
|
||||
|
||||
local notif_type="info"
|
||||
case "${status}" in
|
||||
CRITICAL) notif_type="failure" ;;
|
||||
WARNING) notif_type="warning" ;;
|
||||
CLEAR) notif_type="success" ;;
|
||||
esac
|
||||
|
||||
local title="[${status}] ${name} on ${host}"
|
||||
local body="${status_message}: ${alarm}
|
||||
Chart: ${chart}
|
||||
Value: ${value} ${units}
|
||||
Info: ${info}
|
||||
Raised for: ${raised_for}"
|
||||
|
||||
local httpcode
|
||||
httpcode=$(docurl -X POST \
|
||||
--data-urlencode "title=${title}" \
|
||||
--data-urlencode "body=${body}" \
|
||||
--data-urlencode "type=${notif_type}" \
|
||||
"${apprise_url}")
|
||||
|
||||
if [ "${httpcode}" = "200" ]; then
|
||||
info "sent custom notification for ${name} on ${host}"
|
||||
return 0
|
||||
fi
|
||||
error "failed to send notification for ${name} on ${host} (HTTP ${httpcode})"
|
||||
return 1
|
||||
}
|
||||
@@ -3,8 +3,9 @@ services:
|
||||
# See sample https://github.com/outline/outline/blob/main/.env.sample
|
||||
|
||||
outline_app:
|
||||
image: outlinewiki/outline:1.1.0
|
||||
image: outlinewiki/outline:1.8.1
|
||||
container_name: outline_app
|
||||
user: "{{ owner_create_result.uid }}:{{ owner_create_result.group }}"
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- outline_postgres
|
||||
@@ -12,6 +13,8 @@ services:
|
||||
networks:
|
||||
- "outline_network"
|
||||
- "web_proxy_network"
|
||||
volumes:
|
||||
- "{{ media_dir }}:/var/lib/outline/data"
|
||||
environment:
|
||||
NODE_ENV: 'production'
|
||||
URL: 'https://outline.vakhrushev.me'
|
||||
@@ -22,16 +25,8 @@ services:
|
||||
PGSSLMODE: 'disable'
|
||||
REDIS_URL: 'redis://outline_redis:6379'
|
||||
|
||||
FILE_STORAGE: 's3'
|
||||
FILE_STORAGE_UPLOAD_MAX_SIZE: '262144000'
|
||||
AWS_ACCESS_KEY_ID: '{{ outline_s3_access_key }}'
|
||||
AWS_SECRET_ACCESS_KEY: '{{ outline_s3_secret_key }}'
|
||||
AWS_REGION: '{{ outline_s3_region }}'
|
||||
AWS_S3_ACCELERATE_URL: ''
|
||||
AWS_S3_UPLOAD_BUCKET_URL: '{{ outline_s3_url }}'
|
||||
AWS_S3_UPLOAD_BUCKET_NAME: '{{ outline_s3_bucket }}'
|
||||
AWS_S3_FORCE_PATH_STYLE: 'true'
|
||||
AWS_S3_ACL: 'private'
|
||||
FILE_STORAGE: 'local'
|
||||
FILE_STORAGE_UPLOAD_MAX_SIZE: '262144000' # 250 MB
|
||||
|
||||
OIDC_CLIENT_ID: '{{ outline_oidc_client_id | replace("$", "$$") }}'
|
||||
OIDC_CLIENT_SECRET: '{{ outline_oidc_client_secret | replace("$", "$$") }}'
|
||||
@@ -62,7 +57,7 @@ services:
|
||||
outline_postgres:
|
||||
image: postgres:16.3-bookworm
|
||||
container_name: outline_postgres
|
||||
user: "{{ user_create_result.uid }}:{{ user_create_result.group }}"
|
||||
user: "{{ owner_create_result.uid }}:{{ owner_create_result.group }}"
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- "/etc/passwd:/etc/passwd:ro"
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
# =============================================================================
|
||||
# Remembos — конфигурация
|
||||
# =============================================================================
|
||||
# Скопируйте этот файл в config.toml и заполните значения.
|
||||
|
||||
# =============================================================================
|
||||
# Подключение к Memos
|
||||
# =============================================================================
|
||||
[memos]
|
||||
|
||||
# Адрес инстанса Memos, включая протокол.
|
||||
# Пример: "https://memos.example.com"
|
||||
url = "http://memos_app:5230"
|
||||
|
||||
# Токен доступа к API.
|
||||
# Создаётся в Memos: Settings → Access Tokens.
|
||||
token = "{{ remembos_memos_token }}"
|
||||
|
||||
# Публичный адрес Memos (для ссылок на оригинальные заметки).
|
||||
# Если Memos и Remembos развёрнуты на одном сервере, внутренний адрес (url)
|
||||
# может отличаться от публичного. Если не указан — используется url.
|
||||
# Пример: "https://memos.example.com"
|
||||
public_url = "https://memos.vakhrushev.me"
|
||||
|
||||
# =============================================================================
|
||||
# База данных (SQLite)
|
||||
# =============================================================================
|
||||
[database]
|
||||
|
||||
# Путь к файлу базы данных SQLite.
|
||||
# В ней хранится история показов и кэш запросов.
|
||||
# Если файл не существует — будет создан автоматически.
|
||||
path = "/data/remembos.db"
|
||||
|
||||
# =============================================================================
|
||||
# Алгоритм поиска воспоминаний
|
||||
# Подробное описание: spec/SEARCH.md
|
||||
# =============================================================================
|
||||
[search]
|
||||
|
||||
# Минимальное количество дней, прежде чем одна и та же заметка
|
||||
# может быть показана повторно. Чем больше значение, тем реже
|
||||
# будут повторяться воспоминания, но при малом количестве заметок
|
||||
# это может привести к ситуации, когда нечего показать.
|
||||
cooldown_days = 90
|
||||
|
||||
# Ослабленный cooldown: используется как fallback, если с основным
|
||||
# cooldown не удалось найти ни одного кандидата.
|
||||
relaxed_cooldown_days = 30
|
||||
|
||||
# Максимальное количество заметок, запрашиваемых у Memos за один запрос.
|
||||
# Влияет на размер пула кандидатов внутри каждого уровня поиска.
|
||||
page_size = 50
|
||||
|
||||
# Насколько далеко в прошлое искать воспоминания (в годах).
|
||||
# Например, 10 означает поиск заметок за последние 10 лет.
|
||||
max_years_back = 10
|
||||
|
||||
# Предпочитать более старые воспоминания при выборе из кандидатов.
|
||||
# Если true — заметки из далёкого прошлого получают более высокий вес.
|
||||
# Если false — все кандидаты внутри уровня равноценны.
|
||||
prefer_older = true
|
||||
|
||||
# Веса уровней поиска (в процентах, сумма должна быть 100).
|
||||
# Определяют вероятность выбора каждого уровня при поиске.
|
||||
#
|
||||
# Tier 1 — точная дата в прошлые годы (самое ценное совпадение)
|
||||
# Tier 2 — тот же день месяца в прошлые месяцы
|
||||
# Tier 3 — та же неделя (±3 дня) в прошлые годы
|
||||
# Tier 4 — тот же месяц в прошлые годы
|
||||
# Tier 5 — тот же квартал в прошлые годы
|
||||
# Tier 6 — то же полугодие в прошлые годы
|
||||
# Tier 7 — недавнее прошлое (от 2 до 6 месяцев назад)
|
||||
[search.tier_weights]
|
||||
tier1 = 35
|
||||
tier2 = 15
|
||||
tier3 = 15
|
||||
tier4 = 12
|
||||
tier5 = 10
|
||||
tier6 = 5
|
||||
tier7 = 8
|
||||
|
||||
# =============================================================================
|
||||
# Telegram-бот
|
||||
# =============================================================================
|
||||
[telegram]
|
||||
|
||||
# Включить Telegram-бот для ежедневной отправки воспоминаний.
|
||||
enabled = true
|
||||
|
||||
# Токен бота, полученный от @BotFather.
|
||||
token = "{{ remembos_telegram_token }}"
|
||||
|
||||
# ID чата, в который бот отправляет воспоминания.
|
||||
# Можно узнать через @userinfobot или из логов бота при первом сообщении.
|
||||
chat_id = {{ remembos_telegram_chat_id }}
|
||||
|
||||
# Время ежедневной отправки воспоминания (формат HH:MM).
|
||||
# Используется часовой пояс, указанный в параметре timezone.
|
||||
send_at = "09:00"
|
||||
|
||||
# =============================================================================
|
||||
# Веб-приложение
|
||||
# =============================================================================
|
||||
[web]
|
||||
|
||||
# Адрес и порт, на котором запускается веб-сервер.
|
||||
listen = "0.0.0.0:8080"
|
||||
|
||||
# =============================================================================
|
||||
# Общие настройки
|
||||
# =============================================================================
|
||||
[general]
|
||||
|
||||
# Часовой пояс для определения «сегодняшнего дня» и времени отправки.
|
||||
# Формат — IANA (например, "Europe/Moscow", "Asia/Yekaterinburg").
|
||||
timezone = "Europe/Moscow"
|
||||
|
||||
# Уровень логирования: debug, info, warn, error.
|
||||
log_level = "info"
|
||||
|
||||
# Разрешить загрузку дополнительных воспоминаний (кнопка на веб-странице, /more в Telegram).
|
||||
# Полезно для тестирования. Каждое загруженное воспоминание записывается в историю показов.
|
||||
allow_load_more = true
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
services:
|
||||
|
||||
remembos_app:
|
||||
image: "{{ yc_container_registry_repository }}/remembos:v0.2.0"
|
||||
container_name: remembos_app
|
||||
restart: unless-stopped
|
||||
user: "{{ owner_create_result.uid }}:{{ owner_create_result.group }}"
|
||||
environment:
|
||||
- PUID={{ owner_create_result.uid }}
|
||||
- PGID={{ owner_create_result.group }}
|
||||
networks:
|
||||
- "web_proxy_network"
|
||||
volumes:
|
||||
- "{{ config_dir }}:/config:ro"
|
||||
- "{{ data_dir }}:/data"
|
||||
command:
|
||||
- "--config"
|
||||
- "/config/config.toml"
|
||||
|
||||
networks:
|
||||
web_proxy_network:
|
||||
external: true
|
||||
@@ -1,44 +1,44 @@
|
||||
$ANSIBLE_VAULT;1.1;AES256
|
||||
33396537353265633634336630353330653337623861373731613734663938633837613437366537
|
||||
3439383366633266623463366530626662346338393165630a663539313066663061353635666366
|
||||
61393437393131333166626165306563366661353338363138633239666566313330363331666537
|
||||
3763356535396334380a386362383436363732353234333033613133383264643934306432313335
|
||||
34646164323664636532663835306230386633316539373564383163346663376666633564326134
|
||||
30666135626637343963383766383836653135633739636261353666303666633566346562643962
|
||||
63376165636434343066306539653637343736323437653465656436323533636237643333326438
|
||||
35626239323530643066363533323039393237333338316135313838643464306161646635313062
|
||||
36386565626435373333393566393831366538363864313737306565343162316536353539333864
|
||||
63376264643566613266373665666363366662643262616634333132386535383731396462633430
|
||||
32343738343039616139343833366661303430383766376139636434616565356161396433643035
|
||||
37363165383935373937346464343738643430333764336264373931616332393964346566636638
|
||||
39303434343461326464623363323937396663376335316237373166306134636432376435663033
|
||||
34346436623435626363636237373965633139343661623135633764303862353465306235666563
|
||||
66653764666635636462636434663264646665383236343166643133613966366334653030653262
|
||||
38326437313939616332636638323033346139343732653933356239306132613665376163646164
|
||||
30316663643666633334653133613764396165646533636534613931663138666366316235396466
|
||||
61313964396264626339306135376635633133366433303033633363396132303938363638346333
|
||||
66326466326134313535393831343262363862663065323135643630316431336531373833316363
|
||||
64376338653366353031333836643137333736363534363164306331313337353663653961623665
|
||||
64626562366637336637353433303261303964633236356162363139396339396136393237643935
|
||||
34316266326561663834353762343766363933313463313263393063343562613933393361653861
|
||||
38363635323231666438366536626435373365323733663139666534636564623666356436346539
|
||||
63326436386436356636633637373738343032353664323736653939346234643165313461643833
|
||||
35666439613136396264313033336539313537613238393262306365656238396464373936616538
|
||||
64316365616464386638313331653030346330393665353539393834346135643434363736323135
|
||||
37663433326439356663633162616435313061353662373766633731636439636266666466613363
|
||||
39343930386534376330663230623832643933336235636166626534366664366562356165373764
|
||||
63343432323864366162376263656565646661633536666336643030363039616666343063386165
|
||||
37343238303034313832393538313632396261316232376635633732656663396631323261363433
|
||||
38373738363833323934353739643538376237316535623035383965613965636337646537326537
|
||||
64663837643632666334393634323264613139353332306263613165383733386662366333316139
|
||||
63373839346265366166333331353231663763306163323063613138323835313831303666306561
|
||||
39316666343761303464333535336361333462623363633333383363303134336139356436666165
|
||||
62616364373030613837353939363636653537373965613531636130383266643637333233316137
|
||||
39353866366239643265366162663031346439663234363935353138323739393337313835313062
|
||||
33373263326565383735366364316461323930336437623834356132346633636364313732383661
|
||||
66346634613762613037386238656334616430633037343066623463313035646339313638653137
|
||||
65643166316664626236633332326136303235623934306462643636373437373630346435633835
|
||||
66346364393236393563623032306631396561623263653236393939313333373635303365316638
|
||||
66373037333565323733656331636337336665363038353635383531386366633632363031623430
|
||||
31356461663438653736316464363231303938653932613561633139316361633461626361383132
|
||||
396436303634613135383839396566393135
|
||||
64636264303339643062343231346262316463353562396635623734643763376163383361623135
|
||||
3065326434613532376662343761323339316234356363630a386563613639363332623137653365
|
||||
30386363303332333566633737306335336162396366316133653837326264383966653762383037
|
||||
6466333038346436650a343238626637356532376133323464396666643061376363393466663838
|
||||
37313235336563643361316339316661356639343933396161333335356332363933656530393634
|
||||
34376632326135373864636232616163373738383165326338613037303364323530313766343038
|
||||
34366538633062623064626131376261663032666663306339663361663665303866373833646261
|
||||
32663931626266663064663066643866356532353363636365633139663930353764386436623539
|
||||
66313061393564303737306261383632303063313032613033336130376563386139353835303531
|
||||
65623864613639346238663434653361616563626639643437636638396230323232393065663839
|
||||
34383737653064343433313364663532363635326165623361303536373136666130306266383237
|
||||
61653939326137356139363535353666356265336536393763656136353661636336633231366132
|
||||
62303065663233306130316435333364313039366362393762383463313035333034623730643931
|
||||
63323035633838303530313361323966346437656366386430316631303637376431396261343166
|
||||
31313734643831376633383065373436633136633261373838633662633433363363616162323233
|
||||
34666564333637316266623439383934363862336238613436356531373834643262653463326634
|
||||
64306530383338613161303138313038393433306430663331343033393832323532376261653838
|
||||
33373463636533356134633030393965386131353034323734303934636462363863386231353534
|
||||
65303739643338653265313864633632376461373766343536626464303332636332346531303165
|
||||
64396363323465393736633937363435663662346136613636643265353830616563623838613632
|
||||
65613565363139323431653463363461353666313464656664656331633263333766353666346138
|
||||
37366561366262356239366133616266643032636239363238643237383663633433383365626238
|
||||
61336632613763616439373730633532316362623663646365303336383531633438323837323939
|
||||
32313962313264303435633736346565326438626238356361353264353666643165653535303336
|
||||
31633137396465363035373137636162366165323130396631373865393638376335313838396138
|
||||
37316263663535376664383764343030623138363137356465316664336564636166313163353566
|
||||
30656636626163333138346639323465396531666664396231326136653430343061393234366266
|
||||
35386534633131666166353938343066343830613133643833303338656165393439373038336638
|
||||
34373436313931666234393530353536353866343330616133653563303764363962333361353639
|
||||
61316365373565313865393364356361313063303333303063623435323336316534643937316466
|
||||
30633664353131336531336332323862653566363635623965373238313965353434343733303239
|
||||
34313836353233653333336130376532386265383762383163386264396231623938616162363861
|
||||
64363938366665626666383033356566623765363737643565643964326135666566383866366563
|
||||
39643532306134346562346665656431656366383564376135633536313965613738333535376137
|
||||
31663566336664373436613866396434623133663361343564623535646462366636616236396661
|
||||
34663866323835373438623533353833663261663736646335316564383339343363626264343630
|
||||
65346434303763343763383337306463376235663361643037636231323139303239363532303439
|
||||
34316133326639623035653532346130633263376531623130616239626433343131333064333632
|
||||
35626531353562396462633639653534373537356666343266396565623137306232656633303335
|
||||
35316432643633643139616264316636383364316432373533373535323762353035346434343166
|
||||
39356266643137663365613832313765376462623032366332363563306536353736333461643930
|
||||
38633666333330323433373532313030316130346464616565333265376533303564303638376536
|
||||
373863396332313264373733323437303130
|
||||
|
||||
@@ -4,7 +4,7 @@ services:
|
||||
# noinspection ComposeUnknownValues
|
||||
image: "{{ registry_transcriber_image }}"
|
||||
container_name: transcriber_app
|
||||
user: "{{ user_create_result.uid }}:{{ user_create_result.group }}"
|
||||
user: "{{ owner_create_result.uid }}:{{ owner_create_result.group }}"
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- "{{ config_file }}:/config/config.toml:ro"
|
||||
@@ -13,8 +13,8 @@ services:
|
||||
- "web_proxy_network"
|
||||
- "monitoring_network"
|
||||
environment:
|
||||
- "USER_UID={{ user_create_result.uid }}"
|
||||
- "USER_GID={{ user_create_result.group }}"
|
||||
- "USER_UID={{ owner_create_result.uid }}"
|
||||
- "USER_GID={{ owner_create_result.group }}"
|
||||
command: ./transcriber --config=/config/config.toml
|
||||
|
||||
networks:
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# See versions: https://github.com/matrix-construct/tuwunel/releases
|
||||
# Configuration reference: https://github.com/matrix-construct/tuwunel/blob/main/tuwunel-example.toml
|
||||
|
||||
services:
|
||||
|
||||
tuwunel_app:
|
||||
image: jevolk/tuwunel:v1.6.1
|
||||
container_name: tuwunel_app
|
||||
restart: unless-stopped
|
||||
user: "{{ owner_create_result.uid }}:{{ owner_create_result.group }}"
|
||||
networks:
|
||||
- "web_proxy_network"
|
||||
volumes:
|
||||
- "{{ data_dir }}:/var/lib/tuwunel"
|
||||
environment:
|
||||
TUWUNEL_SERVER_NAME: "{{ tuwunel_server_name }}"
|
||||
TUWUNEL_DATABASE_PATH: "/var/lib/tuwunel"
|
||||
TUWUNEL_ADDRESS: "0.0.0.0"
|
||||
TUWUNEL_PORT: "6167"
|
||||
TUWUNEL_MAX_REQUEST_SIZE: "20000000"
|
||||
|
||||
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.
|
||||
# The canonical delegation is served by Caddy on {{ tuwunel_server_name }} (see Caddyfile).
|
||||
TUWUNEL_WELL_KNOWN_SERVER: "{{ tuwunel_well_known_server }}"
|
||||
TUWUNEL_WELL_KNOWN_CLIENT: "{{ tuwunel_well_known_client }}"
|
||||
|
||||
TUWUNEL_LOG: "info"
|
||||
|
||||
networks:
|
||||
web_proxy_network:
|
||||
external: true
|
||||
@@ -3,10 +3,10 @@
|
||||
services:
|
||||
|
||||
wakapi_app:
|
||||
image: ghcr.io/muety/wakapi:2.16.1
|
||||
image: ghcr.io/muety/wakapi:2.17.4
|
||||
container_name: wakapi_app
|
||||
restart: unless-stopped
|
||||
user: '{{ user_create_result.uid }}:{{ user_create_result.group }}'
|
||||
user: '{{ owner_create_result.uid }}:{{ owner_create_result.group }}'
|
||||
networks:
|
||||
- "web_proxy_network"
|
||||
volumes:
|
||||
@@ -7,7 +7,7 @@ services:
|
||||
wanderer_search:
|
||||
container_name: wanderer_search
|
||||
image: getmeili/meilisearch:v1.20.0
|
||||
user: "{{ user_create_result.uid }}:{{ user_create_result.group }}"
|
||||
user: "{{ owner_create_result.uid }}:{{ owner_create_result.group }}"
|
||||
environment:
|
||||
<<: *cenv
|
||||
MEILI_NO_ANALYTICS: "true"
|
||||
@@ -28,7 +28,7 @@ services:
|
||||
wanderer_db:
|
||||
container_name: wanderer_db
|
||||
image: "flomp/wanderer-db:{{ wanderer_version }}"
|
||||
user: "{{ user_create_result.uid }}:{{ user_create_result.group }}"
|
||||
user: "{{ owner_create_result.uid }}:{{ owner_create_result.group }}"
|
||||
depends_on:
|
||||
wanderer_search:
|
||||
condition: service_healthy
|
||||
@@ -54,7 +54,7 @@ services:
|
||||
wanderer_web:
|
||||
container_name: wanderer_web
|
||||
image: "flomp/wanderer-web:{{ wanderer_version }}"
|
||||
user: "{{ user_create_result.uid }}:{{ user_create_result.group }}"
|
||||
user: "{{ owner_create_result.uid }}:{{ owner_create_result.group }}"
|
||||
depends_on:
|
||||
wanderer_search:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
# Must be executed for every user
|
||||
# See https://cloud.yandex.ru/docs/container-registry/tutorials/run-docker-on-vm#run
|
||||
|
||||
set -eu
|
||||
|
||||
curl --silent --show-error -H Metadata-Flavor:Google 169.254.169.254/computeMetadata/v1/instance/service-accounts/default/token | \
|
||||
cut -f1 -d',' | \
|
||||
cut -f2 -d':' | \
|
||||
tr -d '"' | \
|
||||
docker login --username iam --password-stdin cr.yandex
|
||||
+23
-9
@@ -9,17 +9,31 @@ templates:
|
||||
pre-commit:
|
||||
jobs:
|
||||
|
||||
- name: "format python"
|
||||
glob: "**/*.py"
|
||||
run: "uv run ruff format {staged_files}"
|
||||
stage_fixed: true
|
||||
|
||||
- name: "check python"
|
||||
glob: "**/*.py"
|
||||
run: "uv run ruff check {staged_files}"
|
||||
|
||||
- name: "mypy"
|
||||
glob: "**/*.py"
|
||||
run: "uv run mypy {staged_files}"
|
||||
|
||||
- name: "yamllint"
|
||||
glob: "**/*.{yml,yaml}"
|
||||
run: "uv run yamllint --config-file .yamllint.yml --format colored {staged_files}"
|
||||
|
||||
- name: "ansible-lint"
|
||||
glob: "**/*.{yml,yaml}"
|
||||
exclude:
|
||||
- ".gitea/**"
|
||||
run: "uv run ansible-lint --profile production --offline -- {staged_files}"
|
||||
|
||||
- name: "gitleaks"
|
||||
run: "gitleaks git --staged"
|
||||
|
||||
- name: "check secret files"
|
||||
run: "python3 {av-hooks-dir}/pre-commit/check-secrets-encrypted-with-ansible-vault.py"
|
||||
|
||||
- name: "format python"
|
||||
glob: "**/*.py"
|
||||
run: "black --quiet {staged_files}"
|
||||
stage_fixed: true
|
||||
|
||||
- name: "mypy"
|
||||
glob: "**/*.py"
|
||||
run: "mypy {staged_files}"
|
||||
|
||||
@@ -1,48 +1,63 @@
|
||||
---
|
||||
- name: 'Configure netdata'
|
||||
- name: "Configure netdata"
|
||||
ansible.builtin.import_playbook: playbook-netdata.yml
|
||||
|
||||
#
|
||||
|
||||
- name: 'Configure dozzle'
|
||||
- name: "Configure dozzle"
|
||||
ansible.builtin.import_playbook: playbook-dozzle.yml
|
||||
|
||||
- name: 'Configure gitea'
|
||||
- name: "Configure gitea"
|
||||
ansible.builtin.import_playbook: playbook-gitea.yml
|
||||
|
||||
- name: 'Configure gramps'
|
||||
- name: "Configure gramps"
|
||||
ansible.builtin.import_playbook: playbook-gramps.yml
|
||||
|
||||
- name: 'Configure memos'
|
||||
- name: "Configure memos"
|
||||
ansible.builtin.import_playbook: playbook-memos.yml
|
||||
|
||||
- name: 'Configure miniflux'
|
||||
- name: "Configure miniflux"
|
||||
ansible.builtin.import_playbook: playbook-miniflux.yml
|
||||
|
||||
- name: 'Configure outline'
|
||||
- name: "Configure outline"
|
||||
ansible.builtin.import_playbook: playbook-outline.yml
|
||||
|
||||
- name: 'Configure rssbridge'
|
||||
- name: "Configure rssbridge"
|
||||
ansible.builtin.import_playbook: playbook-rssbridge.yml
|
||||
|
||||
- name: 'Configure wakapi'
|
||||
- name: "Configure wakapi"
|
||||
ansible.builtin.import_playbook: playbook-wakapi.yml
|
||||
|
||||
- name: 'Configure wanderer'
|
||||
- name: "Configure wanderer"
|
||||
ansible.builtin.import_playbook: playbook-wanderer.yml
|
||||
|
||||
- name: "Configure calibre"
|
||||
ansible.builtin.import_playbook: playbook-calibre.yml
|
||||
|
||||
- name: "Configure remembos"
|
||||
ansible.builtin.import_playbook: playbook-remembos.yml
|
||||
|
||||
- name: "Configure apprise"
|
||||
ansible.builtin.import_playbook: playbook-apprise.yml
|
||||
|
||||
- name: "Configure tuwunel"
|
||||
ansible.builtin.import_playbook: playbook-tuwunel.yml
|
||||
|
||||
#
|
||||
|
||||
- name: 'Configure homepage'
|
||||
- name: "Configure homepage"
|
||||
ansible.builtin.import_playbook: playbook-homepage.yml
|
||||
|
||||
- name: 'Configure transcriber'
|
||||
- name: "Configure transcriber"
|
||||
ansible.builtin.import_playbook: playbook-transcriber.yml
|
||||
|
||||
#
|
||||
|
||||
- name: 'Configure authelia'
|
||||
- name: "Configure authelia"
|
||||
ansible.builtin.import_playbook: playbook-authelia.yml
|
||||
|
||||
- name: 'Configure caddy proxy'
|
||||
- name: "Configure caddy proxy"
|
||||
ansible.builtin.import_playbook: playbook-caddyproxy.yml
|
||||
|
||||
- name: "Configure goaccess"
|
||||
ansible.builtin.import_playbook: playbook-goaccess.yml
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
- name: "Configure apprise application"
|
||||
hosts: all
|
||||
|
||||
vars_files:
|
||||
- vars/secrets.yml
|
||||
- vars/vars.yml
|
||||
|
||||
vars:
|
||||
app_name: "apprise"
|
||||
app_user: "{{ app_name }}"
|
||||
app_owner_uid: 1104
|
||||
app_owner_gid: 1104
|
||||
base_dir: "{{ (application_dir, app_name) | path_join }}"
|
||||
config_dir: "{{ (base_dir, 'config') | path_join }}"
|
||||
|
||||
tasks:
|
||||
- name: "Create user and environment"
|
||||
ansible.builtin.import_role:
|
||||
name: owner
|
||||
vars:
|
||||
owner_name: "{{ app_user }}"
|
||||
owner_uid: "{{ app_owner_uid }}"
|
||||
owner_gid: "{{ app_owner_gid }}"
|
||||
owner_extra_groups: ["docker"]
|
||||
|
||||
- name: "Create application internal directories"
|
||||
ansible.builtin.file:
|
||||
path: "{{ item }}"
|
||||
state: "directory"
|
||||
owner: "{{ app_user }}"
|
||||
group: "{{ app_user }}"
|
||||
mode: "0750"
|
||||
loop:
|
||||
- "{{ base_dir }}"
|
||||
- "{{ config_dir }}"
|
||||
|
||||
- name: "Copy apprise config"
|
||||
ansible.builtin.template:
|
||||
src: "./files/{{ app_name }}/server.template.cfg"
|
||||
dest: "{{ config_dir }}/server.cfg"
|
||||
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"
|
||||
|
||||
- name: "Run application with docker compose"
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: "{{ base_dir }}"
|
||||
state: "present"
|
||||
remove_orphans: true
|
||||
tags:
|
||||
- run-app
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
vars_files:
|
||||
- vars/secrets.yml
|
||||
- vars/vars.yml
|
||||
- files/authelia/secrets.yml
|
||||
|
||||
vars:
|
||||
|
||||
+22
-2
@@ -4,11 +4,15 @@
|
||||
|
||||
vars_files:
|
||||
- vars/secrets.yml
|
||||
- vars/vars.yml
|
||||
|
||||
vars:
|
||||
backup_config_dir: "/etc/backup"
|
||||
backup_config_file: "{{ (backup_config_dir, 'config.toml') | path_join }}"
|
||||
|
||||
rclone_config_dir: "/etc/rclone"
|
||||
rclone_config_file: "{{ (rclone_config_dir, 'rclone.conf') | path_join }}"
|
||||
|
||||
restic_shell_script: "{{ (bin_prefix, 'restic-shell.sh') | path_join }}"
|
||||
backup_all_script: "{{ (bin_prefix, 'backup-all.py') | path_join }}"
|
||||
|
||||
@@ -21,6 +25,22 @@
|
||||
group: root
|
||||
mode: "0755"
|
||||
|
||||
- name: "Create rclone config directory"
|
||||
ansible.builtin.file:
|
||||
path: "{{ rclone_config_dir }}"
|
||||
state: "directory"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0755"
|
||||
|
||||
- name: "Create rclone config file"
|
||||
ansible.builtin.template:
|
||||
src: "files/backups/rclone.template.conf"
|
||||
dest: "{{ rclone_config_file }}"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0640"
|
||||
|
||||
- name: "Create backup config file"
|
||||
ansible.builtin.template:
|
||||
src: "files/backups/config.template.toml"
|
||||
@@ -35,11 +55,11 @@
|
||||
state: present
|
||||
line: "{{ primary_user }} ALL=(ALL) NOPASSWD: {{ backup_all_script }}"
|
||||
validate: /usr/sbin/visudo -cf %s # ВАЖНО: проверка синтаксиса перед сохранением
|
||||
create: no # Файл уже должен существовать
|
||||
create: false # Файл уже должен существовать
|
||||
|
||||
- name: "Copy restic shell script"
|
||||
ansible.builtin.template:
|
||||
src: "files/backups/restic-shell.sh.j2"
|
||||
src: "files/backups/restic-shell.template.sh"
|
||||
dest: "{{ restic_shell_script }}"
|
||||
owner: root
|
||||
group: root
|
||||
|
||||
+32
-2
@@ -4,6 +4,7 @@
|
||||
|
||||
vars_files:
|
||||
- vars/secrets.yml
|
||||
- vars/vars.yml
|
||||
|
||||
vars:
|
||||
app_name: "caddyproxy"
|
||||
@@ -41,9 +42,38 @@
|
||||
- "{{ config_dir }}"
|
||||
- "{{ caddy_file_dir }}"
|
||||
|
||||
# Shared HTTP access log directory: caddy writes here, other
|
||||
# containers (goaccess, etc.) mount it read-only. Dir mode 0755
|
||||
# so anyone can list/read; the file mode itself comes from the
|
||||
# `mode 644` option in the Caddyfile log snippet.
|
||||
- name: "Create shared caddy logs directory"
|
||||
ansible.builtin.file:
|
||||
path: "{{ caddy_logs_dir }}"
|
||||
state: "directory"
|
||||
owner: "{{ app_user }}"
|
||||
group: "{{ app_user }}"
|
||||
mode: "0755"
|
||||
|
||||
- name: "Find pre-existing caddy log files"
|
||||
ansible.builtin.find:
|
||||
paths: "{{ caddy_logs_dir }}"
|
||||
file_type: "file"
|
||||
register: caddy_log_files
|
||||
|
||||
# Lumberjack created earlier files with 0600 before we set `mode`
|
||||
# in the Caddyfile; relax them so existing rotated archives stay
|
||||
# readable to consumers.
|
||||
- name: "Relax mode on pre-existing caddy log files"
|
||||
ansible.builtin.file:
|
||||
path: "{{ item.path }}"
|
||||
mode: "0644"
|
||||
loop: "{{ caddy_log_files.files }}"
|
||||
loop_control:
|
||||
label: "{{ item.path }}"
|
||||
|
||||
- name: "Copy caddy file"
|
||||
ansible.builtin.template:
|
||||
src: "./files/{{ app_name }}/Caddyfile.j2"
|
||||
src: "./files/{{ app_name }}/Caddyfile.template"
|
||||
dest: "{{ (caddy_file_dir, 'Caddyfile') | path_join }}"
|
||||
owner: "{{ app_user }}"
|
||||
group: "{{ app_user }}"
|
||||
@@ -51,7 +81,7 @@
|
||||
|
||||
- name: "Copy docker compose file"
|
||||
ansible.builtin.template:
|
||||
src: "./files/{{ app_name }}/docker-compose.yml.j2"
|
||||
src: "./files/{{ app_name }}/docker-compose.template.yml"
|
||||
dest: "{{ base_dir }}/docker-compose.yml"
|
||||
owner: "{{ app_user }}"
|
||||
group: "{{ app_user }}"
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
- name: "Configure calibre application"
|
||||
hosts: all
|
||||
|
||||
vars_files:
|
||||
- vars/secrets.yml
|
||||
- vars/vars.yml
|
||||
|
||||
vars:
|
||||
app_name: "calibre"
|
||||
app_user: "{{ app_name }}"
|
||||
app_owner_uid: 1102
|
||||
app_owner_gid: 1102
|
||||
base_dir: "{{ (application_dir, app_name) | path_join }}"
|
||||
config_dir: "{{ (base_dir, 'config') | path_join }}"
|
||||
books_dir: "{{ (base_dir, 'books') | path_join }}"
|
||||
|
||||
tasks:
|
||||
- name: "Create user and environment"
|
||||
ansible.builtin.import_role:
|
||||
name: owner
|
||||
vars:
|
||||
owner_name: "{{ app_user }}"
|
||||
owner_uid: "{{ app_owner_uid }}"
|
||||
owner_gid: "{{ app_owner_gid }}"
|
||||
owner_extra_groups: ["docker"]
|
||||
|
||||
- name: "Create application internal directories"
|
||||
ansible.builtin.file:
|
||||
path: "{{ item }}"
|
||||
state: "directory"
|
||||
owner: "{{ app_user }}"
|
||||
group: "{{ app_user }}"
|
||||
mode: "0750"
|
||||
loop:
|
||||
- "{{ base_dir }}"
|
||||
- "{{ books_dir }}"
|
||||
- "{{ config_dir }}"
|
||||
|
||||
- name: "Create backup targets file"
|
||||
ansible.builtin.lineinfile:
|
||||
path: "{{ base_dir }}/backup-targets"
|
||||
line: "{{ item }}"
|
||||
create: true
|
||||
owner: "{{ app_user }}"
|
||||
group: "{{ app_user }}"
|
||||
mode: "0750"
|
||||
loop:
|
||||
- "{{ books_dir }}"
|
||||
- "{{ config_dir }}"
|
||||
|
||||
- name: "Copy docker compose file"
|
||||
ansible.builtin.template:
|
||||
src: "./files/{{ app_name }}/docker-compose.template.yml"
|
||||
dest: "{{ base_dir }}/docker-compose.yml"
|
||||
owner: "{{ app_user }}"
|
||||
group: "{{ app_user }}"
|
||||
mode: "0640"
|
||||
|
||||
- name: "Run application with docker compose"
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: "{{ base_dir }}"
|
||||
state: "present"
|
||||
remove_orphans: true
|
||||
tags:
|
||||
- run-app
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
vars_files:
|
||||
- vars/secrets.yml
|
||||
- vars/vars.yml
|
||||
|
||||
tasks:
|
||||
# - name: "Install python docker lib from pip"
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
vars_files:
|
||||
- vars/secrets.yml
|
||||
- vars/vars.yml
|
||||
|
||||
vars:
|
||||
app_name: "dozzle"
|
||||
|
||||
+21
-5
@@ -4,6 +4,7 @@
|
||||
|
||||
vars_files:
|
||||
- vars/secrets.yml
|
||||
- vars/vars.yml
|
||||
|
||||
# See: https://github.com/zyedidia/eget/releases
|
||||
|
||||
@@ -23,7 +24,7 @@
|
||||
ansible.builtin.command:
|
||||
cmd: >
|
||||
{{ eget_bin_path }} rclone/rclone --quiet --upgrade-only --to {{ eget_install_dir }} --asset zip
|
||||
--tag v1.72.0
|
||||
--tag v1.73.4
|
||||
changed_when: false
|
||||
|
||||
- name: "Install restic"
|
||||
@@ -33,11 +34,19 @@
|
||||
--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.5
|
||||
--tag v1.4.6
|
||||
changed_when: false
|
||||
|
||||
- name: "Install gobackup"
|
||||
@@ -51,12 +60,19 @@
|
||||
ansible.builtin.command:
|
||||
cmd: >
|
||||
{{ eget_bin_path }} go-task/task --quiet --upgrade-only --to {{ eget_install_dir }} --asset tar.gz
|
||||
--tag v3.45.5
|
||||
--tag v3.48.0
|
||||
changed_when: false
|
||||
|
||||
- name: 'Install dust'
|
||||
ansible.builtin.command:
|
||||
cmd: >
|
||||
{{ bin_prefix }}/eget bootandy/dust --quiet --upgrade-only --to {{ bin_prefix }} --asset gnu
|
||||
--tag v1.2.3
|
||||
{{ 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
|
||||
|
||||
+3
-2
@@ -4,6 +4,7 @@
|
||||
|
||||
vars_files:
|
||||
- vars/secrets.yml
|
||||
- vars/vars.yml
|
||||
|
||||
vars:
|
||||
app_name: "gitea"
|
||||
@@ -38,7 +39,7 @@
|
||||
|
||||
- name: "Copy backup script"
|
||||
ansible.builtin.template:
|
||||
src: "files/{{ app_name }}/backup.sh.j2"
|
||||
src: "files/{{ app_name }}/backup.template.sh"
|
||||
dest: "{{ base_dir }}/backup.sh"
|
||||
owner: "{{ app_user }}"
|
||||
group: "{{ app_user }}"
|
||||
@@ -46,7 +47,7 @@
|
||||
|
||||
- name: "Copy docker compose file"
|
||||
ansible.builtin.template:
|
||||
src: "./files/{{ app_name }}/docker-compose.yml.j2"
|
||||
src: "./files/{{ app_name }}/docker-compose.template.yml"
|
||||
dest: "{{ base_dir }}/docker-compose.yml"
|
||||
owner: "{{ app_user }}"
|
||||
group: "{{ app_user }}"
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
---
|
||||
- name: "Configure goaccess application"
|
||||
hosts: all
|
||||
|
||||
vars_files:
|
||||
- vars/secrets.yml
|
||||
- vars/vars.yml
|
||||
|
||||
vars:
|
||||
app_name: "goaccess"
|
||||
app_user: "{{ app_name }}"
|
||||
app_owner_uid: 1106
|
||||
app_owner_gid: 1106
|
||||
base_dir: "{{ (application_dir, app_name) | path_join }}"
|
||||
|
||||
db_dir: "{{ (base_dir, 'db') | path_join }}"
|
||||
report_dir: "{{ (base_dir, 'report') | path_join }}"
|
||||
|
||||
tasks:
|
||||
- name: "Create user and environment"
|
||||
ansible.builtin.import_role:
|
||||
name: owner
|
||||
vars:
|
||||
owner_name: "{{ app_user }}"
|
||||
owner_uid: "{{ app_owner_uid }}"
|
||||
owner_gid: "{{ app_owner_gid }}"
|
||||
owner_extra_groups: ["docker"]
|
||||
|
||||
- name: "Create internal application directories"
|
||||
ansible.builtin.file:
|
||||
path: "{{ item }}"
|
||||
state: "directory"
|
||||
owner: "{{ app_user }}"
|
||||
group: "{{ app_user }}"
|
||||
mode: "0770"
|
||||
loop:
|
||||
- "{{ base_dir }}"
|
||||
- "{{ db_dir }}"
|
||||
- "{{ report_dir }}"
|
||||
|
||||
# Earlier runs left root-owned files inside db/report (the
|
||||
# containers used to start as root). Recurse-chown realigns them
|
||||
# so the now-non-root processor can rewrite/restore them.
|
||||
- name: "Realign ownership of generated artefacts"
|
||||
ansible.builtin.file:
|
||||
path: "{{ item }}"
|
||||
state: "directory"
|
||||
owner: "{{ app_user }}"
|
||||
group: "{{ app_user }}"
|
||||
recurse: true
|
||||
loop:
|
||||
- "{{ db_dir }}"
|
||||
- "{{ report_dir }}"
|
||||
|
||||
# Owner/mode проставит caddyproxy при своём (позднем) прогоне.
|
||||
- name: "Ensure caddy logs directory exists"
|
||||
ansible.builtin.file:
|
||||
path: "{{ caddy_logs_dir }}"
|
||||
state: "directory"
|
||||
mode: "0755"
|
||||
|
||||
- name: "Ensure caddy access log exists before goaccess starts"
|
||||
ansible.builtin.copy:
|
||||
content: ""
|
||||
dest: "{{ (caddy_logs_dir, 'access.log') | path_join }}"
|
||||
force: false
|
||||
owner: "root"
|
||||
group: "root"
|
||||
mode: "0644"
|
||||
|
||||
- name: "Copy docker compose file"
|
||||
ansible.builtin.template:
|
||||
src: "./files/{{ app_name }}/docker-compose.template.yml"
|
||||
dest: "{{ base_dir }}/docker-compose.yml"
|
||||
owner: "{{ app_user }}"
|
||||
group: "{{ app_user }}"
|
||||
mode: "0640"
|
||||
|
||||
- name: "Copy Dockerfile and entrypoint for the local jq-enabled goaccess image"
|
||||
ansible.builtin.copy:
|
||||
src: "./files/{{ app_name }}/{{ item.name }}"
|
||||
dest: "{{ (base_dir, item.name) | path_join }}"
|
||||
owner: "{{ app_user }}"
|
||||
group: "{{ app_user }}"
|
||||
mode: "{{ item.mode }}"
|
||||
loop:
|
||||
- { name: "Dockerfile", mode: "0640" }
|
||||
- { name: "entrypoint.sh", mode: "0750" }
|
||||
|
||||
- name: "Run application with docker compose"
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: "{{ base_dir }}"
|
||||
state: "present"
|
||||
build: "always"
|
||||
remove_orphans: true
|
||||
tags:
|
||||
- run-app
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
vars_files:
|
||||
- vars/secrets.yml
|
||||
- vars/vars.yml
|
||||
|
||||
vars:
|
||||
app_name: "gramps"
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
|
||||
vars_files:
|
||||
- vars/secrets.yml
|
||||
- vars/vars.yml
|
||||
- vars/homepage.yml
|
||||
- vars/homepage.images.yml
|
||||
|
||||
tasks:
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
|
||||
vars_files:
|
||||
- vars/secrets.yml
|
||||
- vars/vars.yml
|
||||
- vars/homepage.yml
|
||||
- vars/homepage.images.yml
|
||||
|
||||
tasks:
|
||||
- name: "Create user and environment"
|
||||
@@ -27,9 +27,11 @@
|
||||
loop:
|
||||
- "{{ base_dir }}"
|
||||
|
||||
- name: "Login to yandex docker registry."
|
||||
ansible.builtin.script:
|
||||
cmd: "files/yandex-docker-registry-auth.sh"
|
||||
- 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:
|
||||
@@ -44,5 +46,6 @@
|
||||
project_src: "{{ base_dir }}"
|
||||
state: "present"
|
||||
remove_orphans: true
|
||||
pull: "always"
|
||||
tags:
|
||||
- run-app
|
||||
|
||||
+4
-2
@@ -4,6 +4,7 @@
|
||||
|
||||
vars_files:
|
||||
- vars/secrets.yml
|
||||
- vars/vars.yml
|
||||
|
||||
vars:
|
||||
app_name: "memos"
|
||||
@@ -39,7 +40,7 @@
|
||||
|
||||
- name: "Copy gobackup config"
|
||||
ansible.builtin.template:
|
||||
src: "./files/{{ app_name }}/gobackup.yml.j2"
|
||||
src: "./files/{{ app_name }}/gobackup.template.yml"
|
||||
dest: "{{ gobackup_config }}"
|
||||
owner: "{{ app_user }}"
|
||||
group: "{{ app_user }}"
|
||||
@@ -47,7 +48,7 @@
|
||||
|
||||
- name: "Copy backup script"
|
||||
ansible.builtin.template:
|
||||
src: "files/{{ app_name }}/backup.sh.j2"
|
||||
src: "files/{{ app_name }}/backup.template.sh"
|
||||
dest: "{{ base_dir }}/backup.sh"
|
||||
owner: "{{ app_user }}"
|
||||
group: "{{ app_user }}"
|
||||
@@ -72,6 +73,7 @@
|
||||
owner: "{{ app_user }}"
|
||||
group: "{{ app_user }}"
|
||||
mode: "0640"
|
||||
register: docker_compose_file_result
|
||||
|
||||
- name: "Run application with docker compose"
|
||||
community.docker.docker_compose_v2:
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
vars_files:
|
||||
- vars/secrets.yml
|
||||
- vars/vars.yml
|
||||
|
||||
vars:
|
||||
app_name: "miniflux"
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
vars_files:
|
||||
- vars/secrets.yml
|
||||
- vars/vars.yml
|
||||
|
||||
vars:
|
||||
app_name: "netdata"
|
||||
@@ -13,6 +14,7 @@
|
||||
base_dir: "{{ (application_dir, app_name) | path_join }}"
|
||||
config_dir: "{{ (base_dir, 'config') | path_join }}"
|
||||
config_go_d_dir: "{{ (config_dir, 'go.d') | path_join }}"
|
||||
config_health_d_dir: "{{ (config_dir, 'health.d') | path_join }}"
|
||||
data_dir: "{{ (base_dir, 'data') | path_join }}"
|
||||
|
||||
tasks:
|
||||
@@ -37,6 +39,7 @@
|
||||
- "{{ data_dir }}"
|
||||
- "{{ config_dir }}"
|
||||
- "{{ config_go_d_dir }}"
|
||||
- "{{ config_health_d_dir }}"
|
||||
|
||||
- name: "Copy netdata config file"
|
||||
ansible.builtin.template:
|
||||
@@ -75,6 +78,43 @@
|
||||
loop: "{{ go_d_existing_files.files }}"
|
||||
when: (item.path | basename) not in (go_d_source_files.files | map(attribute='path') | map('basename') | list)
|
||||
|
||||
- name: "Find all health.d config files"
|
||||
ansible.builtin.find:
|
||||
paths: "files/{{ app_name }}/health.d"
|
||||
file_type: file
|
||||
delegate_to: localhost
|
||||
register: health_d_source_files
|
||||
|
||||
- name: "Template all health.d config files"
|
||||
ansible.builtin.template:
|
||||
src: "{{ item.path }}"
|
||||
dest: "{{ config_health_d_dir }}/{{ item.path | basename }}"
|
||||
owner: "{{ app_user }}"
|
||||
group: "{{ app_user }}"
|
||||
mode: "0640"
|
||||
loop: "{{ health_d_source_files.files }}"
|
||||
|
||||
- name: "Find existing health.d config files on server"
|
||||
ansible.builtin.find:
|
||||
paths: "{{ config_health_d_dir }}"
|
||||
file_type: file
|
||||
register: health_d_existing_files
|
||||
|
||||
- name: "Remove health.d config files that don't exist in source"
|
||||
ansible.builtin.file:
|
||||
path: "{{ item.path }}"
|
||||
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)
|
||||
|
||||
- name: "Copy health alarm notify config"
|
||||
ansible.builtin.template:
|
||||
src: "files/{{ app_name }}/health_alarm_notify.template.conf"
|
||||
dest: "{{ config_dir }}/health_alarm_notify.conf"
|
||||
owner: "{{ app_user }}"
|
||||
group: "{{ app_user }}"
|
||||
mode: "0640"
|
||||
|
||||
- name: "Grab docker group id."
|
||||
ansible.builtin.shell:
|
||||
cmd: |
|
||||
|
||||
+30
-10
@@ -4,16 +4,22 @@
|
||||
|
||||
vars_files:
|
||||
- vars/secrets.yml
|
||||
- vars/vars.yml
|
||||
|
||||
vars:
|
||||
app_name: "outline"
|
||||
app_user: "{{ app_name }}"
|
||||
app_owner_uid: 1007
|
||||
app_owner_gid: 1008
|
||||
|
||||
base_dir: "{{ (application_dir, app_name) | path_join }}"
|
||||
data_dir: "{{ (base_dir, 'data') | path_join }}"
|
||||
postgres_data_dir: "{{ (base_dir, 'data', 'postgres') | path_join }}"
|
||||
postgres_backups_dir: "{{ (base_dir, 'backups', 'postgres') | path_join }}"
|
||||
media_dir: "{{ (base_dir, 'media') | path_join }}"
|
||||
backups_dir: "{{ (base_dir, 'backups') | path_join }}"
|
||||
|
||||
postgres_data_dir: "{{ (data_dir, 'postgres') | path_join }}"
|
||||
postgres_backups_dir: "{{ (backups_dir, 'postgres') | path_join }}"
|
||||
|
||||
|
||||
tasks:
|
||||
- name: "Create user and environment"
|
||||
@@ -35,17 +41,11 @@
|
||||
loop:
|
||||
- "{{ base_dir }}"
|
||||
- "{{ data_dir }}"
|
||||
- "{{ media_dir }}"
|
||||
- "{{ backups_dir }}"
|
||||
- "{{ postgres_data_dir }}"
|
||||
- "{{ postgres_backups_dir }}"
|
||||
|
||||
- name: "Copy docker compose file"
|
||||
ansible.builtin.template:
|
||||
src: "./files/{{ app_name }}/docker-compose.template.yml"
|
||||
dest: "{{ base_dir }}/docker-compose.yml"
|
||||
owner: "{{ app_user }}"
|
||||
group: "{{ app_user }}"
|
||||
mode: "0640"
|
||||
|
||||
- name: "Copy backup script"
|
||||
ansible.builtin.template:
|
||||
src: "./files/{{ app_name }}/backup.template.sh"
|
||||
@@ -54,6 +54,26 @@
|
||||
group: "{{ app_user }}"
|
||||
mode: "0750"
|
||||
|
||||
- name: "Create backup targets file"
|
||||
ansible.builtin.lineinfile:
|
||||
path: "{{ base_dir }}/backup-targets"
|
||||
line: "{{ item }}"
|
||||
create: true
|
||||
owner: "{{ app_user }}"
|
||||
group: "{{ app_user }}"
|
||||
mode: "0750"
|
||||
loop:
|
||||
- "{{ media_dir }}"
|
||||
- "{{ backups_dir }}"
|
||||
|
||||
- name: "Copy docker compose file"
|
||||
ansible.builtin.template:
|
||||
src: "./files/{{ app_name }}/docker-compose.template.yml"
|
||||
dest: "{{ base_dir }}/docker-compose.yml"
|
||||
owner: "{{ app_user }}"
|
||||
group: "{{ app_user }}"
|
||||
mode: "0640"
|
||||
|
||||
- name: "Run application with docker compose"
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: "{{ base_dir }}"
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
- name: "Configure remembos application"
|
||||
hosts: all
|
||||
|
||||
vars_files:
|
||||
- vars/secrets.yml
|
||||
- vars/vars.yml
|
||||
|
||||
vars:
|
||||
app_name: "remembos"
|
||||
app_user: "{{ app_name }}"
|
||||
app_owner_uid: 1103
|
||||
app_owner_gid: 1103
|
||||
base_dir: "{{ (application_dir, app_name) | path_join }}"
|
||||
config_dir: "{{ (base_dir, 'config') | path_join }}"
|
||||
data_dir: "{{ (base_dir, 'data') | path_join }}"
|
||||
config_file: "{{ (config_dir, 'config.toml') | path_join }}"
|
||||
|
||||
tasks:
|
||||
- name: "Create user and environment"
|
||||
ansible.builtin.import_role:
|
||||
name: owner
|
||||
vars:
|
||||
owner_name: "{{ app_user }}"
|
||||
owner_uid: "{{ app_owner_uid }}"
|
||||
owner_gid: "{{ app_owner_gid }}"
|
||||
owner_extra_groups: ["docker"]
|
||||
|
||||
- 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 }}"
|
||||
- "{{ config_dir }}"
|
||||
|
||||
- name: "Copy config"
|
||||
ansible.builtin.template:
|
||||
src: "./files/{{ app_name }}/config.template.toml"
|
||||
dest: "{{ config_file }}"
|
||||
owner: "{{ app_user }}"
|
||||
group: "{{ app_user }}"
|
||||
mode: "0640"
|
||||
register: config_file_result
|
||||
|
||||
- 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"
|
||||
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:
|
||||
project_src: "{{ base_dir }}"
|
||||
state: "present"
|
||||
remove_orphans: true
|
||||
tags:
|
||||
- run-app
|
||||
|
||||
- name: "Restart docker compose services if config changed but not docker-compose.yml"
|
||||
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
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
vars_files:
|
||||
- vars/secrets.yml
|
||||
- vars/vars.yml
|
||||
|
||||
vars:
|
||||
user_name: "<put-name-here>"
|
||||
@@ -25,6 +26,11 @@
|
||||
path: "/var/www/{{ user_name }}"
|
||||
state: absent
|
||||
|
||||
- name: "Remove application dir"
|
||||
ansible.builtin.file:
|
||||
path: "{{ (application_dir, user_name) | path_join }}"
|
||||
state: absent
|
||||
|
||||
- name: "Remove home dir"
|
||||
ansible.builtin.file:
|
||||
path: "/home/{{ user_name }}"
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
vars_files:
|
||||
- vars/secrets.yml
|
||||
- vars/vars.yml
|
||||
|
||||
vars:
|
||||
app_name: "rssbridge"
|
||||
@@ -34,7 +35,7 @@
|
||||
|
||||
- name: "Copy docker compose file"
|
||||
ansible.builtin.template:
|
||||
src: "./files/{{ app_name }}/docker-compose.yml.j2"
|
||||
src: "./files/{{ app_name }}/docker-compose.template.yml"
|
||||
dest: "{{ base_dir }}/docker-compose.yml"
|
||||
owner: "{{ app_user }}"
|
||||
group: "{{ app_user }}"
|
||||
|
||||
+5
-3
@@ -4,6 +4,7 @@
|
||||
|
||||
vars_files:
|
||||
- vars/secrets.yml
|
||||
- vars/vars.yml
|
||||
|
||||
vars:
|
||||
apt_packages:
|
||||
@@ -40,9 +41,9 @@
|
||||
group: root
|
||||
mode: "0755"
|
||||
|
||||
- name: 'Create directory for mount'
|
||||
- name: 'Create directory for applications'
|
||||
ansible.builtin.file:
|
||||
path: '/mnt/applications'
|
||||
path: '{{ application_dir }}'
|
||||
state: 'directory'
|
||||
mode: '0755'
|
||||
tags:
|
||||
@@ -50,9 +51,10 @@
|
||||
|
||||
- name: 'Mount external storages'
|
||||
ansible.posix.mount:
|
||||
path: '/mnt/applications'
|
||||
path: '{{ application_dir }}'
|
||||
src: 'UUID=3942bffd-8328-4536-8e88-07926fb17d17'
|
||||
fstype: ext4
|
||||
state: mounted
|
||||
when: mount_external_storage | default(false) | bool
|
||||
tags:
|
||||
- mount-storage
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
vars_files:
|
||||
- vars/secrets.yml
|
||||
- vars/vars.yml
|
||||
- vars/transcriber.yml
|
||||
- vars/transcriber.images.yml
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
vars_files:
|
||||
- vars/secrets.yml
|
||||
- vars/vars.yml
|
||||
- vars/transcriber.yml
|
||||
- vars/transcriber.images.yml
|
||||
|
||||
@@ -38,9 +39,11 @@
|
||||
group: "{{ app_user }}"
|
||||
mode: "0600"
|
||||
|
||||
- name: "Login to yandex docker registry."
|
||||
ansible.builtin.script:
|
||||
cmd: "files/yandex-docker-registry-auth.sh"
|
||||
- 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:
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
- name: "Configure tuwunel matrix server"
|
||||
hosts: all
|
||||
|
||||
vars_files:
|
||||
- vars/secrets.yml
|
||||
- vars/vars.yml
|
||||
|
||||
vars:
|
||||
app_name: "tuwunel"
|
||||
app_user: "{{ app_name }}"
|
||||
app_owner_uid: 1105
|
||||
app_owner_gid: 1105
|
||||
base_dir: "{{ (application_dir, app_name) | path_join }}"
|
||||
data_dir: "{{ (base_dir, 'data') | path_join }}"
|
||||
backups_dir: "{{ (base_dir, 'backups') | path_join }}"
|
||||
|
||||
tuwunel_server_name: "vakhrushev.me"
|
||||
tuwunel_well_known_server: "matrix.vakhrushev.me:443"
|
||||
tuwunel_well_known_client: "https://matrix.vakhrushev.me"
|
||||
|
||||
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 }}"
|
||||
owner_extra_groups: ["docker"]
|
||||
|
||||
- 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 }}"
|
||||
- "{{ backups_dir }}"
|
||||
|
||||
- name: "Disable backup script"
|
||||
ansible.builtin.file:
|
||||
dest: "{{ base_dir }}/backup.sh"
|
||||
state: absent
|
||||
|
||||
- name: "Create backup targets file"
|
||||
ansible.builtin.lineinfile:
|
||||
path: "{{ base_dir }}/backup-targets"
|
||||
line: "{{ item }}"
|
||||
create: true
|
||||
owner: "{{ app_user }}"
|
||||
group: "{{ app_user }}"
|
||||
mode: "0750"
|
||||
loop:
|
||||
- "{{ data_dir }}"
|
||||
|
||||
- name: "Copy docker compose file"
|
||||
ansible.builtin.template:
|
||||
src: "./files/{{ app_name }}/docker-compose.template.yml"
|
||||
dest: "{{ base_dir }}/docker-compose.yml"
|
||||
owner: "{{ app_user }}"
|
||||
group: "{{ app_user }}"
|
||||
mode: "0640"
|
||||
|
||||
- name: "Run application with docker compose"
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: "{{ base_dir }}"
|
||||
state: "present"
|
||||
remove_orphans: true
|
||||
tags:
|
||||
- run-app
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
- name: "Configure UFW firewall"
|
||||
hosts: all
|
||||
|
||||
vars_files:
|
||||
- vars/secrets.yml
|
||||
- vars/vars.yml
|
||||
|
||||
tasks:
|
||||
- name: "Ensure UFW is installed"
|
||||
ansible.builtin.apt:
|
||||
name: ufw
|
||||
state: present
|
||||
update_cache: true
|
||||
|
||||
- name: "Set default incoming policy to deny"
|
||||
community.general.ufw:
|
||||
direction: incoming
|
||||
policy: deny
|
||||
|
||||
- name: "Set default outgoing policy to allow"
|
||||
community.general.ufw:
|
||||
direction: outgoing
|
||||
policy: allow
|
||||
|
||||
- name: "Allow SSH on port 22"
|
||||
community.general.ufw:
|
||||
rule: allow
|
||||
port: "22"
|
||||
proto: tcp
|
||||
|
||||
- name: "Allow Gitea SSH on port 2222"
|
||||
community.general.ufw:
|
||||
rule: allow
|
||||
port: "2222"
|
||||
proto: tcp
|
||||
|
||||
- name: "Allow HTTP on port 80/tcp"
|
||||
community.general.ufw:
|
||||
rule: allow
|
||||
port: "80"
|
||||
proto: tcp
|
||||
|
||||
- name: "Allow HTTPS on port 443/tcp"
|
||||
community.general.ufw:
|
||||
rule: allow
|
||||
port: "443"
|
||||
proto: tcp
|
||||
|
||||
- name: "Allow HTTPS QUIC on port 443/udp"
|
||||
community.general.ufw:
|
||||
rule: allow
|
||||
port: "443"
|
||||
proto: udp
|
||||
|
||||
- name: "Enable UFW"
|
||||
community.general.ufw:
|
||||
state: enabled
|
||||
logging: true
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
vars_files:
|
||||
- vars/secrets.yml
|
||||
- vars/vars.yml
|
||||
|
||||
tasks:
|
||||
- name: Perform an upgrade of packages
|
||||
|
||||
+4
-3
@@ -4,6 +4,7 @@
|
||||
|
||||
vars_files:
|
||||
- vars/secrets.yml
|
||||
- vars/vars.yml
|
||||
|
||||
vars:
|
||||
app_name: "wakapi"
|
||||
@@ -39,7 +40,7 @@
|
||||
|
||||
- name: "Copy gobackup config"
|
||||
ansible.builtin.template:
|
||||
src: "./files/{{ app_name }}/gobackup.yml.j2"
|
||||
src: "./files/{{ app_name }}/gobackup.template.yml"
|
||||
dest: "{{ gobackup_config }}"
|
||||
owner: "{{ app_user }}"
|
||||
group: "{{ app_user }}"
|
||||
@@ -47,7 +48,7 @@
|
||||
|
||||
- name: "Copy backup script"
|
||||
ansible.builtin.template:
|
||||
src: "files/{{ app_name }}/backup.sh.j2"
|
||||
src: "files/{{ app_name }}/backup.template.sh"
|
||||
dest: "{{ base_dir }}/backup.sh"
|
||||
owner: "{{ app_user }}"
|
||||
group: "{{ app_user }}"
|
||||
@@ -55,7 +56,7 @@
|
||||
|
||||
- name: "Copy docker compose file"
|
||||
ansible.builtin.template:
|
||||
src: "./files/{{ app_name }}/docker-compose.yml.j2"
|
||||
src: "./files/{{ app_name }}/docker-compose.template.yml"
|
||||
dest: "{{ base_dir }}/docker-compose.yml"
|
||||
owner: "{{ app_user }}"
|
||||
group: "{{ app_user }}"
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
vars_files:
|
||||
- vars/secrets.yml
|
||||
- vars/vars.yml
|
||||
|
||||
vars:
|
||||
app_name: "wanderer"
|
||||
|
||||
@@ -5,3 +5,5 @@ ungrouped:
|
||||
ansible_host: "158.160.46.255"
|
||||
ansible_user: "major"
|
||||
ansible_become: true
|
||||
application_dir: "/mnt/applications"
|
||||
mount_external_storage: true
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
[project]
|
||||
name = "pet-project-server"
|
||||
version = "0.1.0"
|
||||
description = "Add your description here"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"ansible>=13.2.0",
|
||||
"ansible-lint>=25.12.2",
|
||||
"invoke>=2.2.1",
|
||||
"mypy>=1.19.1",
|
||||
"requests>=2.32.5",
|
||||
"ruff>=0.15.2",
|
||||
"types-requests>=2.32.4.20260107",
|
||||
"yamllint>=1.37.1",
|
||||
]
|
||||
+11
-6
@@ -1,9 +1,14 @@
|
||||
---
|
||||
- src: yatesr.timezone
|
||||
version: 1.2.2
|
||||
roles:
|
||||
- src: 'yatesr.timezone'
|
||||
version: '1.2.2'
|
||||
|
||||
- src: geerlingguy.security
|
||||
version: 3.0.0
|
||||
- src: 'geerlingguy.security'
|
||||
version: '3.0.0'
|
||||
|
||||
- src: geerlingguy.docker
|
||||
version: 7.4.7
|
||||
- src: 'geerlingguy.docker'
|
||||
version: '7.9.0'
|
||||
|
||||
collections:
|
||||
- name: 'community.docker'
|
||||
- name: 'community.general'
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
groups: "{{ owner_extra_groups }}"
|
||||
uid: "{{ owner_uid }}"
|
||||
shell: /bin/bash
|
||||
register: user_create_result
|
||||
register: owner_create_result
|
||||
|
||||
- name: 'Set up user ssh keys for user "{{ owner_name }}".'
|
||||
ansible.posix.authorized_key:
|
||||
@@ -34,11 +34,12 @@
|
||||
|
||||
- name: "Prepare env variables."
|
||||
ansible.builtin.set_fact:
|
||||
env_dict: '{{ owner_env | combine({"USER_UID": user_create_result.uid, "USER_GID": user_create_result.group}) }}'
|
||||
# yamllint disable-line rule:line-length
|
||||
owner_env_dict: '{{ owner_env | combine({"USER_UID": owner_create_result.uid, "USER_GID": owner_create_result.group}) }}'
|
||||
|
||||
- name: 'Set up environment variables for user "{{ owner_name }}".'
|
||||
ansible.builtin.template:
|
||||
src: env.j2
|
||||
src: env.template
|
||||
dest: "/home/{{ owner_name }}/.env"
|
||||
owner: "{{ owner_name }}"
|
||||
group: "{{ owner_group }}"
|
||||
@@ -49,7 +50,7 @@
|
||||
path: "/home/{{ owner_name }}/.bashrc"
|
||||
regexp: "^export {{ item.key }}="
|
||||
state: absent
|
||||
with_dict: "{{ env_dict }}"
|
||||
with_dict: "{{ owner_env_dict }}"
|
||||
|
||||
- name: 'Include in bashrc environment variables for user "{{ owner_name }}".'
|
||||
ansible.builtin.lineinfile:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user