docs: AI project context (docs/ai) and repository documentation refresh

- docs/ai/: stable, repo-verified context - README, architecture, tech-stack,
  edge-cases, plan (confirmed active work only), migration-tofu (the blue-green
  OpenTofu migration runbook and per-service findings), legacy-warning, links.
- AGENTS.md: slimmed to a working contract that points at docs/ai instead of
  restating it; CLAUDE.md is an adapter that @-includes it.
- README.md, ansible/README.md, ansible/roles/README.md,
  roles/lxc_docker_host/README.md: bring wording in line with the current
  control plane (Makefile entry point, registry, tofu, memoir-bot gone).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uoq5AVK8mkBgg83Mq6o5V
This commit is contained in:
Dmitry
2026-09-03 07:06:10 +03:00
co-authored by Claude Sonnet 5
parent 5e27ba2513
commit d2e1e6876a
13 changed files with 1955 additions and 377 deletions
+117 -324
View File
@@ -1,361 +1,154 @@
# AGENTS.md # AGENTS.md
## Project Summary ## Назначение
**HomeLab infras** is a GitOps-like repository for managing home infrastructure through Ansible. HomeLab infras - Ansible-first control plane для домашней инфраструктуры на
Proxmox VE. Желаемое состояние хранится в inventory, vars, roles и playbooks;
прямые изменения на серверах допустимы только для read-only диагностики или
break-glass восстановления и затем должны быть отражены в Ansible.
- Management: `ansible/` (playbooks, roles, inventory) Стабильный контекст проекта находится в [`docs/ai/`](docs/ai/README.md). Не
- Documentation: Obsidian vault `/home/ada/Documents/Vaults/SecondBrain/02 Projects/HomeLab/` загружай весь набор по умолчанию: читай только документы, относящиеся к задаче.
- Active infrastructure: Proxmox VE cluster (`cloud-pc`, `mini-pc`), PBS, OpenVPN, ZeroTier
- Archive: `archive/2026-07-proxmox-migration/` (historical NixOS/Docker configs, reference only)
## Design Goal ## Что читать
**Ansible-first infrastructure**: HomeLab configuration is managed through Ansible inventory, roles and playbooks. Direct server changes are allowed only for break-glass recovery or read-only diagnostics; afterwards the intended state must be captured in Ansible. | Задача | Контекст |
|---|---|
| Любое изменение | `README.md`, [`docs/ai/README.md`](docs/ai/README.md), релевантная секция [`architecture.md`](docs/ai/architecture.md) |
| Inventory или новый service | `ansible/inventory/hosts.yml`, `ansible/inventory/group_vars/all/services.yml`, [`architecture.md`](docs/ai/architecture.md), [`edge-cases.md`](docs/ai/edge-cases.md) |
| Provisioning или update | `ansible/README.md`, relevant playbook/role, [`edge-cases.md`](docs/ai/edge-cases.md), [`legacy-warning.md`](docs/ai/legacy-warning.md) |
| Network, SSH, OpenVPN, Caddy | `ansible/ssh_config`, `ansible/inventory/group_vars/all/main.yml`, [`architecture.md`](docs/ai/architecture.md), [`links.md`](docs/ai/links.md) |
| Backups и recovery | `ansible/playbooks/pve-backup-jobs.yml`, `ansible/roles/backup_audit/`, [`architecture.md`](docs/ai/architecture.md), [`edge-cases.md`](docs/ai/edge-cases.md) |
| Grimmory MCP | `tools/grimmory-mcp/README.md`, [`tech-stack.md`](docs/ai/tech-stack.md), [`edge-cases.md`](docs/ai/edge-cases.md) |
| Legacy/frozen code | [`legacy-warning.md`](docs/ai/legacy-warning.md) |
| Переезд на OpenTofu | [`migration-tofu.md`](docs/ai/migration-tofu.md), `tofu/README.md`, `tofu/*.tf` |
| Текущая работа | [`plan.md`](docs/ai/plan.md); `current-task.md` является историческим планом |
## Obsidian Integration Перед инфраструктурными изменениями прочитай релевантные заметки в Obsidian:
Project documentation lives in `/home/ada/Documents/Vaults/SecondBrain/02 Projects/HomeLab/`. Use it as wiki: `/home/ada/Documents/Vaults/SecondBrain/02 Projects/HomeLab/`
- **Read** before making infra changes — context, decisions, constraints. Основные заметки: `HomeLab.md`,
- **Update** after making changes — document non-obvious decisions, new patterns, lessons learned. `Notes/Текущее состояние HomeLab после миграции на Proxmox.md`, `Log.md`.
- Key files: `HomeLab.md`, `Notes/Текущее состояние HomeLab после миграции на Proxmox.md`, `Log.md`. После изменения обнови соответствующую заметку, если изменились topology,
операционная процедура, решение или неочевидное ограничение.
When introducing infra changes, update the relevant Obsidian note to keep documentation in sync. ## Источники правды
## Operating Model - Hosts и groups: `ansible/inventory/hosts.yml`.
- Shared network/access vars: `ansible/inventory/group_vars/all/main.yml`.
- Service facts: `ansible/inventory/group_vars/all/services.yml`.
- SSH users, keys, ports и ProxyJump: `ansible/ssh_config`.
- Manual operations и safety gates: `ansible/Makefile`.
- Secrets: ignored `.env` в КОРНЕ репозитория; его читают и Make/Ansible, и OpenTofu.
- Human decisions и operations log: HomeLab Obsidian vault.
- User: passwords, SSH access, base network reachability. Программные потребители реестра:
- Agent: infrastructure changes via `ansible/` files.
- Prefer adding/updating a role or playbook over ad-hoc commands.
- For every new VM, LXC container, or managed host, provision the user's SSH public key by default unless explicitly told otherwise.
- Run smallest safe Ansible check before broader changes.
- Do not commit secrets (`.env`, tokens, keys, real passwords).
## Quick Start - `playbooks/reverse-proxy.yml` — сборка Caddyfile;
- `playbooks/ru-vps-base.yml` — стек Caddy и его закреплённый образ;
- `playbooks/pve-backup-jobs.yml` — списки VMID заданий PBS (поле `backup.job`);
- `roles/backup_audit` — VMID для аудита (флаг `monitoring.backup_audit_vmid`);
- `playbooks/validate.yml` — сверка реестра с фактическим состоянием Proxmox.
Окружение собрано в Nix — venv и системные пакеты не нужны. Остальное (`pve-*.yml`, `status.yml`, monitoring, SSH config) по-прежнему
дублирует значения и должно меняться согласованно.
`make validate` — read-only gate, проверяющий это расхождение.
## Границы
- Active control plane находится в `ansible/`.
- `archive/2026-07-proxmox-migration/` - только историческая справка; не редактировать
и не возвращать из него конфигурацию как active implementation.
- Не редактировать generated artifacts, `ansible/collections/`, `.venv/`,
`node_modules/`, `.direnv/` и secret-bearing ignored files.
- `roles/compose_service` подключён в `playbooks/ru-vps-base.yml` (стек Caddy).
`roles/lxc_docker_host` не подключён нигде.
Не считать active service playbooks устаревшими до отдельной миграции.
- Не исправлять найденный technical debt в несвязанной задаче без отдельного решения.
- Для новой VM/LXC/managed host по умолчанию provision public SSH key пользователя,
если пользователь явно не указал иное.
## Setup и команды
Предпочтительное окружение - Nix:
```bash ```bash
nix develop # или один раз: direnv allow nix develop # или один раз: direnv allow
ansible-galaxy collection install \
# Один раз на клон: galaxy-коллекции -r ansible/requirements.yml \
ansible-galaxy collection install -r ansible/requirements.yml -p ansible/collections -p ansible/collections # один раз на clone
``` ```
Дальше всё управление идёт через `make` из каталога `ansible/`: Работай через Make из `ansible/` или с `make -C ansible` из root:
```bash ```bash
cd ansible make -C ansible help
make help # список всех целей — начинать отсюда make -C ansible check
make check # связность и ожидаемые IP make -C ansible status EXTRA="--limit '!gyro'"
make status # сводное состояние всей инфраструктуры (read-only) make -C ansible inventory
make lint # ansible-lint + yamllint make -C ansible docs
make docs # актуальная таблица хостов из inventory make -C ansible lint
make deploy-gitea # playbooks/pve-gitea.yml, .env подхватывается сам
make dry-gitea # то же в режиме --check --diff
make update-gitea # бэкап -> обновление -> health-check
``` ```
`make` без аргументов печатает `help`. Опасные цели (`mihomo-harden`, `monitoring`, Локальный syntax-check всех playbooks, соответствующий CI:
`update-all`) требуют явного `CONFIRM=1`. Дополнительные флаги — через `EXTRA`:
```bash ```bash
make status EXTRA="--limit '!gyro'" nix develop -c sh -c \
'cd ansible && for f in playbooks/*.yml; do ansible-playbook --syntax-check "$f"; done'
``` ```
`gyro` использует шифрованный `host_vars/gyro/vault.yml`, поэтому для него нужен Grimmory MCP требует отдельный Node.js `>=22` runtime:
`--ask-vault-pass` (цель `make gyro` добавляет флаг сама).
### SSH руками
Транспорт описан в `ansible/ssh_config` — один источник правды и для Ansible,
и для терминала. Чтобы заработал `ssh gitea`, добавь в `~/.ssh/config`:
```
Include /home/ada/Documents/Projects/HomeLab/infras/ansible/ssh_config
```
SSH берёт первое совпадение: Include в начале файла — побеждают настройки репозитория,
в конце — личные записи из `~/.ssh/config.d/`. На Ansible порядок не влияет, он ходит
с явным `-F`.
## Active Infrastructure
Каноничный источник — `ansible/inventory/hosts.yml` и реестр сервисов
`ansible/inventory/group_vars/all/services.yml`. Актуальную таблицу всегда можно
получить командой `make docs`, поэтому здесь — только опорная картина.
### Nodes
| Name | Role | LAN IP | Notes |
|---|---|---:|---|
| `ru-vps` | Public VPS, JumpHost, qdevice, OpenVPN server, Caddy | `157.22.231.198:3422` | OpenVPN `10.78.0.1` |
| `cloud-pc` | Proxmox VE node, storage | `192.168.1.5` | Main node |
| `mini-pc` | Proxmox VE node | `192.168.1.10` | Secondary node |
| `pbs` | Proxmox Backup Server LXC (CT 120) | `192.168.1.20` | Прямой доступ, без ProxyJump |
| `ovpn-mini` | OpenVPN gateway LXC (CT 132) | `192.168.1.23` | OpenVPN `10.78.0.2`, без ProxyJump |
| `vaultwarden` | Vaultwarden LXC (CT 140) | `192.168.1.24` | `pass.ada-dev.ru` |
| `gitea` | Gitea LXC (CT 141) | `192.168.1.25` | `git.ada-dev.ru`, SSH `:2222` |
| `memoir-bot` | Telegram memoir bot LXC (CT 142) | `192.168.1.26` | образ собирается локально |
| `mihomo` | Local proxy/UI LXC (CT 143) | `192.168.1.27` | UI `:8080`, API `:9090`, proxy `:7890/:7891` |
| `adguard` | AdGuard Home LXC (CT 144) | `192.168.1.28` | DNS `:53`, UI `:3000` |
| `docker-test` | Песочница/кандидат в CI-раннеры (CT 145) | `192.168.1.29` | |
| `monitoring` | Monitoring LXC (CT 146) | `192.168.1.30` | Uptime Kuma активен, Prometheus заморожен |
| `hermes-ai` | AI-хост (CT 147) | `192.168.1.31` | ходит наружу через mihomo |
| `emergency-bot` | Telegram-бот break-glass доступа (CT 148) | `192.168.1.32` | |
| `grimmory` | Grimmory + MariaDB (CT 149) | `192.168.1.34` | `books.ada-dev.ru` |
| `gyro` | Investment allocator (CT 150) | `192.168.1.35` | vault-переменные, outbound-only |
### Inventory Groups
- `homelab` — ru-vps
- `pve_nodes` — cloud-pc, mini-pc
- `lxc_infra` — все LXC (13 шт.)
- `monitoring_server` — monitoring
- `monitoring_exporters` — хосты с Node Exporter
- `monitoring_smart_exporters` — cloud-pc, mini-pc
- `vpn_openvpn` — ru-vps, ovpn-mini
- `shell_hosts` — ru-vps, cloud-pc, mini-pc, hermes-ai
- `servers` — все управляемые хосты
Общие переменные живут в `inventory/group_vars/`, а не в `hosts.yml`:
`group_vars/all/main.yml` (сеть, доступ, OpenVPN), `group_vars/lxc_infra/main.yml`
(LXC ходят под root без sudo), `group_vars/all/services.yml` (реестр сервисов).
### Transport
- **OpenVPN**: ru-vps (10.78.0.1) ↔ ovpn-mini (10.78.0.2), порт 8443/tcp
- **JumpHost**: SSH к нодам и LXC через ProxyJump `ru-vps`; исключения — `pbs` и
`ovpn-mini`, они доступны напрямую по LAN
- Всё это описано в `ansible/ssh_config`; Ansible подключает его через
`ansible_ssh_common_args` в `group_vars/all/main.yml` (путь считается от
`inventory_dir`, поэтому не зависит от cwd и места клона)
## Directory Structure
```
flake.nix / .envrc # Nix dev-окружение (ansible, линтеры, python-зависимости)
.ansible-lint / .yamllint # Конфигурация линтеров
.gitea/workflows/lint.yml # CI: yamllint + ansible-lint + syntax-check
ansible/
├── Makefile # ЕДИНАЯ точка входа для ручного управления (make help)
├── ansible.cfg
├── ssh_config # Транспорт: ProxyJump, пользователи, ключи
├── scripts/
│ └── gen-inventory-docs.py
├── inventory/
│ ├── hosts.yml # Только адреса и индивидуальные факты хостов
│ ├── group_vars/
│ │ ├── all/main.yml # Общие переменные
│ │ ├── all/services.yml # Реестр сервисов homelab_services
│ │ └── lxc_infra/main.yml
│ └── host_vars/gyro/ # main.yml + шифрованный vault.yml
├── playbooks/
│ ├── check.yml # Связность и ожидаемые IP
│ ├── status.yml # Read-only сводка по всей инфраструктуре
│ ├── reverse-proxy.yml # Единый Caddy-плейбук по реестру сервисов
│ ├── pve-*.yml # Создание LXC (нужен .env)
│ ├── *-update.yml # Обновления: бэкап -> апдейт -> health-check
│ └── openvpn-*.yml
├── roles/
│ ├── lxc_docker_host/ # LXC под Docker: пакеты, fuse-overlayfs, ufw
│ ├── compose_service/ # compose.yml + systemd-юнит + health-check
│ ├── pve_lxc/ # Создание LXC через Proxmox API
│ ├── backup_audit/ # Аудит PBS и restic
│ ├── monitoring_*/ # Prometheus-стек (заморожен), экспортеры
│ ├── uptime_kuma/ # Активный мониторинг
│ ├── emergency_access/ # Break-glass reverse SSH
│ ├── emergency_bot/ # Telegram-бот break-glass
│ ├── gyro/ # Investment allocator
│ ├── openvpn_gateway/ # OpenVPN транспорт
│ └── bash_config/ # Единый bash-конфиг
└── tasks/
archive/2026-07-proxmox-migration/ # Историческое, только как справка
```
Роли `lxc_docker_host` и `compose_service` созданы, но пока не подключены ни к одному
плейбуку — миграция `pve-*.yml` на них не выполнена. Пример использования и перечень
того, что меняется на живом хосте при переходе, — в `roles/compose_service/README.md`.
## Common Playbooks
Предпочитай `make` — он сам грузит `.env` и защищает опасные цели.
```bash ```bash
make check # связность и ожидаемые IP npm install --prefix tools/grimmory-mcp
make status # read-only сводка: хосты, юниты, диски, VPN, бэкапы npm test --prefix tools/grimmory-mcp
make deploy-<service> # playbooks/pve-<service>.yml
make dry-<service> # то же с --check --diff
make update-<service> # gitea | vaultwarden | adguard | mihomo | grimmory
make reverse-proxy # Caddy на ru-vps по реестру сервисов
make backup-audit # аудит PBS + offsite restic
make openvpn-check # проверка транспорта ru-vps <-> ovpn-mini
make bash-config # единый bash-конфиг на shell_hosts
make user-ssh-key # разложить публичный ключ на все хосты
``` ```
Обновления всегда идут в порядке: свежий бэкап/аудит -> обновление -> health-check. ## Safety Contract
Образы закреплены по `tag@sha256:digest`; floating-теги и авто-апдейтеры не используются.
## Proxmox API Tasks
Плейбукам `pve-*.yml` нужны переменные Proxmox API. Держи их в `ansible/.env` - Сначала выполняй smallest safe local validation, затем bounded live check только
(файл в `.gitignore`, шаблон — `.env.example`): когда он нужен задаче.
- `make dry-<service>` использует `--check --diff`, но не является полной симуляцией
Proxmox API или command-heavy `pct` playbooks.
- `make status` read-only, но его exit code не является health gate.
- `update-all`, `mihomo-harden` и frozen `monitoring` требуют `CONFIRM=1`.
- `gyro` требует Vault password; не обходи `make gyro` без явной причины.
- Service update order: fresh backup/audit -> update -> health check. Backup не
означает automatic rollback.
- Uptime Kuma активен. Prometheus/Alertmanager/Grafana stack заморожен; не запускать
его параллельно без отдельного architecture decision.
- Active remote images обычно pin по `tag@sha256:digest`; frozen Prometheus images
tag-only. Floating auto-updaters не используются.
- Не включать cluster-wide PVE firewall без аудита всех guests с `firewall=1`.
- Не останавливать production services для fault injection без согласованного
maintenance window.
```bash ## Secrets
PROXMOX_HOST=<host>
PROXMOX_USER=<user@realm>
PROXMOX_TOKEN_ID=<token_id>
PROXMOX_TOKEN_SECRET=<secret>
PROXMOX_VALIDATE_CERTS=false
```
`make` подхватывает `.env` сам и падает с внятным сообщением, если его нет — - Никогда не коммить и не цитировать реальные passwords, tokens, private keys,
руками `. ./.env` делать не нужно: `.env`, Vault plaintext, PBS/restic credentials или TLS keys.
- Используй ignored `.env` в корне репозитория, Ansible Vault, runtime prompt или
external local secret file.
- `PROXMOX_ROOT_PASSWORD` (root@pam) нужен ТОЛЬКО целям `tofu-*` и только для
привилегированных полей LXC. Ansible им не пользуется. Не логировать и не
передавать в playbook vars.
- В Git допустимы только sanitized `.env.example` и encrypted Vault content.
- Secret-bearing tasks должны использовать `no_log: true`, а files - минимальные
permissions.
```bash ## Рабочий процесс
make env-check # проверить, что .env на месте и заполнен
make dry-gitea # сначала всегда --check --diff
make deploy-gitea
```
Выпустить токен, если его ещё нет: 1. Прочитай минимальный релевантный context и Obsidian notes.
2. Проверь active inventory, vars, playbook/role и связанные consumers.
3. Внеси smallest correct declarative change; не используй ad-hoc server edits.
4. Запусти минимальные local checks, затем только необходимые bounded live checks.
5. Проверь diff на broad targeting, secrets, destructive behavior и docs drift.
6. Обнови repository/Obsidian documentation для изменившихся решений и процедур.
7. В отчете перечисли changed files, проверки, неисполненные live checks и unknowns.
```bash Для read-only reconnaissance, Ansible safety review, syntax validation, network/log
make bootstrap-pve-token # прямо с ноды, нужен sudo diagnostics, backup audit и Obsidian context используй специализированные агенты из
make bootstrap-monitoring-token # отдельный read-only токен для мониторинга `.opencode/agents/`. Большие logs и command outputs передавай соответствующему
``` read-only summarizer и всегда ограничивай `--since`, `-n` или `--tail`.
## Secrets Policy
- Never commit real secrets to git.
- Use `.env` files (in `.gitignore`), Ansible vault, or runtime prompt.
- Store `.env.example` templates in repo.
- Keep: passwords, tokens, private keys, PBS secrets, TLS keys outside repo.
## Workflow
1. **Read Obsidian** — understand context, constraints, prior decisions.
2. **Make change** — edit/add role/playbook in `ansible/`.
3. **Test safe** — run smallest applicable check/playbook.
4. **Update Obsidian** — document non-obvious decisions and changes.
## Archive Policy
- `archive/2026-07-proxmox-migration/` contains historical NixOS, Docker Compose, GitOps configs.
- Use archived files only as reference when porting behavior to Ansible.
- Do not edit archived files for active infrastructure.
- Restore services via new Ansible roles/playbooks, not by moving old files back.
## Token Economy
Принципы минимизации токенов при управлении инфраструктурой.
### Delegate Heavy Reading
Когда нужно анализировать большие логи или выводы — делегируй субагенту:
```
agent("Read this log and summarize key errors")
```
**Субагент использует для:**
- Анализа логов (journalctl, docker logs, PBS logs)
- Summarization больших файлов
- Поиска паттернов в выводах
- Диагностики статусов
**Основной агент для:**
- Принятия решений на основе конспекта
- Сложных рассуждений над findings
- Изменения кода и архитектуры
### Prefer Scripts Over LLM
Для повторяющихся задач — скрипты, а не объяснения:
```bash
# Вместо: "проверь статус всех LXC"
ansible/scripts/check-lxc-status.sh
# Вместо: "верифицируй PBS backup jobs"
ansible/scripts/check-pbs-backups.sh
```
Паттерн: написать один раз → переиспользовать. LLM только пишет или вызывает.
### Focused Context — читай только нужное
Вместо чтения всего файла — только релевантные части:
```python
# Вместо всего inventory.yml
Read(file_path, offset=1, limit=50) # только hosts vars
# Или grep для конкретного хоста
grep("mini-pc", "inventory/hosts.yml")
```
### Tool Limits — ограничивай вывод команд
```bash
# Вместо: journalctl -u openvpn (тысячи строк)
journalctl -u openvpn --since "1 hour ago" -n 100
# Вместо: docker logs (весь лог)
docker logs --tail 50 gitea
```
Всегда ограничивай вывод: `--tail`, `--since`, `-n`, `head`.
### Batch Operations — группируй задачи
Вместо нескольких запусков — один с несколькими role:
```python
# Плохо
ansible-playbook playbooks/bash-config.yml
ansible-playbook playbooks/docker.yml
ansible-playbook playbooks/ufw.yml
# Хорошо — один плейбук
ansible-playbook playbooks/base-setup.yml # включает bash, docker, ufw
```
### State Caching — не перечитывай статичное
Структура инфры меняется редко. Не перечитывай `hosts.yml`, если структура не изменилась. Кэшируй в memory стабильные данные: network topology, storage layout.
### Structured Output — избегай повторного парсинга
Когда субагент анализирует логи — проси сразу JSON с findings:
```json
{
"summary": "OpenVPN handshake fails due to certificate expired",
"findings": ["cert expired 2026-07-01", "client retries every 5s"],
"relevant_lines": ["Jul 08 10:23:01 TLS auth error"]
}
```
Работа со структурированным результатом вместо повторного чтения логов.
### Declarative Over Imperative
Описывай желаемое состояние, а не шаги:
```yaml
# Плохо: каждый раз перечислять шаги
"Создай LXC, установи Docker, добавь пользователя..."
# Хорошо: декларативно
lxc_container:
name: "vaultwarden"
features: ["docker", "autostart"]
user: "ansible"
```
Ansible/Terraform сами разберут шаги.
+9 -6
View File
@@ -2,6 +2,7 @@
Активная инфраструктура домашней лаборатории управляется через Ansible. Активная инфраструктура домашней лаборатории управляется через Ansible.
Каноничные инструкции для людей и агентов — в [AGENTS.md](./AGENTS.md). Каноничные инструкции для людей и агентов — в [AGENTS.md](./AGENTS.md).
Стабильный архитектурный контекст и риски — в [docs/ai/](./docs/ai/README.md).
## Быстрый старт ## Быстрый старт
@@ -32,7 +33,8 @@ make deploy-gitea # применить
make update-gitea # бэкап -> обновление -> health-check make update-gitea # бэкап -> обновление -> health-check
``` ```
`.env` с Proxmox-токенами подхватывается автоматически. Опасные цели требуют `CONFIRM=1`. Секреты лежат в `.env` в **корне репозитория**`.gitignore`); его подхватывают
и Ansible, и OpenTofu. Опасные цели требуют `CONFIRM=1`.
## SSH руками ## SSH руками
@@ -49,12 +51,13 @@ Include /home/ada/Documents/Projects/HomeLab/infras/ansible/ssh_config
- `ansible/inventory/group_vars/all/services.yml` — реестр сервисов (VMID, IP, порты, домены, образы) - `ansible/inventory/group_vars/all/services.yml` — реестр сервисов (VMID, IP, порты, домены, образы)
- `flake.nix` — dev-окружение - `flake.nix` — dev-окружение
- `.gitea/workflows/lint.yml` — CI: yamllint, ansible-lint, syntax-check - `.gitea/workflows/lint.yml` — CI: yamllint, ansible-lint, syntax-check
- `docs/ai/` — архитектура, stack, edge cases и legacy boundaries для агентов
- `archive/2026-07-proxmox-migration/` — исторические NixOS/Docker конфиги, только как справка - `archive/2026-07-proxmox-migration/` — исторические NixOS/Docker конфиги, только как справка
## Grimmory MCP ## Grimmory MCP
`tools/grimmory-mcp/` содержит read-only интеграцию с Grimmory API для OpenCode `tools/grimmory-mcp/` содержит read-only интеграцию с Grimmory API для OpenCode.
и явно вызываемые инструменты синхронизации с Obsidian. Явно вызываемые sync tools записывают сгенерированные заметки и обложки в Obsidian.
```bash ```bash
npm install --prefix tools/grimmory-mcp npm install --prefix tools/grimmory-mcp
@@ -62,6 +65,6 @@ npm run configure --prefix tools/grimmory-mcp
npm test --prefix tools/grimmory-mcp npm test --prefix tools/grimmory-mcp
``` ```
OpenCode регистрирует сервер глобально. После перезапуска OpenCode используй Глобальная регистрация MCP в OpenCode выполняется вне этого репозитория. После
`/grimmory-sync` для обновления заметок книг в `90 Library/Books` и обложек настройки используй `/grimmory-sync` для обновления заметок книг в
в `99 System/Export/Grimmory/Covers`. `90 Library/Books` и обложек в `99 System/Export/Grimmory/Covers`.
+54 -41
View File
@@ -12,8 +12,10 @@ Ansible is the control plane for HomeLab infrastructure changes.
## Layout ## Layout
- `inventory/hosts.yml` — canonical host list and host-specific facts. - `inventory/hosts.yml` — canonical host list and host-specific facts.
- `inventory/group_vars/all/services.yml` — service registry and reverse-proxy input; deployment playbooks still duplicate these facts.
- `playbooks/` — entry points for tasks. - `playbooks/` — entry points for tasks.
- `roles/` — reusable configuration units. - `roles/` — reusable configuration units.
- `Makefile` — canonical manual entry point and safety gates.
## Current Groups ## Current Groups
@@ -24,38 +26,43 @@ Ansible is the control plane for HomeLab infrastructure changes.
- `monitoring_exporters` — hosts exposing Node Exporter metrics. - `monitoring_exporters` — hosts exposing Node Exporter metrics.
- `monitoring_smart_exporters` — Proxmox nodes exposing SMART metrics. - `monitoring_smart_exporters` — Proxmox nodes exposing SMART metrics.
- `vpn_openvpn` — OpenVPN transport hosts: `ru-vps`, `ovpn-mini`. - `vpn_openvpn` — OpenVPN transport hosts: `ru-vps`, `ovpn-mini`.
- `shell_hosts` — hosts with unified bash config: `ru-vps`, `cloud-pc`, `mini-pc`. - `shell_hosts` — hosts with unified bash config: `ru-vps`, `cloud-pc`, `mini-pc`, `hermes-ai`.
- `servers` — all managed hosts. - `servers` — all managed hosts.
## First Checks ## First Checks
Install control-node dependencies locally: From the repository root, use the Nix environment and install Galaxy collections once per clone:
```bash ```bash
python3 -m venv .venv nix develop
. .venv/bin/activate ansible-galaxy collection install -r ansible/requirements.yml -p ansible/collections
pip install -r requirements.txt
ansible-galaxy collection install -r requirements.yml -p collections
``` ```
Run from `ansible/`: Run manual operations through Make from `ansible/`:
```bash ```bash
ansible-playbook playbooks/check.yml make help
make check
make status EXTRA="--limit '!gyro'"
make lint
``` ```
`make setup` remains a local venv fallback when Nix is unavailable.
## Controlled Updates ## Controlled Updates
Service updates are manual and use pinned `tag@sha256:digest` image references only; floating tags and auto-update agents are not used. Service updates are manual. Active update-managed remote images are pinned as
`tag@sha256:digest`; the frozen Prometheus stack is tag-only. Floating
auto-update agents are not used.
Run the dedicated playbook for the target service from `ansible/`: Run the dedicated Make target from `ansible/`:
```bash ```bash
.venv/bin/ansible-playbook playbooks/vaultwarden-update.yml make update-vaultwarden
.venv/bin/ansible-playbook playbooks/gitea-update.yml make update-gitea
.venv/bin/ansible-playbook playbooks/adguard-update.yml make update-adguard
.venv/bin/ansible-playbook playbooks/mihomo-update.yml make update-mihomo
.venv/bin/ansible-playbook playbooks/grimmory-update.yml make update-grimmory
``` ```
Update flow is always: fresh backup/audit first, then the update playbook, then health verification. Update flow is always: fresh backup/audit first, then the update playbook, then health verification.
@@ -69,55 +76,62 @@ Update flow is always: fresh backup/audit first, then the update playbook, then
Use the dedicated hardening playbook only when explicitly approved: Use the dedicated hardening playbook only when explicitly approved:
```bash ```bash
.venv/bin/ansible-playbook playbooks/ru-vps-mihomo-harden.yml -e ru_vps_mihomo_harden_confirm=true make mihomo-harden CONFIRM=1
``` ```
It rotates the live Mihomo SOCKS credentials on `ru-vps`, locks the proxy to loopback, and removes the public UFW exposure for ports `7890` and `7891`. It rotates the live Mihomo SOCKS credentials on `ru-vps`, locks the proxy to loopback, and removes the public UFW exposure for ports `7890` and `7891`.
The rotated credentials are not recoverable for clients unless you roll back the saved config backup. The rotated credentials are not recoverable for clients unless you roll back the saved config backup.
For Proxmox API playbooks, create ignored `.env` from `.env.example` and load it: For Proxmox API playbooks, create ignored `.env` in the **repository root** from
`.env.example`. It is shared with OpenTofu (`tofu/`). Make loads it
automatically:
```bash ```bash
cp .env.example .env cp ../.env.example ../.env # секреты живут в корне репозитория
. ./.env make env-check
.venv/bin/ansible-playbook playbooks/pve-ovpn-mini.yml make dry-ovpn-mini
make deploy-ovpn-mini
``` ```
Or bootstrap the token from `mini-pc` with sudo: Or bootstrap the token from `mini-pc` with sudo:
```bash ```bash
.venv/bin/ansible-playbook playbooks/bootstrap-pve-api-token.yml -K make bootstrap-pve-token
``` ```
Create the separate read-only PVE token used by the monitoring exporter: Create the separate read-only PVE token used by the monitoring exporter:
```bash ```bash
.venv/bin/ansible-playbook playbooks/bootstrap-monitoring-pve-token.yml make bootstrap-monitoring-token
``` ```
OpenVPN transport: OpenVPN transport:
```bash ```bash
.venv/bin/ansible-playbook playbooks/openvpn-vps-mini.yml -K make openvpn
.venv/bin/ansible-playbook playbooks/openvpn-check.yml make openvpn-check
``` ```
Monitoring is provisioned in two steps after loading the monitoring secrets from ignored `.env` or Ansible Vault: CT 146 can be provisioned separately. Uptime Kuma is the active monitoring service:
```bash ```bash
. ./.env make deploy-monitoring
.venv/bin/ansible-playbook playbooks/pve-monitoring.yml make uptime-kuma
.venv/bin/ansible-playbook playbooks/monitoring.yml
``` ```
`pve-monitoring.yml` creates CT `146` (`monitoring`, `192.168.1.30`) on `cloud-pc`. `monitoring.yml` configures exporters, the `ru-vps` probe vantage point, and the central Prometheus stack. It is frozen while Uptime Kuma is in use; do not run it unless restoring Prometheus monitoring. `pve-monitoring.yml` creates CT `146` (`monitoring`, `192.168.1.30`) on
`cloud-pc`. The older `monitoring.yml` configures Prometheus, Alertmanager,
Grafana and exporters; it is frozen and `make monitoring CONFIRM=1` is reserved
for an explicitly approved restoration decision.
## Gyro Investment Allocator ## Gyro Investment Allocator
`pve-gyro.yml` creates unprivileged CT `150` (`gyro`, `192.168.1.35`) on `mini-pc`. `gyro.yml` installs Python 3.13+, pinned `uv`, the `gyro` service user, a container-local GitHub deploy key, restrictive firewall rules, and a weekday systemd timer. Production `gyro` now runs in Tofu-provisioned CT `156` (`gyro`, `192.168.1.35`) on `mini-pc`. CT `150` is stopped and kept only as rollback for at least a week; it is not removed.
UFW is the currently enforced isolation layer: inbound is denied except SSH from LAN/OpenVPN, and east-west outbound is denied except the Mihomo HTTP proxy. The equivalent CT `150` Proxmox firewall is staged, but the cluster-wide PVE firewall remains disabled; do not enable it without auditing every node and guest with `firewall=1`. `make gyro` configures Python 3.13+, pinned `uv`, the `gyro` service user, the container-local GitHub deploy key, restrictive firewall rules, and the weekday systemd timer. Do not use `make deploy-gyro` for the cutover path.
UFW is the currently enforced isolation layer: inbound is denied except SSH from LAN/OpenVPN, and east-west outbound is denied except the Mihomo HTTP proxy. The equivalent CT `156` Proxmox firewall is already carried over; the cluster-wide PVE firewall remains disabled, so do not enable it without auditing every node and guest with `firewall=1`.
The role keeps deployment and the timer disabled by default. The active host vars deploy `git@github.com:ada-dmitry/t_tech-gyro.git` with GitHub's verified ED25519 host key; the timer still requires the ignored Vault file: The role keeps deployment and the timer disabled by default. The active host vars deploy `git@github.com:ada-dmitry/t_tech-gyro.git` with GitHub's verified ED25519 host key; the timer still requires the ignored Vault file:
@@ -129,17 +143,17 @@ ansible-vault encrypt inventory/host_vars/gyro/vault.yml
After encrypting the secrets, set `gyro_timer_enabled: true` in `main.yml` and apply with `--ask-vault-pass`. `DRY_RUN_OVERRIDE` remains `true` until real trading is explicitly approved. After encrypting the secrets, set `gyro_timer_enabled: true` in `main.yml` and apply with `--ask-vault-pass`. `DRY_RUN_OVERRIDE` remains `true` until real trading is explicitly approved.
```bash ```bash
. ./.env make gyro
.venv/bin/ansible-playbook playbooks/pve-gyro.yml
.venv/bin/ansible-playbook playbooks/gyro.yml --ask-vault-pass
``` ```
The timer runs at 11:00 Europe/Moscow from Monday through Friday and uses `OnFailure=` for a best-effort Telegram alert. CT `150` is included in the daily mini-pc PBS job and backup freshness audit. The timer runs at 11:00 Europe/Moscow from Monday through Friday and uses `OnFailure=` for a best-effort Telegram alert. PBS backup jobs and the backup freshness audit derive the current Gyro VMID from the registry once regenerated, so CT `156` is picked up automatically.
The legacy `pve-gyro.yml` remains available for rollback recovery only and is blocked by default after cutover unless an explicit override is passed.
Uptime Kuma uses the existing monitoring LXC and stops/disables `homelab-monitoring` without deleting its configuration or data. Its UI is available only from the LAN at `http://192.168.1.30:3001`; create monitors and notification settings in the UI. Uptime Kuma uses the existing monitoring LXC and stops/disables `homelab-monitoring` without deleting its configuration or data. Its UI is available only from the LAN at `http://192.168.1.30:3001`; create monitors and notification settings in the UI.
```bash ```bash
.venv/bin/ansible-playbook playbooks/uptime-kuma.yml make uptime-kuma
``` ```
## Emergency Reverse SSH ## Emergency Reverse SSH
@@ -149,9 +163,8 @@ Uptime Kuma uses the existing monitoring LXC and stops/disables `homelab-monitor
Before applying, set `EMERGENCY_BOT_TOKEN`, `EMERGENCY_ALLOWED_USER_IDS`, `EMERGENCY_VPS_HOST_KEY`, and `EMERGENCY_MINI_PC_HOST_KEY` in ignored `.env` or Ansible Vault. The host-key variables must be verified public host keys, not values obtained during deployment. Before applying, set `EMERGENCY_BOT_TOKEN`, `EMERGENCY_ALLOWED_USER_IDS`, `EMERGENCY_VPS_HOST_KEY`, and `EMERGENCY_MINI_PC_HOST_KEY` in ignored `.env` or Ansible Vault. The host-key variables must be verified public host keys, not values obtained during deployment.
```bash ```bash
. ./.env make deploy-emergency-bot
.venv/bin/ansible-playbook playbooks/pve-emergency-bot.yml make emergency-access
.venv/bin/ansible-playbook playbooks/emergency-access.yml
``` ```
From an authorized private Telegram chat, use the `Enable SSH`, `Status`, and `Stop` buttons or `/emergency ssh`, `/emergency status`, and `/emergency stop`. `/emergency ssh` enables a 60-minute tunnel only; it does not expose a public port. Connect while it is active with: From an authorized private Telegram chat, use the `Enable SSH`, `Status`, and `Stop` buttons or `/emergency ssh`, `/emergency status`, and `/emergency stop`. `/emergency ssh` enables a 60-minute tunnel only; it does not expose a public port. Connect while it is active with:
@@ -167,13 +180,13 @@ The target account is `ansible`; it has no password login. Use the existing priv
Bootstrap the Ansible service account on shell hosts: Bootstrap the Ansible service account on shell hosts:
```bash ```bash
.venv/bin/ansible-playbook -i inventory/hosts.yml playbooks/bootstrap-ansible-user.yml -K make bootstrap-ansible-user
``` ```
When a task needs privilege escalation: When a task needs privilege escalation:
```bash ```bash
ansible-playbook playbooks/<name>.yml -K make play-<name> EXTRA="-K"
``` ```
## Workflow ## Workflow
+14 -3
View File
@@ -36,12 +36,23 @@ systemd-юнит `Type=oneshot` с `docker compose up -d --remove-orphans`,
Подробности и пример плейбука Gitea на новых ролях: Подробности и пример плейбука Gitea на новых ролях:
[`compose_service/README.md`](compose_service/README.md). [`compose_service/README.md`](compose_service/README.md).
**Статус:** роли созданы и проверены синтаксически, но пока не подключены ни **Статус:** `compose_service` подключён в `playbooks/ru-vps-base.yml` (стек
к одному живому сервису. Перевод `pve-*.yml` на них — отдельный этап. Caddy на ru-vps) — это его первый и пока единственный потребитель.
`lxc_docker_host` не подключён нигде. Перевод `pve-*.yml` на обе роли —
отдельный этап.
## Источник данных ## Источник данных
Факты о сервисах (vmid, узел, адрес, порты, домен, образы с digest, ресурсы, Факты о сервисах (vmid, узел, адрес, порты, домен, образы с digest, ресурсы,
бэкап, мониторинг, порядок автозапуска) собраны в реестре бэкап, мониторинг, порядок автозапуска) собраны в реестре
`ansible/inventory/group_vars/all/services.yml` (`homelab_services`). `ansible/inventory/group_vars/all/services.yml` (`homelab_services`).
Его уже потребляет `playbooks/reverse-proxy.yml`. Программные потребители реестра:
- `playbooks/reverse-proxy.yml` — сборка Caddyfile;
- `playbooks/ru-vps-base.yml` — стек Caddy и его закреплённый образ;
- `playbooks/pve-backup-jobs.yml` — списки VMID заданий PBS (поле `backup.job`);
- `roles/backup_audit` — VMID для аудита (флаг `monitoring.backup_audit_vmid`);
- `playbooks/validate.yml` — сверка реестра с фактическим состоянием Proxmox.
Остальное (`pve-*.yml`, `status.yml`, monitoring, SSH config) по-прежнему
дублирует значения и должно меняться согласованно.
+2 -2
View File
@@ -5,8 +5,8 @@ Docker»: пакеты, проверка `/dev/fuse`, `storage-driver: fuse-over
запуск демона и базовый UFW. запуск демона и базовый UFW.
Роль вынесена из повторяющихся блоков `playbooks/pve-*.yml` Роль вынесена из повторяющихся блоков `playbooks/pve-*.yml`
(gitea, vaultwarden, mihomo, adguard, memoir-bot, docker-test, grimmory, (gitea, vaultwarden, mihomo, adguard, docker-test, grimmory, hermes-ai) —
hermes-ai) — суммарно около 350 строк копипасты. суммарно около 350 строк копипасты.
## Что делает ## Что делает
+68
View File
@@ -0,0 +1,68 @@
# AI Context
## Назначение
Этот набор документов фиксирует стабильный, подтвержденный репозиторием контекст
HomeLab. Он дополняет краткий рабочий контракт в [`AGENTS.md`](../../AGENTS.md) и
не заменяет канонические Ansible inventory/vars или операционные заметки Obsidian.
HomeLab управляется как Ansible-first control plane: Proxmox VE/LXC, сетевой
транспорт, сервисы, резервное копирование, обновления и мониторинг описываются в
`ansible/`. Прямые изменения на серверах допустимы только для read-only диагностики
или break-glass восстановления с последующим переносом желаемого состояния в Ansible.
## Возможности
- создание и настройка LXC через Proxmox API или `pct` по SSH;
- управление сервисами через Docker/systemd и Docker Compose/systemd;
- OpenVPN-транспорт и SSH ProxyJump через `ru-vps`;
- Caddy reverse proxy для публичных сервисов;
- PBS и offsite restic backups с аудитом свежести;
- управляемые обновления по схеме backup/audit -> update -> health check;
- активный Uptime Kuma и сохраненный, но замороженный Prometheus stack;
- локальный Grimmory MCP с ручной синхронизацией книг в Obsidian.
## Форма системы
- `ansible/Makefile` является основной ручной точкой входа.
- `ansible/inventory/hosts.yml` задает хосты, группы и индивидуальные адреса.
- `ansible/inventory/group_vars/all/services.yml` содержит сводный реестр сервисов,
но пока программно управляет только генерацией reverse proxy.
- `ansible/playbooks/` содержит операционные entry points, `ansible/roles/` - роли.
- `tools/grimmory-mcp/` является отдельным Node.js stdio MCP-процессом.
- Obsidian vault хранит решения, текущую эксплуатационную картину и журнал работ.
## Карта репозитория
| Путь | Назначение |
|---|---|
| `ansible/Makefile` | Проверки, deploy, update и защищенные операции |
| `ansible/inventory/` | Канонические хосты, группы, общие и host-specific vars |
| `ansible/playbooks/` | Операционные Ansible entry points |
| `ansible/roles/` | Переиспользуемые роли и сохраненный monitoring stack |
| `ansible/ssh_config` | SSH users, keys, ports и ProxyJump |
| `.opencode/agents/` | Read-only специализированные агенты HomeLab |
| `tools/grimmory-mcp/` | Grimmory API и Obsidian sync integration |
| `archive/2026-07-proxmox-migration/` | История до Ansible control plane, не active source |
## Ключевые ограничения
- Не хранить secrets в Git или Obsidian.
- Не считать `--check --diff` полной симуляцией Proxmox provisioning.
- Не запускать замороженный Prometheus stack без отдельного решения.
- Не считать generic roles `lxc_docker_host` и `compose_service` подключенными к
production: активные service playbooks пока остаются источником поведения.
- Не исправлять обнаруженный технический долг в рамках несвязанной задачи.
- Не редактировать archive, generated files, installed Galaxy collections или
`node_modules` как active implementation.
## Документы
- [`architecture.md`](architecture.md) - observed architecture и data/control flows.
- [`tech-stack.md`](tech-stack.md) - runtimes, dependencies и команды.
- [`edge-cases.md`](edge-cases.md) - failure modes, safety gaps и coverage.
- [`plan.md`](plan.md) - только подтвержденная активная работа.
- [`migration-tofu.md`](migration-tofu.md) - пошаговый план перехода provisioning
LXC на OpenTofu по схеме blue-green.
- [`legacy-warning.md`](legacy-warning.md) - границы legacy/frozen/prototype кода.
- [`links.md`](links.md) - официальные version-relevant references.
+142
View File
@@ -0,0 +1,142 @@
# Архитектура
## Контекст и точки входа
Репозиторий является control plane, а не приложением с единым runtime. Оператор
запускает цели `ansible/Makefile`; Make загружает локальные secrets, применяет safety
gates и вызывает playbooks. `ansible.cfg` выбирает inventory, roles и локально
установленные Galaxy collections.
Канонические источники:
- хосты и группы: `ansible/inventory/hosts.yml`;
- общий доступ и сеть: `ansible/inventory/group_vars/all/main.yml`;
- сводные сведения о сервисах: `ansible/inventory/group_vars/all/services.yml`;
- SSH transport: `ansible/ssh_config`;
- ручные операции: `ansible/Makefile`.
Программные потребители реестра:
- `playbooks/reverse-proxy.yml` — сборка Caddyfile;
- `playbooks/ru-vps-base.yml` — стек Caddy и его закреплённый образ;
- `playbooks/pve-backup-jobs.yml` — списки VMID заданий PBS (поле `backup.job`);
- `roles/backup_audit` — VMID для аудита (флаг `monitoring.backup_audit_vmid`);
- `playbooks/validate.yml` — сверка реестра с фактическим состоянием Proxmox.
Остальное (`pve-*.yml`, `status.yml`, monitoring, SSH config) по-прежнему
дублирует значения и должно меняться согласованно.
## Топология
- `ru-vps`: public VPS, qdevice, Caddy, OpenVPN server и SSH JumpHost.
- `cloud-pc`, `mini-pc`: Proxmox VE nodes.
- `lxc_infra`: PBS, OpenVPN gateway и service LXC.
- `monitoring`: CT 146 на `cloud-pc`; Uptime Kuma активен, Prometheus stack заморожен.
Актуальные VMID, placement, IP, ports, domains и backup policy не копируются сюда:
их нужно читать из `homelab_services` и сверять с `hosts.yml`.
## Provisioning Flow
1. `make deploy-<service>` загружает ignored `.env` из корня репозитория.
2. `playbooks/pve-<service>.yml` создает или сверяет LXC.
3. Часть playbooks вызывает `roles/pve_lxc` через Proxmox API.
4. Остальные подключаются по SSH к PVE node и выполняют `pct create/set/start`.
5. Следующий play настраивает LXC, runtime, systemd и health checks.
Два provisioner-пути имеют разные check-mode и ownership guards. Нельзя переносить
service между ними как косметический рефакторинг.
## Service Runtime
- Большинство сервисов используют systemd units вокруг `docker run`.
- Grimmory использует Compose и отдельный `DOCKER-USER` firewall unit.
- Uptime Kuma и сохраненный monitoring stack используют legacy `docker-compose`.
- `lxc_docker_host` и `compose_service` описывают будущий общий паттерн, но ни один
production playbook их пока не вызывает.
## Network Flow
`ansible_ssh_common_args` подключает `ansible/ssh_config`. Обычный путь управления:
```text
controller -> ru-vps:3422 -> 192.168.1.x target
```
`pbs` и `ovpn-mini` являются direct-LAN исключениями. LXC обычно управляются как
`root`, а PVE nodes и `ru-vps` - как service account `ansible`.
OpenVPN site tunnel:
```text
ru-vps 10.78.0.1:8443/tcp <-> ovpn-mini 10.78.0.2 -> 192.168.1.0/24
```
Публичный service flow:
```text
Internet -> Caddy on ru-vps -> OpenVPN -> ovpn-mini -> LAN service
```
Потеря `ru-vps` или site tunnel одновременно влияет на public upstreams и обычный
SSH management path.
## Reverse Proxy
`playbooks/reverse-proxy.yml` выбирает записи `homelab_services` с блоком `proxy`,
валидирует metadata и Caddyfile, обновляет marked blocks и проверяет upstreams.
Ansible управляет Caddyfile, но не установкой и lifecycle контейнера Caddy на `ru-vps`.
Grimmory OPDS/KOReader routes имеют специальные headers и отключение compression;
их нельзя упрощать без device compatibility tests.
## Backup и Update
`playbooks/pve-backup-jobs.yml` задает cluster-level PBS schedules. Offsite restic
profiles выполняются systemd timers и используют application-aware SQLite backup или
MariaDB dump. `roles/backup_audit` проверяет freshness, `restic check` и выборочные
restores, после чего атомарно пишет textfile metrics.
Update playbooks выполняют:
```text
fresh backup/audit -> reapply service declaration -> health verification
```
Backup является prerequisite для ручного recovery, а не автоматическим rollback.
## Monitoring
Uptime Kuma в CT 146 является активным monitoring UI. Его роль останавливает и
отключает `homelab-monitoring`, сохраняя конфигурацию и данные Prometheus stack.
`make monitoring` защищен `CONFIRM=1` и предназначен только для отдельно принятого
решения о восстановлении старого stack.
Exporter roles, groups и backup metrics остаются в репозитории. Hardcoded Prometheus
targets могут расходиться с inventory, пока stack заморожен.
## Grimmory MCP
`tools/grimmory-mcp/src/server.js` запускает локальный stdio MCP. Grimmory API
используется read-only; authentication POST не меняет library data. Явно вызванные
sync tools читают API, опционально загружают cover, атомарно обновляют managed sections
Obsidian notes и запускают внешний vault index script.
Sync не является общей транзакцией: full sync может закончиться после частичного
набора успешных book updates. Vault path и credential file защищены отдельными
проверками, описанными в [`edge-cases.md`](edge-cases.md).
## Testing Boundaries
- Ansible имеет static lint и syntax checks, но не имеет Molecule/idempotence harness.
- `check.yml` проверяет reachability и expected IP, а не service health.
- `status.yml` формирует наблюдательный отчет и не является failing health gate.
- Grimmory MCP имеет Node test suite.
- Gitea Actions workflow существует, но runner и Actions не активированы.
## Неизвестно
- Текущие runtime versions Proxmox VE, OpenVPN, Caddy и restic не закреплены repo manifests.
- UI-only состояние AdGuard, Uptime Kuma monitors и часть service credentials не может
быть установлена из репозитория.
- Наличие DHCP reservation для Grimmory и намеренность отсутствия backup CT 148
требуют проверки оператором.
+104
View File
@@ -0,0 +1,104 @@
# Edge Cases и риски
## Proxmox Provisioning
- Handled: ряд service playbooks проверяет hostname существующего VMID перед
изменением контейнера.
- Gap: generic `roles/pve_lxc` не содержит общего foreign-VMID guard; часть callers
и direct `pct` playbooks также не выполняет ownership assertion.
- Gap: некоторые `pct start` tasks считают любой return code `255` допустимым, что
может скрыть ошибку, не связанную с already-running state.
- Unknown: `--check --diff` не моделирует Proxmox module calls и command-heavy
playbooks полностью. `make dry-*` является preview, а не isolated simulation.
## Validation Semantics
- `playbooks/check.yml` проверяет Ansible reachability и `expected_lan_ip`.
- `playbooks/status.yml` подавляет многие command errors и отображает DOWN/failed
состояния в отчете; успешный exit code не означает healthy infrastructure.
- `make lint` запускает ansible-lint и yamllint из корня репозитория и включает
all-playbook syntax loop, то есть воспроизводит CI.
- CI workflow не исполняется без включенных Gitea Actions и runner.
- Ansible roles не имеют Molecule, idempotence или integration test harness.
## Network Failure Domains
- Потеря `ru-vps` нарушает обычный ProxyJump к большинству hosts и public Caddy.
- Потеря OpenVPN site tunnel нарушает private upstream path public services.
- Потеря Mihomo одновременно затрагивает egress Hermes, внешние Uptime Kuma probes,
emergency Telegram и Gyro notifications.
- Normal SSH использует `StrictHostKeyChecking=accept-new`; emergency и Gyro paths
требуют заранее проверенных pinned host keys.
- Proxmox API TLS validation по шаблону `.env.example` отключен по умолчанию.
## Secrets и Privilege
- Handled: ignored `.env`, Vault, `0600`, `no_log` и external password files не
позволяют хранить ожидаемые secrets в tracked config.
- Constraint: никогда не переносить значения из ignored/archived `.env` в docs,
output или commits.
- Risk: `bootstrap-pve-api-token.yml` вращает privileged token и переписывает весь
корневой `.env` целиком, а не построчно. Теряются `MONITORING_*`, `EMERGENCY_*` и
`PROXMOX_ROOT_PASSWORD`. С 2026-09-02 у задачи `backup: true`, но восстанавливать
придётся вручную. Запускать только как отдельную осознанную операцию.
- Constraint: строки в `.env` пишутся с префиксом `export`, и это не стиль:
`bootstrap-monitoring-pve-token.yml` ищет их через `regexp: "^export NAME="`.
Убрать префикс — значит получить дубликаты строк вместо обновления.
- Risk: LXC управляются как root, shell hosts используют `ansible` с passwordless sudo.
## Backups и Updates
- Handled: SQLite backup API + integrity check, atomic MariaDB dump, per-profile
`flock`, PBS/restic freshness audit и selective restore.
- Gap: update failures не запускают automatic rollback; backup только обеспечивает
возможность ручного recovery.
- Gap: PBS audit проверяет freshness, но не выполняет restore или PBS data integrity
drill. Обычный `restic check` не читает все data packs.
- Gap: recurring Grimmory audit проверяет non-empty SQL dump, но не импортирует его
во временную MariaDB.
- Gap: CT 148 `emergency-bot` не имеет backup/monitoring в service registry.
- Constraint: code-only downgrade Grimmory после Flyway migration запрещен; нужен
previous image вместе с pre-upgrade database backup.
- Concurrency: нет repository-wide lock от одновременных operator/update runs;
preflight через `pgrep vzdump` имеет race до запуска нового backup.
- Handled: storage-level `prune-backups` on PVE storage `pbs` removed
declaratively; retention now runs centrally in PBS `prune-pbs`, so the PBS
side is the only authority for PBS-backed retention. The weekly PBS-container
backup on storage `backup` with `keep-last=2` remains an intentional
exception.
## Monitoring
- Active Uptime Kuma и frozen Prometheus stack не должны запускаться как две
параллельные monitoring architectures без отдельного решения.
- Hardcoded Prometheus targets могут расходиться с inventory и service registry.
- Central monitoring на `cloud-pc` не может независимо сообщить о полном отказе
своего node без внешнего наблюдателя.
## Reverse Proxy и Firewall
- Handled: reverse proxy metadata, Caddyfile, container config и upstreams проходят
validation до/после restart.
- Constraint: Ansible не управляет lifecycle Caddy container на `ru-vps`.
- Constraint: не упрощать Grimmory OPDS/KOReader handlers и не удалять его
`DOCKER-USER` protection без эквивалентных compatibility/security checks.
- Constraint: не включать cluster-wide PVE firewall без аудита всех guests с
`firewall=1`.
## Grimmory MCP
- Handled и tested: pagination guards, optional 204/404 resources, concurrent auth
promises, credential ownership/mode/no-follow checks, vault path containment,
cover size/type validation, atomic note writes и preservation of user markers.
- Gap: full sync не транзакционен и может завершиться с частично обновленным vault.
- Gap: нет mutex для concurrent sync/index rebuild.
- Gap: поиск existing note для каждой книги повторно сканирует весь notes directory.
- External dependency: Python и vault-owned index script должны существовать и
завершиться в timeout; они не управляются этим репозиторием.
## Требующие проверки состояния
- Завершена ли initial UI configuration AdGuard.
- Создана ли DHCP reservation/exclusion для Grimmory `192.168.1.34`.
- Настроены ли Gyro Vault secrets; tracked configuration не подтверждает их наличие.
- Является ли отсутствие backup для CT 148 намеренным решением.
+75
View File
@@ -0,0 +1,75 @@
# Legacy и fragile boundaries
Этот файл не является backlog. Он предотвращает случайную замену active behavior
более новым, старым или внешне похожим кодом без отдельного решения.
## Исторический Archive
- Path: `archive/2026-07-proxmox-migration/`.
- Evidence: каталог содержит прежние NixOS, Docker Compose, GitOps, ZeroTier и другие
pre-Ansible материалы.
- Constraint: не редактировать и не возвращать файлы из archive как active config.
- Decision: accepted historical reference; active implementation создается в `ansible/`.
ZeroTier удален из active infrastructure 11 июля 2026 года. Active inventory, roles
и playbooks его не содержат.
## Frozen Monitoring Stack
- Paths: `ansible/roles/monitoring_server/`, `monitoring_exporter/`,
`monitoring_blackbox/`, `ansible/playbooks/monitoring.yml`.
- Evidence: `ansible/Makefile` помечает target `monitoring` как frozen и требует
`CONFIRM=1`; role `uptime_kuma` останавливает `homelab-monitoring`.
- Constraint: наличие кода не означает, что Prometheus stack активен.
- Decision: defer; Uptime Kuma является active monitoring до нового решения.
## Prototype Roles
- Paths: `ansible/roles/lxc_docker_host/`, `ansible/roles/compose_service/`.
- Evidence: `ansible/roles/README.md`. `compose_service` с 2026-09-02 вызывается из
`playbooks/ru-vps-base.yml` (стек Caddy); `lxc_docker_host` по-прежнему не вызывается
ни одним playbook.
- Constraint: не считать direct `pve-*.yml` dead code и не мигрировать service как
opportunistic cleanup. Миграция меняет runtime, pull, firewall и recreation semantics.
- Decision: defer; выполнять отдельно по одному service с backup и health validation.
Empty `ansible/roles/base`, `docker` и `ufw` являются остатками ранней структуры, а
не active reusable roles.
## Duplicated Service Facts
- Paths: service registry, `pve-*.yml`, `pve-backup-jobs.yml`, backup audit defaults,
monitoring templates, status playbook и SSH config.
- Evidence: реестр потребляют reverse proxy, `ru-vps-base.yml`, backup jobs, backup
audit и `validate.yml`; `pve-*.yml`, `status.yml`, monitoring и SSH config всё ещё
дублируют значения.
- Constraint: изменение VMID/IP/image/backup/monitoring требует сверки оставшихся
consumers. `make validate` ловит расхождение реестра с Proxmox по hostname, IP,
cores, memory и swap, но не по образам, бэкапам и SSH config.
- Decision: accepted risk до отдельной migration/validator задачи.
## Partial Ownership
- Caddy installation/container lifecycle на `ru-vps` и provisioning PBS CT 120 не
управляются репозиторием.
- Hermes playbook подготавливает runtime/proxy, но не deploy самого Hermes application.
- UI state Uptime Kuma и AdGuard не полностью декларативен.
- Constraint: не заявлять полную reproducibility этих компонентов без проверки
внешнего состояния.
## Compatibility Layers
- Gitea/Vaultwarden `caddy_legacy_regexp` удаляет старые Caddy sections. Не удалять
поля до подтвержденного успешного reverse-proxy migration run.
- Grimmory OPDS/KOReader headers и compression behavior являются device compatibility
contract, а dual v1/v2 API handling MCP соответствует deployed Grimmory v3.2.4.
- OpenVPN использует static-key configuration. OpenVPN 2.6 считает этот режим
deprecated; миграция на TLS требует отдельного network change plan.
## Superseded Planning Documents
- `current-task.md` - исторический Prometheus plan, не active task.
- `tasks/grimmory-deployment-plan.md` - смешивает план и deployment record; текущее
состояние проверять по Ansible.
- Constraint: не выполнять оставшиеся пункты этих документов автоматически.
- Decision: preserve as history; новые approved tasks записывать в `plan.md`.
+20
View File
@@ -0,0 +1,20 @@
# Официальные ссылки
Ссылки подобраны только для контрактов, которые нельзя надежно вывести из локального
кода. Если runtime version не закреплена, это указано явно.
| Тема | Официальная ссылка | Применимость |
|---|---|---|
| Ansible check/diff | [Check and diff mode](https://docs.ansible.com/projects/ansible-core/devel/playbook_guide/playbooks_checkmode.html) | Объясняет partial simulation и modules без check-mode; rolling docs, repository lower bound `>=2.19` |
| Proxmox Ansible module | [`community.proxmox.proxmox`](https://docs.ansible.com/ansible/latest/collections/community/proxmox/proxmox_module.html) | API provisioning; installed Galaxy version не закреплена |
| Proxmox LXC API | [LXC API endpoint](https://pve.proxmox.com/pve-docs/api-viewer/index.html#/nodes/{node}/lxc) | Контракт API role; live PVE version требует проверки |
| `pct` | [`pct(1)`](https://pve.proxmox.com/pve-docs/pct.1.html) | Direct-SSH provisioners и diagnostics; rolling PVE manual |
| OpenVPN | [OpenVPN 2.6 manual](https://build.openvpn.net/man/openvpn-2.6/openvpn.8.html) | Site/laptop tunnel directives; peer versions неизвестны, static-key mode deprecated в 2.6 |
| Caddy validation | [`caddy validate`](https://caddyserver.com/docs/command-line#caddy-validate) | Используется reverse proxy playbook; Caddy version не закреплена |
| Caddy reverse proxy | [`reverse_proxy`](https://caddyserver.com/docs/caddyfile/directives/reverse_proxy) | Public upstream и Grimmory handlers |
| restic integrity | [Checking integrity and consistency](https://restic.readthedocs.io/en/stable/045_working_with_repos.html#checking-integrity-and-consistency) | Различает metadata check и чтение data packs; runtime version неизвестна |
| restic restore | [Restore](https://restic.readthedocs.io/en/stable/050_restore.html) | Selective L2 restore в backup audit |
| Uptime Kuma | [README 1.23.16](https://github.com/louislam/uptime-kuma/blob/1.23.16/README.md) | Совпадает с pinned active image version |
| MCP TypeScript SDK | [Server guide 1.30.0](https://github.com/modelcontextprotocol/typescript-sdk/blob/1.30.0/docs/server.md) | Совпадает с exact package version и stdio server implementation |
| Grimmory API | [OpenAPI v3.2.4](https://github.com/grimmory-tools/grimmory/releases/download/v3.2.4/openapi.json) | Совпадает с deployed version; live docs newer и API объявлен unstable |
| Grimmory release | [Release v3.2.4](https://github.com/grimmory-tools/grimmory/releases/tag/v3.2.4) | Release/migration review перед изменением app image |
+788
View File
@@ -0,0 +1,788 @@
# Переход provisioning LXC на OpenTofu
Пошаговый план миграции. Рассчитан на исполнителя, который этот разговор не
видел: всё нужное либо здесь, либо по ссылкам на файлы репозитория.
Стратегия — **blue-green**: боевые контейнеры не импортируются в Tofu и не
переконфигурируются на месте. Вместо этого рядом создаётся новый контейнер,
туда переносятся данные, затем переключается адрес, а старый контейнер
некоторое время стоит остановленным как откат.
---
## 0. Как начать сессию по этому плану
Открыть Claude Code в корне репозитория и дать примерно такой промпт,
подставив нужный сервис:
Работаем по docs/ai/migration-tofu.md — переход provisioning LXC на OpenTofu
по схеме blue-green. Прочитай его целиком, а также tofu/README.md и
AGENTS.md.
Делаем сервис №1 из раздела 5 (emergency-bot, CT 148). Идём строго по
процедуре раздела 4, по шагам, не забегая вперёд.
Правила:
- разрушающие шаги (pct stop, pct destroy, tofu-destroy, смена адреса)
выполняешь только после моего явного подтверждения;
- старый контейнер не удаляем, он остаётся откатом;
- make validate должен быть зелёным до и после;
- если реальность разошлась с планом — останавливайся и говори, не
придумывай обход.
Между сервисами сессию имеет смысл начинать заново: контекст одного переезда
следующему не нужен, а свежий контекст надёжнее.
---
## 1. Что уже сделано — не переделывать
- `tofu/` с провайдером `bpg/proxmox`, цели `make tofu-init | tofu-plan |
tofu-apply CONFIRM=1 | tofu-destroy CONFIRM=1`.
- Аутентификация: `root@pam` по паролю из корневого `.env`
(`PROXMOX_ROOT_USER`, `PROXMOX_ROOT_PASSWORD`). Выбор режима автоматический,
печатается в stderr. Подробности и матрица возможностей — `tofu/README.md`.
- Цели `tofu-*` сами поднимают SSH-туннель через `ru-vps`: провайдер ходит в
API по HTTPS и ProxyJump не умеет.
- Реестр `homelab_services` программно потребляют: `reverse-proxy.yml`,
`ru-vps-base.yml`, `pve-backup-jobs.yml` (списки VMID по `backup.job`),
`roles/backup_audit` (VMID по `monitoring.backup_audit_vmid`), `validate.yml`.
- `make validate` — read-only gate, сверяет реестр с `pct config` по hostname,
IP, cores, memory, swap. Падает при расхождении.
- `make lint` воспроизводит CI (линтеры из корня + syntax-check всех плейбуков).
### Что проверено на пилоте (VMID 199, снесён)
В режиме `root@pam` Tofu выставляет декларативно всё, что нужно этой
инфраструктуре:
dev0: deny-write=0,path=/dev/net/tun,uid=0,gid=0,mode=0660
features: fuse=1,keyctl=1,nesting=1
mp0: data:199/vm-199-disk-1.raw,mp=/opt/pilot-data,size=4G
Повторный `plan` даёт `No changes`. Следствия:
- правка `/etc/pve/lxc/<vmid>.conf` через `lineinfile` (девять контейнеров)
заменяется декларацией в Tofu, но КАКОЙ именно — зависит от устройства:
`/dev/fuse` — штатным флагом `features { fuse = true }`, `/dev/net/tun` —
блоком `device_passthrough`. Оба варианта видны в выводе пилота выше:
`features: fuse=1,...` и `dev0: ...path=/dev/net/tun`;
- шаг `pct set <vmid> --features nesting=1,keyctl=1` больше не нужен;
- `lifecycle { ignore_changes = [features] }` не нужен;
- apply одной фазой.
**Важно:** всё это работает ТОЛЬКО под `root@pam` по паролю. API-токен, даже
принадлежащий root, проверку не проходит — в Proxmox она буквальная
(`$authuser eq 'root@pam'`, `PVE/LXC.pm:1658`), а при токенной аутентификации
`$authuser` равен полному `user@realm!tokenname` (`PVE/HTTPServer.pm:86`).
### Ключевое упрощение
**Если при переезде сохранить IP, меняется только VMID.** А все потребители
VMID уже выводят его из реестра автоматически (backup jobs, backup audit).
Поэтому cutover — это правка одного поля `vmid` в `services.yml`, а не
синхронная правка шести файлов. Ради этого и делалась генерация.
---
## 2. Инварианты
1. **Последовательным обязан быть только cutover.** Инвариант изначально
звучал как «один сервис за раз»; 2026-09-02 он уточнён после параллельного
прогона шести сервисов. Разделение такое:
- **Параллелится безопасно:** подготовка (снятие эталона, описание в Tofu),
создание новых контейнеров (один пакетный `apply`, Tofu сам разводит
ресурсы) и конфигурация на временных адресах (шаг 4.3). Всё это не
трогает боевые контейнеры и полностью откатывается точечным
`tofu destroy` нужного ресурса.
- **Остаётся строго последовательным:** перенос данных (4.4), переключение
адреса (4.5) и правка реестра (4.6) — по одному сервису, с явным
подтверждением человека на каждый разрушающий шаг.
Причины, по которым `apply` физически не параллелится: состояние Tofu одно
и локальное, а цели `make tofu-*` поднимают SSH-туннель на фиксированный
порт 18006 с фиксированным control-сокетом. Два одновременных прогона
столкнутся.
2. **Старый контейнер останавливается, но не удаляется** — минимум неделю. Это
единственный быстрый откат.
3. `make validate` зелёный до и после каждого переезда.
4. Перед стартом каждого сервиса — свежий бэкап PBS этого VMID.
5. VMID и IP выведенных контейнеров не переиспользовать сразу: в PBS остаются
цепочки по VMID, в кэшах — адреса.
6. **CT 120 `pbs` не мигрировать.** Это цель бэкапов, на которую опирается план
отката всех остальных. Он `unmanaged` и таким остаётся.
7. Не менять одновременно provisioning и рантайм сервиса без необходимости.
Если переводишь `docker run` на `compose_service` — это отдельное
осознанное решение по конкретному сервису, а не часть переезда по умолчанию.
---
## 3. Предпосылки перед первым переездом
- [x] Вывод `memoir-bot` — live-шаги оператора выполнены 2026-09-02: deploy key
`SecondBrain` отозван, монитор в Uptime Kuma снят, `pct stop 142`
выполнен (подтверждено `pct list` на mini-pc). Это была репетиция
удаления старого контейнера. `pct destroy 142` сознательно отложен —
см. [`plan.md`](plan.md) и общий инвариант о паузе перед удалением.
- [x] `make validate` — зелёный (проверено 2026-09-02, 12 сервисов, drift нет).
- [x] `make backup-audit` — прогнан 2026-09-02, все хосты `ok`, без ошибок.
- [x] `tofu/terraform.tfstate` — решено оставить только локальным на время
переезда сервиса №1. Осознанный риск: state небольшой, при потере можно
re-import всё созданное заново. Постоянное решение (например, offsite
restic-профиль) — отдельная задача, не блокирует старт.
- [x] Свободное место проверено. На 2026-09-02: cloud-pc `data` — 816 ГиБ
свободно, mini-pc `local-lvm` — 116 ГиБ. Запаса хватает на любой сервис,
включая grimmory (64 ГиБ).
### Пул временных адресов и VMID
Свободны на 2026-09-02: IP `192.168.1.6-9, 11-19, 21, 22, 26, 33, 36-40`,
VMID `151+` и `199`. Временный адрес обязан лежать в `192.168.1.5-40` —
только этот диапазон ru-vps маршрутизирует в LAN через `tun0`, иначе новый
контейнер будет недоступен по ProxyJump.
Шаблон `local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst` присутствует на
обеих нодах — проверено.
---
## 4. Процедура переезда одного сервиса
Обозначения: `OLD` — текущий VMID, `NEW` — временный VMID, `IP` — боевой адрес,
`TMPIP` — временный адрес.
### 4.1 Подготовка
1. `make validate` и `make backup-audit` — оба зелёные.
2. Снять эталон: `ssh <node> sudo pct config OLD` — сохранить вывод. Это
источник правды для того, что нужно воспроизвести.
3. Запустить свежий бэкап: `ssh <node> sudo vzdump OLD --storage pbs --mode snapshot`.
- **Успех проверять наличием снапшота, а не кодом возврата.** Исторически
на 2026-09-02 client-side prune давал `missing Datastore.Modify|Datastore.Prune`,
из-за чего `vzdump` печатал `Backup of VM ... failed` и возвращал ненулевой
код после успешной выгрузки. Это объяснение для старых логов; сейчас
client-side prune из плейбуков убран, поэтому любой новый non-zero надо
считать проблемой. Проверять так:
`ssh cloud-pc 'sudo pvesm list pbs | grep ct/<OLD>'`. Подробности —
[`plan.md`](plan.md), раздел про prune.
- **Если у сервиса Docker с fuse-overlayfs, а rootfs на каталоговом
хранилище (`data`) — сначала остановить сервис.** Такой rootfs не
поддерживает снапшоты, vzdump уходит в режим `suspend` с rsync и падает
на `var/lib/docker/fuse-overlayfs/.../merged`: Permission denied.
Проверено на gitea 2026-09-02: с остановленным сервисом бэкап проходит.
У сервисов на `local-lvm` (vaultwarden) проблемы нет — там снапшот.
- **У сервиса с bind mount в бэкап попадает только rootfs.** vzdump пишет
`excluding bind mount point mpN (...) from backup (not a volume)`. До
переезда это касалось gitea: его репозитории и БД в PBS не попадали
вообще. После перехода на volume попадают.
### 4.2 Описание в Tofu
4. Завести ресурс в `tofu/` (например `tofu/services.tf`), воспроизведя
эталон: `vm_id = NEW`, hostname как у боевого, `TMPIP`, cores, memory, swap,
rootfs на том же datastore и того же размера, `features`, декларацию для
каждого устройства из `lxc.mount.entry`, `mount_point` для каждого `mpN`,
`startup.order`, `start_on_boot`, `unprivileged`, `console { type = "shell" }`
(эталон `cmode` — см. ниже).
- **`/dev/fuse` — это `features.fuse`, а НЕ `device_passthrough`.**
Ловушка: в эталоне `/dev/fuse` выглядит как пара сырых строк
(`lxc.cgroup2.devices.allow: c 10:229 rwm` + `lxc.mount.entry`), и её
хочется механически перевести в `device_passthrough`. Так делать не надо:
`device_passthrough` (`dev0:` в PVE 8.2+) предназначен для сырых
character-устройств вида `/dev/net/tun`, а у `/dev/fuse` есть штатный
флаг PVE, проверенный на пилоте. См. `tofu/pilot.tf.example:17-21`.
Следствие: `pct config` нового контейнера покажет `features: fuse=1,...`,
то есть будет текстуально отличаться от эталона при том же эффекте —
это ожидаемо, а не drift.
- **Bind mount заменять на volume.** Если у сервиса `mpN: /host/path,mp=...`
(сейчас так только у gitea), в новом контейнере это должен быть
`mount_point { volume = "<datastore>", size = "...", path = "..." }`.
Данные переносятся на шаге 4.4, а не монтированием того же каталога:
два контейнера, пишущие в один каталог, повредят данные.
- **`console { type = "shell" }` обязателен.** `roles/pve_lxc` всем
контейнерам ставит `cmode: shell`; провайдер отслеживает это через блок
`console`, и без явного объявления следующий `tofu-plan` предложит
откатить его на дефолт Proxmox `tty` — тот же класс drift, что и
`keyctl` в гибридной схеме. Подробности и история находки —
`tofu/README.md`.
5. `make tofu-plan` — убедиться, что план ровно `1 to add`.
6. `make tofu-apply CONFIRM=1`.
7. Сверить: `ssh <node> sudo pct config NEW` против эталона из шага 2. Отличаться
должны только `vmid` и адрес (плюс явно выписанные Proxmox-дефолты вида
`cpulimit`, `protection`, `template`, `tty` — эталон их просто не
показывал, реальное поведение то же самое). `cmode: shell` должен совпасть
с эталоном уже на этом шаге, если блок `console` объявлен в шаге 4 — правкой
постфактум через `pct set` не чинить, это создаёт drift (см. шаг 4 выше и
`tofu/README.md`).
### 4.3 Настройка
8. Добавить временную запись в `ansible/inventory/hosts.yml`: `<name>-new` с
`ansible_host: TMPIP` и `expected_lan_ip: TMPIP`, и такую же запись в
`ansible/ssh_config` (Host-блок + в агрегированные строки ProxyJump, root,
общие опции).
- Во время cutover использовалась временная группа `lxc_migration_new`;
она не входила в `servers`, поэтому незавершённый переезд не краснил
`make check` и `make status`.
- **Транспорт задавался только в `group_vars/lxc_migration_new/main.yml`, а
не блоком `vars:` в самом `hosts.yml`.** У Ansible inline-переменные
группы из inventory-файла имеют приоритет НИЖЕ, чем `group_vars/all/`,
поэтому `ansible_user: ansible` оттуда перебивает inline `root`, и
подключение падает с `Permission denied`. Каталог `group_vars/<группа>/`
— наоборот, выше `all`. Найдено 2026-09-02.
9. Прогнать конфигурационную часть существующего плейбука против нового
контейнера:
ansible-playbook playbooks/pve-<name>.yml \
-e pve_config_target=<name>-new --limit <name>-new
**Одного `--limit <name>-new` НЕДОСТАТОЧНО, и это не особенность отдельного
сервиса.** Ansible пересекает лимит с паттерном play, а не подменяет его.
Паттерн конфигурационного play — литеральное имя боевого хоста (`hosts:
gitea`), пересечение с `gitea-new` пусто, и play молча получает `hosts (0)`.
Проверено 2026-09-02 через `--list-hosts` на всех плейбуках сразу: ноль
хостов во ВСЕХ play, а не только в создающих. Прошлая сессия
(emergency-bot) сочла это особенностью того сервиса — на самом деле это
общее свойство.
Поэтому у конфигурационных play заведён переопределяемый таргет:
hosts: "{{ pve_config_target | default('<name>') }}"
Он есть в `pve-docker-test.yml`, `pve-gitea.yml`, `pve-vaultwarden.yml`,
`pve-grimmory.yml`, `gyro.yml` и `uptime-kuma.yml`. По умолчанию поведение
не меняется — без `-e` паттерн равен прежнему литералу.
`--limit` при этом всё равно обязателен: он отсекает play создания
контейнера и play-стражи, которые таргетят ноду (`cloud-pc`/`mini-pc`) или
`localhost`. Без него плейбук пойдёт делать `pct create` и править
`/etc/pve/lxc/<OLD>.conf` на боевом VMID.
**Перед каждым прогоном сверяться с `--list-hosts`.** Ожидание: у play
создания и стражей `hosts (0)`, у конфигурационного — ровно `hosts (1):
<name>-new`. Это и есть предохранитель, а не формальность.
Особые случаи:
- `monitoring` — конфигурации в `pve-monitoring.yml` нет вообще, там только
создание. Настраивает `playbooks/uptime-kuma.yml` (роль `uptime_kuma`).
`playbooks/monitoring.yml` (замороженный Prometheus) НЕ запускать.
- `gyro` — конфигурация в `playbooks/gyro.yml`, нужен пароль Ansible Vault
(`make gyro` / `--ask-vault-pass`). Роль читает `host_vars/gyro/`.
Во время cutover использовался временный host_vars shim; после перевода CT
156 на боевой IP он удалён.
10. Проверить, что сервис поднялся на `TMPIP` (его health-эндпоинт или порт).
У сервисов с данными новый контейнер на этом шаге работает на ПУСТОМ
состоянии — данные приезжают только на шаге 4.4. Пустой Gitea, пустой
Vaultwarden с экраном создания учётки, пустая Uptime Kuma, свежая схема
Flyway у grimmory — это ожидаемый результат шага 4.3, а не поломка.
Поднимать новую копию на боевых данных до остановки старой НЕЛЬЗЯ: две
живые копии над одной SQLite или одной MariaDB повредят состояние.
### 4.4 Перенос данных
11. Остановить сервис на СТАРОМ контейнере (`systemctl stop <unit>`), сам
контейнер пока не трогать.
12. Финальная синхронизация. Способ зависит от сервиса — см. раздел 5.
13. Запустить сервис на новом, проверить на `TMPIP`.
### 4.5 Переключение
14. `ssh <node> sudo pct stop OLD`.
15. В Tofu поменять адрес нового контейнера с `TMPIP` на `IP`, `make tofu-apply CONFIRM=1`.
16. Перезапустить контейнер, если адрес не подхватился на живую.
- **Если конфигурация сервиса содержит его собственный адрес — прогнать
плейбук заново сразу после смены адреса.** Из пакета 2026-09-02 это
касается только `grimmory` (`ports: <ip>:6060:6060` и правила
`GRIMMORY-FILTER`): его compose всё ещё содержит TMPIP, и сервис не
поднимется, пока файл не перегенерируют. Проверять просто:
`ss -ltn` на новом контейнере — если сокет висит на конкретном адресе,
а не на `0.0.0.0`/`*`, повторный прогон обязателен. У gitea,
vaultwarden и uptime-kuma сокеты wildcard, им это не нужно.
Прогон уже без `-e pve_config_target`: адрес стал боевым, и имя хоста
само резолвится в новый контейнер.
17. Health-check на боевом `IP`.
18. Если сервис публичный (`proxy` в реестре) — `make reverse-proxy` и проверить
домен снаружи. При сохранённом IP апстрим не меняется, но прогон подтвердит.
19. Обновить `~/.ssh/known_hosts` — host key нового контейнера другой:
`ssh-keygen -R <IP>` и `ssh-keygen -R <name>`. В `ssh_config` стоит
`StrictHostKeyChecking accept-new`, который принимает НОВЫЕ ключи, но не
изменившиеся, поэтому без чистки подключение будет отвергнуто.
- **Заодно закрыть ControlMaster: `ssh -F ansible/ssh_config -O exit
<host>`.** Мастер переживает cutover живым, но его туннель ведёт в уже
остановленный контейнер; новые сессии мультиплексируются поверх мёртвого
соединения и виснут с `Connection timed out during banner exchange`.
Симптом выглядит как сетевая проблема, хотя контейнер полностью доступен
с ноды. Разовый обход для диагностики — `-o ControlPath=none`.
Найдено 2026-09-02 на docker-test.
- У gitea SSH host-ключи git-over-ssh лежат в переносимых данных
(`<data>/ssh/ssh_host_*`), поэтому порт 2222 после переезда отдаёт ТОТ ЖЕ
ключ и `known_hosts` git-клиентов остаётся валидным. Менять нужно только
ключ sshd самого контейнера (порт 22).
- **Про gyro и gitea: ранее записанное предупреждение неверно, проверено
2026-09-02.** Утверждалось, что `roles/gyro` пинит host key
`[192.168.1.25]:2222` и после переезда gitea сломается. На самом деле
задача `Remove obsolete Gitea known host` стоит с `state: absent` — она
УДАЛЯЕТ устаревшую запись, а не создаёт её. Пинится
`gyro_git_known_hosts_name: github.com`, и `gyro_repo_url` —
`git@github.com:...`. Gyro клонирует с GitHub, к gitea отношения не
имеет. Никаких дополнительных действий после переезда gitea не нужно.
### 4.5a Offsite restic: профиль надо переустановить на новом контейнере
**Шаг, которого в плане не было. Найден 2026-09-02 на vaultwarden.**
Если offsite-профиль restic выполняется ВНУТРИ контейнера сервиса (а не на
ноде), то на новом контейнере его нет: там нет ни `restic`, ни `rclone`, ни
systemd-таймера. Бэкап тихо перестаёт выполняться, и `make backup-audit` этого
не ловит — он ставит только свой таймер аудита.
Проверять так:
ssh <name> 'command -v restic rclone; systemctl list-timers --all | grep restic'
Чинить так:
make offsite-restic EXTRA="--limit <name>"
Затем обязательно проверить всю цепочку, а не только наличие таймера:
ssh <name> 'systemctl start homelab-restic-offsite-<name>.service'
ssh <name> 'journalctl -u homelab-restic-offsite-<name>.service -n 30 --no-pager'
Успех выглядит как подключение к репозиторию с уже существующими снапшотами и
`Finished ... Deactivated successfully`.
Кого это касается:
- `vaultwarden` — профиль внутри LXC. Сделано и проверено 2026-09-02.
- `grimmory` — профиль тоже внутри LXC (`hosts: grimmory`). Понадобится то же.
- `gitea` — профиль выполняется на cloud-pc и указывает на `/opt/data/gitea`.
Там задача другая: после переезда на volume этот путь исчезает, профиль надо
переписать на выполнение внутри контейнера, по образцу vaultwarden.
### 4.6 Реестр и потребители
20. В `ansible/inventory/group_vars/all/services.yml` поменять `vmid: OLD` на
`vmid: NEW`. Если менялся `provisioner` — поправить и его на `tofu`.
21. Убрать временные записи `<name>-new` из `hosts.yml` и `ssh_config`.
22. Прогнать зависящие цели: `make backup-jobs` (списки VMID выведутся заново),
`make backup-audit` (перерендерит скрипт аудита с новым VMID).
23. `make validate` — должен быть зелёный.
24. `make status` — контейнер UP, юниты active.
### 4.7 Завершение
25. Оставить `OLD` остановленным минимум неделю.
26. Затем `pct destroy OLD` и решить судьбу его цепочки бэкапов в PBS.
27. Удалить из `playbooks/pve-<name>.yml` часть, создающую контейнер (первый play
с `pct create` / ролью `pve_lxc`, правками `/etc/pve/lxc/*.conf` и
`pct set --features`). Оставить конфигурационную часть.
---
## 5. Порядок сервисов и специфика
Порядок выбран по возрастанию риска. Не менять без причины.
| # | Сервис | VMID | Узел | Почему здесь |
|---|---|---|---|---|
| 1 | `emergency-bot` | ~~148~~ **151** | mini-pc | ПЕРЕЕХАЛ 2026-09-02. Полностью шаблонный, персистентных данных нет |
| 2 | `docker-test` | ~~145~~ **152** | cloud-pc | ПЕРЕЕХАЛ 2026-09-02 |
| 3 | `gitea` | ~~141~~ **153** | cloud-pc | ПЕРЕЕХАЛ 2026-09-02; bind mount → volume, данные впервые попали в PBS |
| 4 | `vaultwarden` | ~~140~~ **154** | mini-pc | ПЕРЕЕХАЛ 2026-09-02 |
| 5 | `monitoring` | ~~146~~ **155** | cloud-pc | ПЕРЕЕХАЛ 2026-09-02; замороженный стек не переносился |
| 6 | `gyro` | ~~150~~ **156** | mini-pc | CUTOVER COMPLETED 2026-09-02; CT156 live on 192.168.1.35, CT150 held as rollback |
| 7 | `grimmory` | ~~149~~ **157** | cloud-pc | ПЕРЕЕХАЛ 2026-09-02; MariaDB через dump/restore |
| 8 | `adguard` | ~~144~~ **158** | mini-pc | ПЕРЕЕХАЛ 2026-09-03; данные /opt/adguard, DNS-провал ~2 мин |
| 9 | `mihomo` | ~~143~~ **159** | mini-pc | ПЕРЕЕХАЛ 2026-09-03; первый боевой device_passthrough /dev/net/tun |
| 10 | `ovpn-mini` | ~~132~~ **160** | mini-pc | ПЕРЕЕХАЛ 2026-09-03; туннель-петля при cutover, см. 5.10 |
| — | `hermes-ai` | 147 | cloud-pc | заморожен и недостижим по SSH, см. раздел 7 |
| — | `pbs` | 120 | cloud-pc | не мигрировать |
**Все мигрируемые сервисы (1-10) переехали на OpenTofu.** Осталось: `hermes-ai`
(заморожен), `pbs` (не мигрируется). Пост-миграционная уборка раздела 6
(удаление `roles/pve_lxc`, стрип creation plays у 8 плейбуков, правки
`architecture.md`) — отдельная задача, ещё не сделана; частично разблокирована.
### Специфика по сервисам
**1. `emergency-bot`.** ПЕРЕЕХАЛ 2026-09-02: OLD 148 остановлен, NEW 151 боевой
на 192.168.1.32. Данных нет вообще: `roles/emergency_bot` разворачивает
`emergency_bot.py.j2`, `.env.j2` и юнит из шаблонов. Шаг 4.4 пропущен целиком.
Бэкапа у него нет (`backup: none`) и это не мешает — он воспроизводим из
репозитория. Нужны `EMERGENCY_*` в корневом `.env`. Хороший первый заход
именно потому, что ошибиться почти негде — тем не менее, реальность разошлась
с планом в двух местах, оба задокументированы там, где их искать:
- **`playbooks/pve-emergency-bot.yml` не содержит конфигурационной части.**
У этого сервиса она в отдельном `playbooks/emergency-access.yml`, причём
два из четырёх play там таргетят `mini-pc` и `ru-vps`, а не сам контейнер
(доверие reverse-SSH через `authorized_key` с `exclusive: true` — полная
замена ключа, не добавление). Шаг 4.3.9 в его буквальном виде
(`--limit <name>-new` на существующем плейбуке) для этого сервиса не
работает: `hosts:` там — жёсткие имена, не группа, и полный прогон против
`mini-pc`/`ru-vps` до cutover преждевременно переключил бы доверие. Кроме
того, `emergency_bot.py.j2` использует Telegram `getUpdates` (не webhook) —
запуск второй копии с тем же `EMERGENCY_BOT_TOKEN`, что и боевая, ловит
409 conflict. Решение для шага 4.3 при следующем сервисе с похожей
архитектурой: деплоить только play(-и), таргетящие сам новый контейнер,
через одноразовый playbook с `hosts: <name>-new` и заведомо нерабочим,
но валидным по формату токеном/credential — так проверяется факт деплоя
(юнит стартует, зависимости стоят, egress работает) без конфликта с боевым
инстансом. Полный прогон с реальными credentials — уже после cutover,
когда исходный playbook с жёстким `hosts: <name>` сам начинает резолвиться
в новый контейнер (адрес не менялся, значит `--limit` не нужен вообще).
- **`cmode` — провайдер отслеживает его через блок `console`, но по
умолчанию блок не объявлен.** Подробности и правильная декларация —
`tofu/README.md` и шаг 4.2 выше.
**2. `docker-test` (145).** Тоже без данных. Проверяет проброс `/dev/fuse`
через `features.fuse` на реальном сервисе (не через `device_passthrough` —
см. уточнение в шаге 4.2).
**3. `gitea` (141 -> 153).** Главный приз и самый содержательный шаг.
- Было: `mp0: /opt/data/gitea,mp=/opt/gitea/data` — bind mount каталога ноды.
**Стало и подтверждено на живом контейнере 2026-09-02:**
`mp0: data:153/vm-153-disk-1.raw,mp=/opt/gitea/data,size=32G` — независимый
volume. Внутри виден как отдельная ФС `/dev/loop10`, ext4, 32 ГиБ.
Ради этого миграция и затевалась.
- Данные: `/opt/data/gitea` на cloud-pc (2.3 ГБ) → внутрь нового контейнера.
Каталоги `git/` (репозитории), `gitea/` (в т.ч. SQLite `gitea/gitea.db`,
~37 МиБ) и `ssh/`. На ноде всё принадлежит `101000:101000` — это host-side
отображение uid 1000 внутри unprivileged LXC.
Последовательность составлена по итогам разведки, но НЕ исполнялась:
ssh gitea 'systemctl stop gitea' # OLD, адрес ещё боевой
ssh cloud-pc "sudo sqlite3 /opt/data/gitea/gitea/gitea.db \
'PRAGMA integrity_check;'" # ожидается ok
# tar по SSH: rsync не годится, его нет на свежем контейнере,
# а tar есть в любом Debian
ssh cloud-pc 'sudo tar -C /opt/data/gitea -cf - .' \
| ssh gitea-new 'tar -C /opt/gitea/data -xf -'
# volume создан с нуля, поэтому владельца выставить явно ИЗНУТРИ
# контейнера — так результат не зависит от offset idmap
ssh gitea-new 'chown -R 1000:1000 /opt/gitea/data'
ssh gitea-new "sqlite3 /opt/gitea/data/gitea/gitea.db 'PRAGMA integrity_check;'"
ssh gitea-new 'systemctl start gitea'
Копирование внутри одного узла упирается в скорость диска, не сети: порядка
1-3 минут на 2.3 ГБ. Общее окно простоя `git.ada-dev.ru` — 5-10 минут.
- Offsite restic-профиль `gitea` выполняется **на cloud-pc**, а не внутри LXC,
и указывает на `/opt/data/gitea`. После переезда на volume этот путь исчезнет
— профиль в `playbooks/offsite-restic-yadisk.yml` придётся переписать на
новый источник. **Не забыть**, иначе offsite-бэкап Gitea тихо перестанет
работать.
- ~~`roles/gyro` пинит SSH host key gitea — обновить.~~ НЕВЕРНО, снято
2026-09-02: там `state: absent` (удаление устаревшей записи), а пинится
github.com. Подробности — в шаге 4.5.19.
- Публичный домен `git.ada-dev.ru` — после cutover `make reverse-proxy`.
**4. `vaultwarden` (140 -> 154).** SQLite `/opt/vaultwarden/data/db.sqlite3`,
публичный домен `pass.ada-dev.ru`.
Разведка 2026-09-02: каталог данных 2.9 МБ; `db.sqlite3` с живыми `-wal`/`-shm`,
`icon_cache`, `rsa_key.pem`, `config.json`. Каталогов `attachments/` и `sends/`
нет, эти возможности не используются. **`config.json` содержит ADMIN_TOKEN и
SMTP**, Ansible ими не управляет; они переедут вместе с каталогом. Это
ожидаемо, но файл не должен попасть в репозиторий.
Offsite-профиль restic здесь выполняется ВНУТРИ контейнера
(`hosts: vaultwarden`, `offsite_source_path: /opt/vaultwarden/data`), а не на
ноде, и адрес при переезде не меняется, поэтому **правки профиля не требуется**
в отличие от gitea.
Оба контейнера на mini-pc, поэтому копирование идёт через `pct exec` без
промежуточного файла. Последовательность составлена по итогам разведки, но НЕ
исполнялась:
ssh mini-pc 'sudo pgrep -x vzdump' # ожидается пусто
ssh mini-pc 'sudo pct exec 140 -- systemctl stop vaultwarden'
# .backup сливает WAL в один файл и заодно проверяет источник
ssh mini-pc "sudo pct exec 140 -- sqlite3 -cmd 'PRAGMA busy_timeout=30000;' \
/opt/vaultwarden/data/db.sqlite3 \".backup '/opt/vaultwarden/data/db.sqlite3.migrate'\""
ssh mini-pc 'sudo pct exec 140 -- sqlite3 \
/opt/vaultwarden/data/db.sqlite3.migrate "PRAGMA integrity_check;"'
ssh mini-pc 'sudo sh -c "pct exec 140 -- tar --exclude=./db.sqlite3 \
--exclude=./db.sqlite3-wal --exclude=./db.sqlite3-shm \
-cf - -C /opt/vaultwarden/data . | pct exec 154 -- tar -xf - -C /opt/vaultwarden/data"'
ssh mini-pc 'sudo pct exec 154 -- mv /opt/vaultwarden/data/db.sqlite3.migrate \
/opt/vaultwarden/data/db.sqlite3'
ssh mini-pc 'sudo pct exec 154 -- sqlite3 /opt/vaultwarden/data/db.sqlite3 \
"PRAGMA integrity_check;"'
ssh mini-pc 'sudo pct exec 140 -- rm /opt/vaultwarden/data/db.sqlite3.migrate'
ssh mini-pc 'sudo pct exec 154 -- systemctl restart vaultwarden'
Объём крошечный, простой на данных порядка полуминуты: время съедают
stop/restart и проверки, а не копирование.
**5. `monitoring` (146 -> 155).** Замороженный Prometheus-стек в новый
контейнер не разворачивать.
**Данные Uptime Kuma лежат НЕ в `/opt/monitoring`**, как утверждалось раньше.
Разведка 2026-09-02: это два независимых каталога верхнего уровня.
| Путь | Размер | Что это |
|---|---|---|
| `/opt/uptime-kuma` | 27 МБ | активный сервис, переносить целиком |
| `/opt/uptime-kuma/data/kuma.db` | 25.6 МиБ | мониторы и уведомления |
| `/opt/monitoring` | 1.7 ГБ | замороженный стек, почти всё — TSDB Prometheus |
Переносить нужно только `/opt/uptime-kuma`. TSDB замороженного стека переносить
незачем: читать его в новом контейнере будет нечему.
Конфигурация нового контейнера — `playbooks/uptime-kuma.yml`. У
`pve-monitoring.yml` конфигурационной части нет вообще, только создание.
`playbooks/monitoring.yml` не запускать.
Последовательность составлена по итогам разведки, но НЕ исполнялась:
ssh cloud-pc 'sudo pct exec 146 -- sqlite3 /opt/uptime-kuma/data/kuma.db \
"PRAGMA wal_checkpoint(TRUNCATE);"'
ssh cloud-pc 'sudo pct exec 146 -- systemctl stop uptime-kuma'
ssh cloud-pc 'sudo pct exec 146 -- sqlite3 /opt/uptime-kuma/data/kuma.db \
"PRAGMA integrity_check;"'
ssh cloud-pc 'sudo sh -c "pct exec 146 -- tar -C /opt -cpf - uptime-kuma \
| pct exec 155 -- tar -C /opt -xpf -"'
ssh cloud-pc 'sudo pct exec 155 -- sqlite3 /opt/uptime-kuma/data/kuma.db \
"PRAGMA integrity_check;"'
ssh cloud-pc 'sudo pct exec 155 -- systemctl start uptime-kuma'
**Вторую копию нельзя поднимать на боевых мониторах до остановки первой:**
Uptime Kuma активно пробит сервисы и шлёт уведомления, две копии дадут
дубликаты алертов.
Уменьшение контейнера — отдельное решение, не часть переезда. Цифры для него,
снятые 2026-09-02: боевой CT 146 занимает 173 МБ RAM из 4096 и 4.7 ГБ из 24;
новый CT 155 без Prometheus-стека — 184 МБ RAM и 1.8 ГБ диска.
В контейнере нет `curl` — для ручных HTTP-проверок использовать `wget`.
**6. `gyro` (150 -> 156).** CUTOVER COMPLETED 2026-09-02. CT 156 живёт на
неизменном production IP `192.168.1.35`, provisioned by Tofu. CT 150
остановлен и удерживается как rollback минимум на неделю; его PBS chain не
удалялся. Файл фаервола `156.fw` уже копия `150.fw`, cluster firewall по-
прежнему disabled. Timer `gyro.timer` active, last oneshot succeeded.
Данных для переноса не было, и TMPIP-specific bind/re-run не требовались.
**7. `grimmory` (149 -> 157).** Самый болезненный, и единственный, где
конфигурационный play пришлось чинить.
**Найденный баг и его исправление (2026-09-02).** В конфигурационном play
боевой адрес был зашит в трёх местах, из-за чего play невозможно было
прогнать против любого другого контейнера:
- `ports: "192.168.1.34:6060:6060"` в compose — Docker не биндит чужой адрес и
роняет `grimmory.service` ещё до старта приложения;
- правила `GRIMMORY-FILTER` (`--ctorigdst 192.168.1.34`) — фильтровали бы не
тот адрес;
- URL задачи `Wait for Grimmory health endpoint`. **Это было опаснее всего:**
задача выполняется на самом целевом хосте, поэтому даже после починки
биндинга она уходила бы по сети в БОЕВОЙ grimmory, получала 200 и давала
ложно-положительный результат, ничего не проверив в новом контейнере.
Исправлено через `grimmory_bind_ip: "{{ expected_lan_ip }}"` — ту же идиому,
что использует `roles/uptime_kuma`. На боевом хосте переменная равна
192.168.1.34, поэтому рендер не изменился: `--check --diff` с `--limit
grimmory` даёт `changed=0`, включая обе задачи, которые пишут адрес в файлы.
**Следствие для шага 4.5, которого в плане не было.** Grimmory — единственный
из шести сервисов пакета, кто биндится на конкретный адрес (`ss -ltn` на новых
контейнерах: gitea `0.0.0.0:3000`, vaultwarden `0.0.0.0:80`, uptime-kuma
`*:3001`, grimmory `192.168.1.16:6060`). После смены адреса на боевой compose
всё ещё будет содержать TMPIP, и сервис не поднимется. Плейбук ОБЯЗАН быть
прогнан заново сразу после cutover — см. шаг 4.5.16.
**Состояние после конфигурации на TMPIP (проверено):** оба юнита active,
оба контейнера healthy, `health=200` на 192.168.1.16, прогон идемпотентен
(`changed=0`).
**Образы совпали с боевым по digest** — `grimmory/grimmory:v3.2.4@sha256:dfa7afdf`
и `lscr.io/linuxserver/mariadb:11.4.8@sha256:91de7f70`. Это требование
`legacy-warning.md`: code-only downgrade после Flyway-миграции запрещён.
**Flyway: схемы сошлись.** На боевом и на новом контейнере одинаково —
`MAX(version)=144`, 142 миграции, все успешны. То есть тот же образ на пустой
базе приходит ровно к боевой версии схемы, и restore дампа новых миграций не
вызовет. Холодный старт с нуля занимает около 5 минут — health-check ретраится,
это нормально.
**Перенос данных.** `/opt/grimmory` — 311 МБ: `books/` 139 МБ (библиотека),
`data/` 5.6 МБ (обложки), `mariadb/` 167 МБ. Файлы MariaDB копировать НЕЛЬЗЯ,
нужен dump/restore. Рабочий рецепт дампа уже есть в
`tasks/offsite-restic-profile.yml` (`mariadb-dump --single-transaction
--routines --events`). Последовательность составлена по итогам разведки, но НЕ
исполнялась:
# приложение стоп, MariaDB оставить живой
ssh grimmory 'cd /opt/grimmory && docker compose stop grimmory'
ssh grimmory 'set -a; . /opt/grimmory/.env; set +a; \
docker exec -e MYSQL_PWD="$DB_PASSWORD" grimmory-mariadb mariadb-dump \
--user=grimmory --single-transaction --routines --events \
--databases grimmory > /opt/grimmory/backup-staging/grimmory.sql'
# библиотека и обложки
ssh cloud-pc 'sudo sh -c "pct exec 149 -- tar -C /opt/grimmory -cpf - books data \
| pct exec 157 -- tar -C /opt/grimmory -xpf -"'
# дамп на новый и restore
ssh cloud-pc 'sudo sh -c "pct exec 149 -- cat /opt/grimmory/backup-staging/grimmory.sql \
| pct exec 157 -- tee /opt/grimmory/backup-staging/grimmory.sql >/dev/null"'
ssh grimmory-new 'cd /opt/grimmory && docker compose stop grimmory'
ssh grimmory-new 'set -a; . /opt/grimmory/.env; set +a; \
docker exec -i -e MYSQL_PWD="$DB_PASSWORD" grimmory-mariadb mariadb \
--user=grimmory grimmory < /opt/grimmory/backup-staging/grimmory.sql'
ssh grimmory-new 'systemctl restart grimmory'
**Дамп не содержит `--add-drop-database`.** На новом контейнере схема уже
создана Flyway с нуля, поэтому перед restore её нужно либо очистить, либо
добавить эту опцию в дамп. Отдельно стоит знать, что restore-путь в этом
репозитории НИКОГДА не проверялся: `edge-cases.md` отмечает, что аудит
проверяет непустоту дампа, но не импортирует его.
**Эталон для сверки после restore** (снят с боевого 2026-09-02):
`book 31`, `author 30`, `book_file 40`, `book_metadata 31`, `category 87`,
`reading_sessions 68`, `shelf 3`, `library 1`, `users 1`, `opds_user_v2 2`,
`koreader_user 1`, `flyway_schema_history 142`.
**После cutover — проверка OPDS на живой читалке, а не только curl'ом.**
Быстрая проверка заголовков:
curl -sS -D- -o /dev/null https://books.ada-dev.ru/api/v1/opds # atom+xml, без Content-Encoding
curl -sS -D- -o /dev/null https://books.ada-dev.ru/api/v1/opds/search.opds
curl -sS https://books.ada-dev.ru/api/v1/healthcheck
Затем в KOReader: добавить каталог, увидеть список полок, скачать книгу
целиком, проверить синхронизацию прогресса.
**8. `adguard` (144 → 158).** ПЕРЕЕХАЛ 2026-09-03. OLD 144 остановлен (откат
≥ неделя, до ~2026-09-10). Данные `/opt/adguard/{conf,work}` (~313 МБ,
`conf/AdGuardHome.yaml` несёт хэш пароля, DNS rewrites, upstream, клиентов)
перенесены целиком через `pct exec … tar` (оба контейнера на mini-pc).
- **Свежий AdGuard на пустом `conf/` не проходит health-гейт плейбука.** Он
уходит в setup-wizard: порт 3000 отдаёт 302, порт 80 — connection reset, а
`pve-adguard.yml` ждёт `http://127.0.0.1/` `[200,302]` и падает после 24
ретраев. Поэтому для adguard данные пред-заливаются ДО шага 4.3 (config play),
а не после. После пред-заливки прогон идемпотентен (`ok=13 changed=0`),
фильтрация подтверждена: `doubleclick.net → 0.0.0.0`.
- Ноды используют DNS роутера (192.168.1.1), контейнеры — 1.1.1.1 (из
`pct config` `nameserver`). Провал .28 задел только DHCP-клиентов LAN и
рабочую станцию (у неё fallback на роутер). Окно ~2 мин, ночью. Роутер не
трогали.
- Сокеты wildcard (`0.0.0.0`), повторный прогон после смены адреса не нужен.
**9. `mihomo` (143 → 159).** ПЕРЕЕХАЛ 2026-09-03. OLD 143 остановлен (откат
≥ неделя). Первый боевой сервис с **двумя механизмами проброса устройств**:
`/dev/fuse` через `features.fuse`, `/dev/net/tun` через блок
`device_passthrough` (`dev0: path=/dev/net/tun,mode=0660`) — оба под root@pam,
проверены здесь на живом сервисе (до этого `device_passthrough` был только на
пилоте).
- Данные `/opt/mihomo` (~80 КБ): `config/config.yaml`, `config/cache.db`,
`config/providers/main.yaml`. `config.yaml` содержит URL подписки прокси-
провайдера — секрет, копируется `pct exec`, в репозиторий не попадает.
Задача «Install default mihomo config if missing» идёт с `force: false`,
перенесённый конфиг не перетирается.
- Все сокеты wildcard (`0.0.0.0`) — повторный прогон после смены адреса не
нужен. Прокси реально проверен: `curl -x .27:7890 …/generate_204 → 204`.
- `ru-vps-mihomo-harden.yml` повторного прогона НЕ требует: он таргетит
`hosts: ru-vps`, работает со скриптом `/usr/local/sbin/ru-vps-mihomo-harden`
на ru-vps, адрес mihomo нигде в нём не зашит, и он за `CONFIRM`-гейтом.
- Зависимые (`bash_config_proxy_*` у hermes-ai, `emergency_telegram_proxy`,
`uptime_kuma_http_proxy`, правило gyro-фаервола `OUT ACCEPT 192.168.1.27:7890`,
`prometheus.yml.j2`) все ссылаются на .27 — адрес сохранён, правок не нужно.
**10. `ovpn-mini` (132 → 160).** ПЕРЕЕХАЛ 2026-09-03 из локальной сети. OLD 132
остановлен (откат ≥ неделя). `/dev/net/tun` через `device_passthrough`.
`features` — только `nesting` (как в эталоне). Конфигурации в `pve-ovpn-mini.yml`
нет — шлюз настраивает `openvpn-vps-mini.yml` (роль `openvpn_gateway`, группа
`vpn_openvpn`). Единственные данные — `/etc/openvpn/homelab/static.key`, общий
с ru-vps; LAN-адрес ovpn-mini ни в одном шаблоне роли не фигурирует
(masquerade по `-o eth0`), поэтому туннель не зависит от смены адреса.
- **Петля транспорта при cutover.** `-F ansible/ssh_config` до нод PVE идёт
ProxyJump через ru-vps, а ru-vps достаёт LAN ЧЕРЕЗ туннель, который
терминирует ovpn-mini. `make tofu-*` строит SSH-туннель к PVE API тем же
путём. `pct stop 132` кладёт туннель → `make tofu-apply` больше не достаёт
API. Разрыв: сначала поднять шлюз на НОВОМ контейнере ещё на TMPIP
(одноразовый плейбук `hosts: ru-vps` slurp ключа + `hosts: <name>-new` роль
`openvpn_gateway`), туннель встаёт с TMPIP-адреса → `make tofu-apply` снова
работает → сменить адрес на боевой → повторный `openvpn-vps-mini.yml`
(идемпотентен, `changed=0` на обоих концах). Прямой доступ к нодам во время
провала — `ssh -o ProxyJump=none <ansible-user>@192.168.1.{5,10}` из LAN.
- **`ssh_config`: у `ovpn-mini` теперь `ProxyJump none`** (в индивидуальном
Host-блоке, побеждает по «первое значение опции»). Путь через ru-vps
закольцовывался бы на его же туннель. Из LAN хост доступен напрямую; вне
LAN управление — консоль ноды (`pct exec`) или заранее поднятый туннель.
- **НЕ запускать OLD 132 как диагностику.** Его `pct config` всё ещё держит
`ip=192.168.1.23/24`; параллельный старт с CT 160 даёт конфликт .23 и роняет
туннель на 1-2 мин (проверено случайно 2026-09-03, восстановилось само).
- `make openvpn-check` — 7/7 ok. Кворум кластера не затронут (qdevice ходит
напрямую нода → ru-vps:5403, не через туннель).
- Косметика: `polkit.service` на CT 160 в `failed` (`status=217/USER` —
минимальный LXC-шаблон без нужного окружения polkit). На OpenVPN не влияет,
не чинилось.
---
## 6. После завершения всех переездов
- Удалить `roles/pve_lxc` — он станет не нужен.
- Убрать из `services.yml` поле `provisioner` со значениями `pct_ssh`/`pve_lxc`
либо заменить на `tofu`.
- Обновить `docs/ai/architecture.md`: раздел «Provisioning Flow» описывает два
пути через `pct` и API — оба исчезнут.
- Обновить `legacy-warning.md`: пункт про два provisioner-пути потеряет смысл.
- Расширить `validate.yml`: сейчас он сверяет hostname, IP, cores, memory, swap.
После миграции имеет смысл добавить features, устройства и mount points —
ровно те поля, которыми теперь управляет Tofu.
- Решить, нужен ли `roles/lxc_docker_host` — он до сих пор ни к чему не
подключён.
---
## 7. Известные проблемы вне миграции
Не блокируют переезд, но про них надо знать.
- **`hermes-ai` (147) недостижим по SSH.** Прозрачный прокси заворачивает в
`hermes-tun` всё, что не пришло с `lo`, включая ответные пакеты входящих
соединений (`ip rule` 9002). Ansible до хоста не достучится, управление —
только через `pct exec`. Сервис заморожен, `make check` показывает его DOWN
ожидаемо. Мигрировать не раньше, чем починится сеть.
- **`resticprofile-check@profile-default` на ru-vps падает еженедельно**:
профиль `default` без репозитория. Реальный `profile-services` работает.
Шум в секции FAILED отчёта `make status`.
- **Gitea runner** зарегистрирован и опрашивает Gitea, но метка `ru-vps` не
совпадает с `runs-on: ubuntu-latest` в workflow, поэтому CI не выполняется.
Раннер монтирует `/var/run/docker.sock` и `/opt/services` на запись — перед
включением CI это стоит пересмотреть.
- **`homelab_pve_egress_ip` динамический.** При смене домашнего адреса qdevice
замолчит. Видно в секции CLUSTER QUORUM отчёта `make status`.
- **`bootstrap-pve-api-token.yml` перезаписывает корневой `.env` целиком** —
вместе с `PROXMOX_ROOT_PASSWORD`, `MONITORING_*` и `EMERGENCY_*`. У задачи
есть `backup: true`, но восстанавливать придётся руками.
- Устаревшие правила UFW на ru-vps (`3128`, `1080`, `993`, `7892`) — за
переключателем `zt_cleanup_unrelated_stale_rules` в
`playbooks/ru-vps-zerotier-decommission.yml`, по умолчанию выключен.
---
## 8. Откат
**До шага 4.5.14** (пока старый контейнер работает): просто не переключаться.
Удалить новый контейнер `make tofu-destroy CONFIRM=1` или точечно.
**После переключения, но до `pct destroy`:** остановить новый, вернуть адрес
старому не нужно — он не менялся, `pct start OLD` возвращает всё как было.
Затем откатить `vmid` в `services.yml` и прогнать `make backup-jobs`,
`make backup-audit`, `make validate`.
**После `pct destroy OLD`:** только восстановление из PBS. Именно поэтому
шаг 4.1.3 (свежий бэкап) обязателен, а шаг 4.7.25 (неделя ожидания) не
сокращается.
+485
View File
@@ -0,0 +1,485 @@
# План работ
## Активные задачи
### Вывод memoir-bot (CT 142)
Решение от 2026-09-02: сервис не используется и выводится из эксплуатации.
Repository-часть выполнена: удалены playbook, host, запись реестра, VMID из backup
job и backup audit, SSH host-блок, проверки status, Prometheus target и no_proxy
Uptime Kuma. Оговорка "Memoir Bot строится локально" убрана из документации -
теперь единственные исключения из digest pinning это frozen Prometheus и docker-test.
Live-шаги оператора выполнены 2026-09-02: deploy key `SecondBrain` отозван,
monitor в Uptime Kuma снят, `pct stop 142` выполнен (подтверждено `pct list` на
mini-pc). Это заодно репетиция удаления старого контейнера перед первым
blue-green переездом (`migration-tofu.md`).
Оставшиеся шаги:
1. Выдержать паузу (по аналогии с общим инвариантом blue-green - минимум
неделю), затем `pct destroy 142`.
2. Решить судьбу цепочки бэкапов VMID 142 в PBS.
3. Не переиспользовать VMID 142 и `192.168.1.26` сразу.
### Регрессия переезда: update-плейбуки зашивают старые VMID
Обнаружено 2026-09-02 сразу после cutover.
`playbooks/vaultwarden-update.yml` и `playbooks/grimmory-update.yml` теперь берут
backup VMID из registry, а не из жёстко прошитых чисел. Это закрывает старую
регрессию со «страховочным бэкапом» на остановленном контейнере.
Аудит также нашёл и исправил две проблемы в Gitea: небезопасный импорт legacy
provisioning без явного выключателя и запуск `homelab-restic-offsite-gitea` на
`cloud-pc` вместо `gitea` (профиль теперь живёт в CT 153). Теперь consumer status
следует реальному backup unit на `gitea`, а `homelab-backup-audit-gitea` остаётся
на `cloud-pc`.
Обновлено 2026-09-03: `adguard-update.yml` и `mihomo-update.yml` изначально
читают VMID из реестра (`homelab_services['<svc>'].vmid`), поэтому после переезда
adguard→158 и mihomo→159 они автоматически указывают на новые контейнеры —
правок не потребовалось.
`make update-vaultwarden`, `make update-grimmory`, `make update-gitea`,
`make update-adguard`, `make update-mihomo` и `make update-all` не запускались
во время проверки.
### Task 3 — PBS storage-level prune removed declaratively
Выполнено 2026-09-02: storage-level `prune-backups` на PVE storage `pbs` удалён
декларативно, retention authority остался в PBS `prune-pbs`.
Локальное недельное PBS-container backup на storage `backup` с
`keep-last=2` — намеренное исключение и не трогалось.
### Кворум кластера: qdevice не голосует
Обнаружено 2026-09-02. `pvecm status`: `Expected votes: 3`, `Total votes: 2`,
флаги узлов `A,NV,NMW` (NV = Not-Voted), `corosync-qdevice: Connect timeout`.
Причина: `corosync.conf` указывает арбитр по публичному адресу ru-vps
(`host: 157.22.231.198`), а UFW пускал 5403/tcp только из `10.122.62.0/24`
сети ZeroTier, выведенной в июле 2026. Арбитр отвалился молча.
Последствие: у двухнодового кластера нет третьего голоса. Кворум держится лишь
пока живы обе ноды; отказ любой из них оставляет выжившую с `1 < 2`.
Решение: чинить ПРЯМОЙ путь нода -> ru-vps:5403, а не заворачивать арбитр в
OpenVPN. Туннель терминируется в `ovpn-mini` (CT 132 на mini-pc), поэтому при
падении mini-pc арбитр исчез бы вместе с ним — защищён был бы только отказ
cloud-pc. Плюс `ovpn-mini` стоит в плане на blue-green переезд, и его
пересоздание роняло бы кворум.
Сделано: `homelab_pve_egress_ip` в `group_vars/all/main.yml`, правила UFW в
`playbooks/ru-vps-base.yml` (открыть 5403 с этого адреса, удалить правило для
`10.122.62.0/24`), проверка кворума добавлена в `playbooks/status.yml` — секция
CLUSTER QUORUM, чтобы повторный отказ не был снова молчаливым.
ВЫПОЛНЕНО 2026-09-02. После прогона `make ru-vps-base`: `Total votes: 3`,
флаги узлов сменились с `A,NV,NMW` на `A,V,NMW`, qdevice отдаёт голос.
Кластер снова имеет арбитра.
Открытым остаётся динамический `homelab_pve_egress_ip`: адрес зафиксирован
статически, при его смене qdevice снова замолчит. Отличие от прошлого раза в
том, что теперь это видно в секции CLUSTER QUORUM отчёта `make status`.
### Вывод ZeroTier с ru-vps
Решение от 2026-09-02: выводить полностью. Проверено — ZT-интерфейса на хосте
нет, маршрутов через него нет, на ZT-адресах никто не слушает; контейнер
`zerotier` подключён в никуда. Мёртв и `ssh-zt22.service` (sshd на порту 22 для
setup qdevice) — порт 22 не слушает никто.
ВЫПОЛНЕНО 2026-09-02 через `playbooks/ru-vps-zerotier-decommission.yml`
(`make zerotier-decommission CONFIRM=1`). Контейнер снят, `ssh-zt22.service`
отключён, правила UFW для интерфейса `zt6q3dmi2d`, `9993/udp`, `9001` и подсети
`10.122.62.0/24` удалены. Сброшено состояние failed у `ssh-zt22.service` и
фантомного `homelab-pve-routes.service`. Прогон идемпотентен, публичные сервисы
и кворум не пострадали.
Намеренно НЕ удалено: каталог `/opt/services/ru-vps/zerotier`, данные
`/opt/data/zerotier`, файл `/etc/ssh/sshd_config_zt22`. `docker compose down`
выполняется без `-v`, identity узла сохранена. Удаление — отдельный шаг.
Правила UFW для `3128/tcp` (squid не запущен), `1080/tcp` (danted слушает 1081),
`993/tcp` и `7892/tcp` оставлены: они не наследие ZeroTier. В плейбуке есть
переключатель `zt_cleanup_unrelated_stale_rules`, по умолчанию выключен.
### resticprofile-check@profile-default падает еженедельно
Обнаружено 2026-09-02 при чистке упавших юнитов на ru-vps.
Fatal: Please specify repository location (-r or --repository-file)
check on profile 'default': exit status 1
Профиль `default` в `/opt/services/ru-vps/resticprofile/profiles.toml` не имеет
репозитория — это профиль-заготовка, для которого не должно быть таймера
проверки. Реальный `profile-services` бэкапится и проверяется штатно, так что
это шум, а не потеря бэкапов.
Значение: юнит постоянно висит в секции FAILED SYSTEMD UNITS отчёта
`make status` и притупляет внимание к настоящим отказам. Стек resticprofile на
ru-vps в Ansible не описан, поэтому чинится либо вручную
(`systemctl disable --now resticprofile-check@profile-default.timer`), либо
вместе со взятием стека под управление.
Прочие упавшие юниты на ru-vps — `ifup@eth0.service` и `networking.service`;
не разбирались, хост при этом полностью работоспособен.
### Gitea Actions: документация устарела
Проверено 2026-09-02. Контейнер `gitea-runner-gitea-runner-1` на ru-vps работает
и опрашивает Gitea, то есть раннер ЗАРЕГИСТРИРОВАН. Утверждения в
`.gitea/workflows/lint.yml` и `tech-stack.md` об обратном неверны.
CI при этом всё равно не выполняется: у раннера метка `ru-vps`, а workflow
требует `runs-on: ubuntu-latest`. Отдельный вопрос перед включением CI: раннер
монтирует `/var/run/docker.sock` и `/opt/services` на запись, то есть любой
workflow получает root над публичной VPS.
### hermes-ai недостижим по SSH: прозрачный прокси съедает обратный путь
Обнаружено 2026-09-02. CT 147 запущен, sshd слушает `*:22`, UFW разрешает 22/tcp
из `192.168.1.0/24` и `10.78.0.0/30`, но хост не отвечает ни с ru-vps, ни с
cloud-pc (то есть и из самой LAN). `make check` показывает его unreachable.
Причина видна в `ip rule` внутри контейнера:
9002: not from all iif lo lookup 2022
Правило заворачивает в таблицу прозрачного прокси (`hermes-tun`) всё, что не
пришло с `lo`, включая ответные пакеты входящих соединений. SYN доходит, SYN-ACK
уходит в туннель — соединение не устанавливается. Исключения для трафика,
пришедшего с eth0, в конфигурации нет.
Побочное следствие: `playbooks/pve-hermes-ai.yml` больше не может отработать —
Ansible не достучится до хоста. Управлять контейнером можно только через
`pct exec` с cloud-pc. Плейбук, судя по всему, отработал один раз и запер себя:
включение прокси не рвёт уже установленную сессию, только новые.
Не чиню: сервис признан малополезным и заморожен (см. ниже). Но `make check` и
`make status` будут показывать его DOWN, и это ожидаемо, а не новая поломка.
### Hermes AI (CT 147) - осознанно заморожен
Решение от 2026-09-02: сервис признан малополезным, но контейнер остается.
`playbooks/pve-hermes-ai.yml` разворачивает runtime, Docker и transparent TUN proxy
через mihomo, но не разворачивает приложение Hermes - это не недоделка, а принятое
состояние. Не предлагать "дописать деплой Hermes" как opportunistic cleanup.
### Секреты переехали в корень репозитория
Решение от 2026-09-02. `.env` и `.env.example` перенесены из `ansible/` в корень:
их потребляет не только Ansible, но и OpenTofu, а держать два файла или ходить в
соседний подкаталог неудобно.
`ENV_FILE` в `ansible/Makefile` теперь абсолютный (`$(REPO_ROOT)/.env`), поэтому
цели работают из любого cwd. Переименование `.env.example` заведено в индекс git,
чтобы файл не потерялся при коммите. Оба пути покрыты `.gitignore`.
Принято решение хранить пароль `root@pam` в `.env` (а не спрашивать его при
запуске). Компромисс осознанный: пользователь `ansible` и так имеет passwordless
sudo на нодах, то есть эффективный root в автоматизации уже был; пароль добавляет
не новый класс доступа, а секрет с худшими свойствами — он же логин в веб-интерфейс
и консоль, его нельзя ограничить по scope и нельзя отозвать иначе, чем сменив
пароль root на нодах.
Переменные: `PROXMOX_ROOT_USER` (по умолчанию `root@pam`) и
`PROXMOX_ROOT_PASSWORD`. Их читают ТОЛЬКО цели `tofu-*`; Ansible ими не
пользуется. Если пароль не задан или равен `replace-me`, Tofu идёт токеном
`ansible@pve`. Выбранный режим печатается в stderr перед запуском.
### Пилот OpenTofu: граница возможностей API-токена
Выполнено 2026-09-02. Каталог `tofu/`, цели `make tofu-*`, подробности и матрица
возможностей — в [`../../tofu/README.md`](../../tofu/README.md).
Кратко, проверено на живом кластере контейнером VMID 199:
- API-токен создаёт LXC со всеми ресурсами, сетью, rootfs, `nesting`, startup и
тегами; `plan` идемпотентен.
- **Mount point как volume на datastore токеном создаётся.** Это снимает вопрос
по `mp0` у gitea: bind mount каталога хоста требует root@pam, а volume — нет.
- `keyctl`, `fuse`, `mount` и `device_passthrough` требуют root@pam (HTTP 403).
Это ограничение Proxmox, а не Tofu: Ansible упирается в то же самое, поэтому в
репозитории уже есть `pct set --features` по SSH и правка
`/etc/pve/lxc/<vmid>.conf`.
- Гибрид Tofu+Ansible требует `lifecycle { ignore_changes = [features] }`, иначе
Tofu откатывает выставленный извне `keyctl` и ломает Docker в контейнере.
- Tofu не умеет ProxyJump: вне LAN нужен SSH-туннель, он встроен в цели `tofu-*`.
РЕЖИМ ВЫБРАН 2026-09-02: root@pam по паролю. Проверено на пилоте — `dev0`,
`features: fuse=1,keyctl=1,nesting=1` и volume mount point выставляются
декларативно, повторный `plan` даёт `No changes`. Значит при переезде каждого
сервиса из его `pve-*.yml` можно убирать `pct set --features` и правку
`/etc/pve/lxc/<vmid>.conf`; `ignore_changes` не нужен.
Токен, принадлежащий root@pam, ограничение НЕ обходит: `$authuser` при токенной
аутентификации равен полному `user@realm!tokenname` (`PVE/HTTPServer.pm:86`), а
проверка в `PVE/LXC.pm:1658` сравнивает строку с `root@pam` буквально. Права
токену добавлять бесполезно. Единственная альтернатива гибриду — пароль root@pam,
то есть системный пароль root узлов Proxmox.
Гибридная схема (Tofu создаёт, Ansible доводит по SSH) отвергнута в пользу
полной декларативности. Она остаётся запасным вариантом, если пароль root
решат из `.env` убрать.
Пилотный контейнер VMID 199 `tofu-pilot` снесён (подтверждено 2026-09-02:
`pct list` на cloud-pc его не показывает).
## Переход на OpenTofu
Пошаговый план миграции provisioning на Tofu вынесен в отдельный документ:
[`migration-tofu.md`](migration-tofu.md). Там же порядок сервисов, специфика
каждого и процедура отката.
Сервис №1 `emergency-bot` переехал 2026-09-02: OLD (VMID 148) остановлен
(откат минимум неделю, затем `pct destroy`), боевой — NEW (VMID 151,
`tofu/services.tf`), адрес не менялся (192.168.1.32). Реестр обновлён
(`vmid: 151`, `provisioner: tofu`), `make validate` и `make status` зелёные.
Две находки, важные для следующих сервисов, задокументированы в
`migration-tofu.md` (раздел 5.1) и `tofu/README.md`: конфигурационная часть
сервиса может жить в отдельном playbook с play, таргетящими другие хосты (не
подходит под буквальный `--limit <name>-new`), и провайдер отслеживает
`cmode` через блок `console`, который нужно объявлять явно с самого начала.
### Пакетный переезд сервисов 2-7 (параллельно)
Решение от 2026-09-02: последовательный переезд по одному сервису слишком
медленный. Инвариант «один сервис за раз» уточнён в `migration-tofu.md`:
последовательным обязан быть только cutover, а подготовка, создание и
конфигурация на временных адресах параллелятся безопасно.
**Создано и проверено 2026-09-02.** Один пакетный `tofu apply` (`6 to add,
0 to change, 0 to destroy` — уже переехавший emergency-bot дрейфа не дал):
| Сервис | OLD | NEW | Узел | TMPIP |
|---|---|---|---|---|
| docker-test | 145 | 152 | cloud-pc | 192.168.1.11 |
| gitea | 141 | 153 | cloud-pc | 192.168.1.12 |
| vaultwarden | 140 | 154 | mini-pc | 192.168.1.13 |
| monitoring | 146 | 155 | cloud-pc | 192.168.1.14 |
| gyro | 150 | 156 | mini-pc | 192.168.1.15 |
| grimmory | 149 | 157 | cloud-pc | 192.168.1.16 |
`pct config` всех шести сверен с эталонами: cpu, память, swap, диск, features,
startup, `cmode: shell` совпали. Запас на нодах после создания: cloud-pc
12.3 ГиБ RAM и 849 ГиБ на `data`, mini-pc 8.8 ГиБ RAM и 117 ГиБ на `local-lvm`.
`make validate` зелёный до и после — новые контейнеры в реестре пока не
числятся, это ожидаемо до шага 4.6.
Что подтвердилось на живых контейнерах впервые:
- **`/dev/fuse` через `features.fuse` эквивалентен старому обходу.** Устройство
присутствует в 152, 153, 154, 157 и отсутствует в 156 (у gyro его и не
должно быть). Это снимает вопрос, который до сих пор был помечен как
непроверенный: пилот проверял `device_passthrough` только на `/dev/net/tun`.
- **Bind mount gitea заменён на volume декларативно:**
`mp0: data:153/vm-153-disk-1.raw,mp=/opt/gitea/data,size=32G`. Ради этого
миграция и затевалась.
- **Пустой `features` у gyro сохраняется.** В `pct config 156` строки
`features` нет вообще, `nesting` не подкрался.
Две ловушки, найденные при этом заходе и почищенные в коде:
1. **`--limit <name>-new` не перенацеливает play, а обнуляет его** — и это
общее свойство, а не особенность emergency-bot, как считалось раньше.
Проверено `--list-hosts`: ноль хостов во ВСЕХ play всех плейбуков.
Заведён переопределяемый таргет `pve_config_target`, поведение по
умолчанию не изменилось. Подробности — `migration-tofu.md`, шаг 4.3.9.
2. **Inline `vars:` у группы в inventory-файле проигрывают `group_vars/all/`.**
Из-за этого Ansible ходил в новые контейнеры под `ansible@` вместо `root`
и получал `Permission denied`. Транспорт вынесен в
`group_vars/lxc_migration_new/main.yml`.
Попутно `roles/uptime_kuma` научился не падать на хосте, где замороженного
юнита `homelab-monitoring` никогда не было: заморозка теперь выполняется
только при его наличии.
Ещё один потребитель, которого не было в плане: `playbooks/vaultwarden-update.yml`
жёстко зашивает VMID 140, включая `vzdump "140"`. Его нужно поправить на шаге
4.6 вместе с реестром.
**Конфигурация на временных адресах (шаг 4.3) выполнена для пяти сервисов.**
Все прогоны идемпотентны — второй заход даёт `changed=0`. Боевые контейнеры не
затронуты: проверено после прогонов, на каждом боевом хосте активен ровно свой
сервис.
| Сервис | Итог | Проверка |
|---|---|---|
| docker-test 152 | OK | Docker active, storage-driver `fuse-overlayfs`, `hello-world` проходит, версия Docker совпала с боевой |
| gitea 153 | OK | HTTP 200, порт 2222 слушает, volume — отдельная ФС `/dev/loop10` ext4 32 ГиБ |
| vaultwarden 154 | OK | HTTP 200, контейнер healthy, база свежая 278 КБ без `icon_cache` |
| monitoring 155 | OK | HTTP 302→200, `kuma.db` создан на месте, `/opt/monitoring` отсутствует |
| grimmory 157 | OK после починки | оба юнита active, health 200, Flyway 144 — как на боевом |
| gyro 156 | OK | конфигурация и cutover завершены 2026-09-02 |
Digest'ы образов на всех пяти совпали с боевыми побайтово.
**Баг в `pve-grimmory.yml`, найденный при этом.** Боевой адрес был зашит в
конфигурационном play трижды: в `ports` compose, в правилах `GRIMMORY-FILTER`
и в URL health-check. Первое роняло `grimmory.service` на любом другом адресе.
Третье опаснее: задача выполняется на целевом хосте, поэтому после починки
биндинга она уходила бы в БОЕВОЙ grimmory, получала 200 и давала
ложно-положительный результат. Исправлено через
`grimmory_bind_ip: "{{ expected_lan_ip }}"`; на боевом хосте рендер не
изменился, `--check --diff` даёт `changed=0`.
**Следствие для cutover.** Grimmory — единственный из шести, кто биндится на
конкретный адрес. После смены адреса его плейбук обязан быть прогнан заново,
иначе compose останется с временным адресом. Добавлено в шаг 4.5.16.
**Ещё одно ошибочное утверждение снято.** План предупреждал, что `roles/gyro`
пинит SSH host key gitea и после переезда сломается. Проверено дословно: там
`state: absent` — задача УДАЛЯЕТ устаревшую запись, а пинится `github.com`,
и `gyro_repo_url` ведёт на GitHub. Переезд gitea на gyro не влияет.
Legacy `pve-gyro.yml` теперь по умолчанию завершает все три plays, когда registry
говорит `provisioner: tofu`; для intentional legacy rollback/recovery нужен
явный override `-e pve_gyro_legacy_provisioning_enabled=true`. Это не normal
deployment.
**Cutover выполнен 2026-09-02 для пяти сервисов.** Все прошли по процедуре
раздела 4: свежий бэкап, перенос данных со сверкой, остановка старого
контейнера, смена адреса, правка реестра, перегенерация заданий бэкапа.
| Сервис | OLD → NEW | Сверка данных |
|---|---|---|
| docker-test | 145 → 152 | данных нет; Docker на fuse-overlayfs, hello-world проходит |
| vaultwarden | 140 → 154 | 1 users, 314 ciphers; integrity_check ok |
| monitoring | 146 → 155 | kuma.db совпал по sha256 и размеру; редирект на /dashboard |
| gitea | 141 → 153 | 64 repos, 5 users, 203 actions; integrity_check ok |
| grimmory | 149 → 157 | book=31 author=30 book_file=40 category=87 reading_sessions=68; Flyway 142/144 без новых миграций |
Публичные домены снаружи: `pass.ada-dev.ru` 200, `git.ada-dev.ru` 200,
`books.ada-dev.ru` health 200 (OPDS отдаёт штатный `401 Basic realm="Booklore
OPDS"` — это требование авторизации, а не поломка; в базе 2 учётки OPDS).
`make validate`, `make lint` зелёные, `make backup-jobs` перегенерировал списки
VMID сам из реестра.
**Смена адреса выполняется провайдером на месте.** Проверено на всех пяти:
`0 added, 1 changed, 0 destroyed`. Это снимало главный риск — пересоздание
контейнера с данными.
Старые контейнеры остановлены и целы: 140, 141, 145, 146, 149. Не удалять
минимум неделю, до 2026-09-09.
### Находки cutover, которых не было в плане
1. **Данные gitea никогда не попадали в PBS.** `vzdump` печатает
`excluding bind mount point mp0 ('/opt/gitea/data') from backup (not a
volume)` — снапшоты `ct/141` содержали только rootfs. Защищал данные лишь
offsite-профиль restic. Переезд на volume это чинит: теперь данные в бэкапе.
Побочно это самый весомый аргумент за миграцию, которого в плане не было.
2. **`vzdump` падает, пока работает Docker с fuse-overlayfs на rootfs в
каталоговом хранилище.** rootfs на `data` снапшоты не поддерживает, vzdump
уходит в режим `suspend` с rsync и спотыкается о
`var/lib/docker/fuse-overlayfs/.../merged`: Permission denied. У vaultwarden
этого нет — его rootfs на `local-lvm`, там настоящий снапшот. Лечится
остановкой сервиса перед бэкапом.
3. **Offsite-профиль restic надо переустанавливать на новом контейнере**, если
он выполняется внутри LXC. Сделано для vaultwarden и grimmory, проверено
прогоном юнита. Профиль gitea переписан с ноды внутрь контейнера
(`playbooks/offsite-restic-yadisk.yml`), старый таймер на cloud-pc отключён.
4. **`lost+found` на новом volume ломает restic.** Свежая ext4 создаёт этот
каталог с владельцем uid 0 хоста, внутри unprivileged LXC он
`nobody:nogroup` и нечитаем; restic отдаёт exit 3, юнит падает каждую ночь,
хотя снапшот сохраняется. Добавлен в исключения профиля gitea.
5. **SSH host-ключи gitea перенеслись вместе с данными.** Ключ на порту 2222
совпал с боевым (`AAAAC3NzaC1lZDI1NTE5AAAAIDE+iqCj`), поэтому у git-клиентов
`known_hosts` остался валидным. Обновлять пришлось только ключ sshd
контейнера на порту 22.
6. **Зависший ControlMaster даёт таймаут на баннере после cutover.** Мастер
остаётся живым, но его туннель ведёт в остановленный контейнер, и новые
сессии мультиплексируются поверх мёртвого соединения. Лечится
`ssh -O exit <host>` или разовым `-o ControlPath=none`.
7. **`systemctl start grimmory` — no-op.** Юнит `Type=oneshot` с
`RemainAfterExit=yes` уже числится активным, нужен `restart`.
8. **`MYSQL_ROOT_PASSWORD` из `.env` не подошёл к MariaDB** (`Access denied for
user 'root'@'localhost'`). Restore выполнен пользователем `grimmory`, у него
`ALL PRIVILEGES` на свою базу — этого достаточно для DROP/CREATE DATABASE.
### Gyro cutover verified
`gyro` завершён как cutover on 2026-09-02: CT 156 живёт на неизменном
production IP `192.168.1.35` и provisioned by Tofu. CT 150 остановлен и
удерживается как rollback минимум на неделю; его PBS snapshot от
`2026-09-02T19:40:56Z` сохранён, цепочку PBS не удаляли. Файл фаервола
`156.fw` уже копия `150.fw`, Datacenter firewall по-прежнему disabled. Timer
`gyro.timer` active, last oneshot success. Данных для переноса не было, и
TMPIP-specific bind/re-run не потребовались.
Удаление старого CT или его backup chain не выполнялось.
### Пакетный переезд сервисов 8-10: adguard, mihomo, ovpn-mini
Выполнено 2026-09-03 автономно, одной сессией. Этим OpenTofu-переезд всех
мигрируемых LXC завершён (осталось: `hermes-ai` заморожен, `pbs` не мигрируется).
| Сервис | OLD → NEW | Узел | Боевой IP (не менялся) | Свежий PBS перед стартом |
|---|---|---|---|---|
| adguard | 144 → **158** | mini-pc | 192.168.1.28 | `ct/144/2026-09-02T21:06:12Z` |
| mihomo | 143 → **159** | mini-pc | 192.168.1.27 | `ct/143/2026-09-02T21:12:49Z` |
| ovpn-mini | 132 → **160** | mini-pc | 192.168.1.23 | `ct/132/2026-09-02T21:17:55Z` |
OLD 144/143/132 остановлены и удерживаются как откат минимум неделю
(до ~2026-09-10). PBS-цепочки не трогали; у NEW 158/159/160 сняты первые
свои снапшоты (`…T21:37-39Z`), чтобы `backup-audit` был чист сразу.
`make validate`, `make lint`, `make tofu-plan` (`No changes`), `make openvpn-check`
(7/7) — зелёные. Внешние домены `git/pass/books.ada-dev.ru` — 200.
**Смена адреса — провайдером на месте** (`0 added, 1 changed, 0 destroyed`),
пересоздания контейнера с данными нет — как и на сервисах 2-7.
Специфика и находки перенесены в
[`migration-tofu.md`](migration-tofu.md) раздел 5 (пункты 8-10). Кратко:
- **adguard:** свежий AdGuard на пустом `conf/` не проходит health-гейт
плейбука (setup-wizard, порт 80 не слушает), поэтому данные заливаются ДО
config play, а не после. DNS-провал ~2 мин, ночью; ноды на DNS роутера,
контейнеры на 1.1.1.1.
- **mihomo:** первый боевой `device_passthrough` для `/dev/net/tun`
(`dev0: path=/dev/net/tun,mode=0660`). `ru-vps-mihomo-harden.yml` повторно
прогонять не нужно. Все зависимые ссылки на .27 остаются валидны.
- **ovpn-mini — петля транспорта.** `-F ansible/ssh_config` и `make tofu-*`
ходят к PVE через ru-vps → туннель, который терминирует сам ovpn-mini.
`pct stop 132` рвёт этот путь и `make tofu-apply` не достаёт API. Разрыв:
поднять шлюз на НОВОМ контейнере ещё на TMPIP (одноразовый плейбук), туннель
встаёт → tofu-apply работает → сменить адрес → повторный `openvpn-vps-mini.yml`
(идемпотентен). Прямой путь к нодам во время провала —
`ssh -o ProxyJump=none <user>@192.168.1.{5,10}` из LAN.
Совокупный провал туннеля (внешний reverse-proxy) ~10 мин.
- **`ssh_config`:** у `ovpn-mini` выставлен `ProxyJump none` (было — через
ru-vps). Путь через ru-vps закольцовывался бы на его же туннель.
- Три creation-play (`pve-adguard.yml`, `pve-mihomo.yml`, `pve-ovpn-mini.yml`)
за гейтом `provisioner != 'tofu'` (`meta: end_play`, override
`-e pve_<svc>_legacy_provisioning_enabled=true`) — как у `pve-gyro.yml`.
Их handlers иначе сделали бы `pct start <OLD>` на боевом адресе.
- **Ошибка сессии:** диагностический `pct start 132` при живом CT 160 дал
конфликт IP .23 и провал туннеля на 1-2 мин (восстановилось само). OLD
контейнеры трогать нельзя, их `pct config` держит боевой адрес.
- Косметика: `polkit.service` на CT 160 в `failed` (`status=217/USER`,
минимальный LXC-шаблон). На OpenVPN не влияет.
**НЕ сделано (осознанно):** уборка раздела 6 `migration-tofu.md` — удаление
`roles/pve_lxc` (ещё используется creation-play пяти сервисов батча 2026-09-02),
стрип creation plays, правки `architecture.md`/`legacy-warning.md`, расширение
`validate.yml`. Это отдельная задача; переездом сервисов 8-10 разблокирована
частично (все 10 на Tofu), но исполнение — не сейчас.
Obsidian-заметки (`HomeLab.md`, `Notes/Текущее состояние…`, `Log.md`) по этому
переезду ещё не обновлены.
## Исторические планы
- [`../../current-task.md`](../../current-task.md) - исторический план Prometheus
monitoring. Он не описывает текущую активную архитектуру: Uptime Kuma активен,
Prometheus stack заморожен.
- [`../../tasks/grimmory-deployment-plan.md`](../../tasks/grimmory-deployment-plan.md)
- план и запись развертывания Grimmory; фактическое состояние проверять по inventory,
service registry и playbooks.
Выявленные риски и deferred work записаны в [`edge-cases.md`](edge-cases.md) и
[`legacy-warning.md`](legacy-warning.md), но не считаются утвержденным backlog.
+76
View File
@@ -0,0 +1,76 @@
# Технологический стек
## Control Plane
| Компонент | Роль | Declared / resolved evidence |
|---|---|---|
| Nix | Воспроизводимый dev shell | `flake.nix`, locked nixpkgs revision в `flake.lock` |
| Python | Ansible controller runtime | `pkgs.python3` в `flake.nix`; точная версия зависит от lock |
| ansible-core | Inventory, playbooks, roles | `>=2.19` в `requirements.txt`; locked Nix package на момент аудита 2.21.3 |
| proxmoxer | Proxmox API client | `>=2.3`; locked Nix package на момент аудита 2.3.0 |
| requests | HTTP dependency | `>=2.31`; точная Nix version определяется `flake.lock` |
| ansible.posix | POSIX modules | `>=2.0.0`; installed version не закреплена |
| community.proxmox | Proxmox modules | `>=2.0.0`; installed version не закреплена |
| community.general | Общие modules | `>=10.0.0`; installed version не закреплена |
| ansible-lint | Static validation | Nix package; CI отдельно pin `25.8.2` |
| yamllint | YAML validation | Nix package; CI отдельно pin `1.37.1` |
Nix shell также содержит Git, jq, OpenSSH, curl и GNU Make. Galaxy collections не
устанавливаются автоматически и живут в ignored `ansible/collections/`.
## Managed Runtime
- Proxmox VE/LXC и PBS являются внешними runtime systems; их версии не заданы manifest.
- Debian LXC templates и Docker/systemd используются service playbooks.
- Caddy, OpenVPN и restic устанавливаются/используются на managed hosts; точные
runtime versions репозиторий не фиксирует.
- Active update-managed container images обычно закреплены `tag@sha256:digest` в
`ansible/inventory/group_vars/all/services.yml` и service playbooks.
- Исключения: frozen Prometheus images закреплены только тегами, а `docker-test`
использует smoke image без declared digest.
## Grimmory MCP
| Компонент | Version | Evidence |
|---|---|---|
| Node.js | `>=22` | `tools/grimmory-mcp/package.json` |
| `@modelcontextprotocol/sdk` | `1.30.0` | package manifest и lockfile |
| Zod | `3.25.76` | package manifest и lockfile |
| Grimmory API compatibility | deployed `v3.2.4` | service registry и compatibility code |
Node.js/npm не входят в `flake.nix`; их нужно предоставлять отдельно.
## Setup
```bash
nix develop
ansible-galaxy collection install \
-r ansible/requirements.yml \
-p ansible/collections
make -C ansible help
```
`make -C ansible setup` остается venv fallback, но Nix является предпочтительным
контроллерным окружением.
## Validation
```bash
make -C ansible inventory
make -C ansible docs
make -C ansible lint
nix develop -c sh -c \
'cd ansible && for f in playbooks/*.yml; do ansible-playbook --syntax-check "$f"; done'
npm test --prefix tools/grimmory-mcp
```
`make check`, `make status`, `make backup-audit` и `make openvpn-check` обращаются к
живой инфраструктуре и не являются локальными unit tests. Документационное изменение
не требует их запуска.
## CI
`.gitea/workflows/lint.yml` описывает yamllint, ansible-lint и syntax-check всех
playbooks. Комментарии в самом workflow фиксируют, что Gitea Actions отключены и
runner не зарегистрирован; автоматического gate сейчас нет. MCP tests в workflow
не включены.