Files
infra/docs/ai/migration-tofu.md
T
DmitryandClaude Sonnet 5 d2e1e6876a 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
2026-09-03 07:06:10 +03:00

789 lines
59 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Переход 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 (неделя ожидания) не
сокращается.