Compare commits

..
14 Commits
Author SHA1 Message Date
DmitryandClaude Sonnet 5 05d8c748ab feat: infrastructure dashboard (Homepage) generated from the service registry
lint / yamllint + ansible-lint + syntax-check (push) Canceled after 0s
playbooks/dashboard.yml deploys Homepage as a second compose stack on the
monitoring LXC (CT 155) next to Uptime Kuma and renders its config from
homelab_services: one tile per service, link to its UI, grouped by Proxmox
node. Adding a service to the registry is enough — no second service list.

- new registry consumer: playbooks/dashboard.yml + playbooks/templates/homepage-*.j2
- homelab_dashboard_* vars in group_vars/all/services.yml (top-level, like
  homelab_reverse_proxy_*); image pinned by digest, floating tag needs an
  explicit -e dashboard_allow_floating_tag=true
- bootstrap-dashboard-pve-token.yml: read-only homepage@pve!dashboard token
  (PVEAuditor) for the Proxmox widget, secret in the root .env as DASHBOARD_PVE_*
- Makefile: dashboard, dry-dashboard, bootstrap-dashboard-token
- container binds the LAN address only (192.168.1.30:8082), not published via Caddy
- docs: architecture.md Monitoring section, plan.md active task, consumer lists

Deployed to CT 155 on 2026-09-03: container healthy, http://192.168.1.30:8082/
returns 200, `make dashboard` idempotent, `make validate` and `make lint` green.
Pending operator steps: `make bootstrap-dashboard-token` (blocked in the agent
session as credential creation) and an Uptime Kuma status page with slug homelab.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KbuZrUoevfBgCpf5DCF4DG
2026-09-03 09:17:16 +03:00
DmitryandClaude Sonnet 5 ca48ef2696 fix: gate the remaining migrated creation plays behind provisioner != tofu
pve-emergency-bot.yml, pve-monitoring.yml and pve-docker-test.yml still ran
their creation plays unconditionally. Invoking make deploy-<svc> would pct
start / pct reboot the stopped OLD VMID (148/146/145) on its production IP,
colliding with the live tofu-managed container.

Add the same pre_tasks `meta: end_play` guard the other seven migrated services
already carry: skip while homelab_services['<svc>'].provisioner == 'tofu',
override with -e pve_<svc>_legacy_provisioning_enabled=true for an intentional
legacy rollback. Verified with --check: both plays in each file end immediately,
no pct calls.

Config for these three lives elsewhere (emergency-access.yml, uptime-kuma.yml,
pve-docker-test.yml play 2), so gating the creation plays makes the files
full no-ops under tofu, like pve-gyro.yml.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uoq5AVK8mkBgg83Mq6o5V
2026-09-03 07:18:53 +03:00
DmitryandClaude Sonnet 5 a23e944756 docs(plan): record onboot fix on OLD containers and the commit layout
- Note that onboot: 0 was set on all 11 stopped OLD/decommissioned containers
  (132/140-150 range) so a node reboot cannot start them into an IP conflict
  with the live NEW containers. Reversible; superseded by pct destroy in a week.
- Note the working tree was split into 11 topical commits (22394cb..d2e1e68).
- Restate that the section 6 cleanup (drop roles/pve_lxc, strip creation plays,
  extend validate.yml, refresh architecture.md/legacy-warning.md) is a separate
  task, now unblocked.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uoq5AVK8mkBgg83Mq6o5V
2026-09-03 07:15:13 +03:00
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
DmitryandClaude Sonnet 5 5e27ba2513 fix: update playbooks read the backup VMID from the registry; drop client-side prune
The *-update.yml playbooks hard-coded the pre-migration VMIDs, so after cutover
the "safety backup" ran against the stopped OLD container. Now the VMID comes
from homelab_services['<svc>'].vmid.

Also removes `--prune-backups keep-all=1` from the vzdump calls: retention is
PBS's job (prune-pbs), and the client-side flag needed Datastore.Modify/Prune
the ansible@pve token does not have, which made vzdump print "Backup ... failed"
and exit non-zero after a successful upload.

gitea-update.yml additionally splits the offsite backup (hosts: gitea) from its
audit (hosts: cloud-pc), matching the profile move into the container.
pve-*.yml imports pass pve_provisioning_enabled: false so the runtime update
path never re-enters container creation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uoq5AVK8mkBgg83Mq6o5V
2026-09-03 07:05:56 +03:00
DmitryandClaude Sonnet 5 a3fe8031fe feat: migrate all managed LXC provisioning to OpenTofu (blue-green)
Blue-green: a new container is created beside the old one, data is copied, the
IP is moved onto it, and the old container is kept stopped as rollback for at
least a week. Keeping the IP means only the VMID changes, and its consumers
(backup jobs, backup audit) already derive it from the registry.

Batch 1 (2026-09-02): emergency-bot 148->151, docker-test 145->152,
gitea 141->153, vaultwarden 140->154, monitoring 146->155, gyro 150->156,
grimmory 149->157.
Batch 2 (2026-09-03): adguard 144->158, mihomo 143->159, ovpn-mini 132->160.
All migratable LXC are now provisioner: tofu. hermes-ai (frozen) and pbs stay.

- tofu/services.tf + tofu/svc-*.tf: one resource per service, reproducing the
  pct-config etalon. /dev/fuse -> features.fuse; /dev/net/tun ->
  device_passthrough (first live use on mihomo and ovpn-mini); gitea bind mount
  -> datastore volume (data finally reaches PBS); console { type = "shell" }
  declared explicitly (provider tracks cmode there).
- services.yml: vmid + provisioner: tofu for every migrated service; features
  strings and device notes updated to the tofu representation; also drops the
  memoir-bot entry and adds homelab_reverse_proxy_image/_unit.
- pve-*.yml: configuration play target is `{{ pve_config_target | default(...) }}`
  so it can run against <name>-new on a temp address (a bare --limit zeroes the
  play instead of retargeting it). Container-creation plays are gated behind
  `provisioner != 'tofu'` / `pve_provisioning_enabled` (meta: end_play), so a
  stray run cannot pct start a stopped OLD VMID on a live IP. Override for
  intentional legacy rollback: -e pve_<svc>_legacy_provisioning_enabled=true.
- ssh_config: drop memoir-bot; ovpn-mini gets ProxyJump none (a jump via ru-vps
  would route through the very tunnel ovpn-mini terminates).
- gyro.yml / uptime-kuma.yml: same pve_config_target override.
- roles/uptime_kuma: only freeze homelab-monitoring when the unit actually
  exists (a fresh blue-green container never had it).
- offsite-restic-yadisk.yml: the gitea restic profile now runs inside the LXC
  (hosts: gitea), since the bind-mount host path is gone after the volume move;
  lost+found excluded (unreadable in an unprivileged LXC, restic exit 3).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uoq5AVK8mkBgg83Mq6o5V
2026-09-03 07:05:46 +03:00
DmitryandClaude Sonnet 5 e349f19e68 feat: remove storage-level PBS prune, keep retention in PBS
playbooks/pve-storage-pbs.yml: drop the prune-backups policy from the PVE
storage entry `pbs` so retention authority lives only in the PBS prune job
(prune-pbs). The weekly local PBS-container backup on storage `backup`
(keep-last=2) is an intentional exception and left alone.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uoq5AVK8mkBgg83Mq6o5V
2026-09-03 07:05:17 +03:00
DmitryandClaude Sonnet 5 79878e36f9 feat: adopt the ru-vps Caddy stack, fix cluster quorum, decommission ZeroTier
One ru-vps housekeeping sweep (2026-09-02/03):

- playbooks/ru-vps-base.yml (new): adopt the Caddy compose stack into Ansible
  (pinned image by digest, homelab-caddy.service), and manage the corosync-qnetd
  UFW rule - allow 5403/tcp from homelab_pve_egress_ip, drop the stale rule for
  the retired ZeroTier 10.122.62.0/24. The qdevice had gone silent because its
  only allowed path was the decommissioned ZeroTier network.
- group_vars/all/main.yml: homelab_pve_egress_ip (the NATed home egress the PVE
  nodes reach corosync-qnetd from - a direct path that does not depend on the
  OpenVPN tunnel). Marked dynamic: a change silently re-breaks the qdevice.
- playbooks/status.yml: CLUSTER QUORUM section (pvecm status per PVE node) so a
  repeat failure is visible. Also drops the memoir-bot unit list and moves the
  gitea offsite-restic unit to the gitea host (see the OpenTofu-migration commit).
- playbooks/ru-vps-zerotier-decommission.yml (new): stop the zerotier container,
  disable ssh-zt22.service, remove the interface/9993/9001/10.122.62.0/24 UFW
  rules. Node identity and data are kept; removal is a separate step.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uoq5AVK8mkBgg83Mq6o5V
2026-09-03 07:05:11 +03:00
DmitryandClaude Sonnet 5 e24b75534e feat: decommission memoir-bot (CT 142)
Service was unused. Live steps (revoke SecondBrain deploy key, drop the Uptime
Kuma monitor, pct stop 142) were done 2026-09-02; pct destroy is deliberately
deferred a week.

- delete playbooks/pve-memoir-bot.yml.
- hosts.yml: drop the memoir-bot host and its monitoring_exporters entry.
- roles/monitoring_server/templates/prometheus.yml.j2: drop 192.168.1.26 target.
- roles/uptime_kuma/defaults: drop 192.168.1.26 from no_proxy.
- roles/lxc_docker_host/defaults: drop the memoir-bot extra-packages comment.

The registry entry, the ssh_config Host block and the status.yml unit list are
removed in the commits that also carry OpenTofu-migration / quorum changes to
those files.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uoq5AVK8mkBgg83Mq6o5V
2026-09-03 07:04:45 +03:00
DmitryandClaude Sonnet 5 baef2c49b3 feat: derive PBS backup jobs and audit VMIDs from the service registry
- pve-backup-jobs.yml: each job's vmid list is now computed from
  homelab_services by backup.job instead of a hand-maintained CSV. Adding a
  service no longer needs a separate edit here (the forgotten-edit failure
  mode that left CT 148 emergency-bot without a backup).
- roles/backup_audit/defaults: backup_audit_pbs_vmids derived from
  homelab_services by the monitoring.backup_audit_vmid flag; single shared
  freshness threshold backup_audit_pbs_max_age_hours (48).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uoq5AVK8mkBgg83Mq6o5V
2026-09-03 07:04:36 +03:00
DmitryandClaude Sonnet 5 7031d7dbb3 feat: read-only service-registry to Proxmox drift gate
playbooks/validate.yml (make validate): reads pct config for every service in
homelab_services and fails if hostname, IP, cores, memory or swap disagree
with the registry. Read-only; meant as a pre/post gate around any inventory or
provisioning change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uoq5AVK8mkBgg83Mq6o5V
2026-09-03 07:04:29 +03:00
DmitryandClaude Sonnet 5 646f2bbc8f feat(tofu): OpenTofu provisioning scaffold, auth modes and pilot notes
- providers.tf / variables.tf / versions.tf: bpg/proxmox ~> 0.84, endpoint and
  credentials from TF_VAR_* (set by the Makefile tofu-* targets from the
  repo-root .env). Two auth modes: root@pam by password (privileged: features
  beyond nesting, device passthrough, datastore mount points) or the
  ansible@pve token.
- README.md: pilot results on VMID 199 - what the token can and cannot do,
  why a root token still fails the literal `$authuser eq 'root@pam'` check,
  the cmode/console drift finding, and the chosen root@pam-by-password mode.
- pilot.tf.example: reference resource shape (features, device_passthrough,
  mount_point), not loaded (.example).
- .terraform.lock.hcl: pin the provider.

State has no backend yet; tofu/*.tfstate stays local and git-ignored.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uoq5AVK8mkBgg83Mq6o5V
2026-09-03 07:04:23 +03:00
DmitryandClaude Sonnet 5 e0c53a1b1b chore: relocate .env to the repository root and refresh Make targets
.env is now consumed by both Ansible and OpenTofu, so keep a single copy at
the repo root instead of ansible/.env:

- rename ansible/.env.example -> .env.example.
- Makefile: ENV_FILE ?= $(REPO_ROOT)/.env (absolute, works from any cwd);
  REQUIRE_ENV/LOAD_ENV updated; help text.
- bootstrap-pve-api-token.yml / bootstrap-monitoring-pve-token.yml write and
  read ../../.env; bootstrap now keeps backup: true (it rewrites the whole
  file, clobbering MONITORING_*/EMERGENCY_*/PROXMOX_ROOT_PASSWORD).
- roles/pve_lxc, roles/monitoring_server: fail_msg points at the repo-root .env.

The Makefile also picks up the new targets added by later commits
(tofu-*, validate, pbs-storage, ru-vps-base, zerotier-decommission); they are
grouped here so all recipes land together.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uoq5AVK8mkBgg83Mq6o5V
2026-09-03 07:04:12 +03:00
DmitryandClaude Sonnet 5 22394cbaba build: add OpenTofu to the dev shell and ignore its state
- flake.nix: add pkgs.opentofu to the devshell (LXC provisioning pilot).
- .gitignore: ignore tofu/.terraform/, *.tfstate*, *.tfplan and tofu/.env.
  The provider lock file (tofu/.terraform.lock.hcl) stays tracked on purpose:
  it pins the provider version.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uoq5AVK8mkBgg83Mq6o5V
2026-09-03 07:03:52 +03:00
79 changed files with 5159 additions and 875 deletions
+9
View File
@@ -26,3 +26,12 @@ result-*
# Ansible local runtime dir (facts cache, locks) # Ansible local runtime dir (facts cache, locks)
.ansible/ .ansible/
# OpenTofu: state содержит фактическую конфигурацию гостей и потому не
# коммитится. Лок-файл провайдера (.terraform.lock.hcl) наоборот нужен в Git —
# он закрепляет версию, поэтому под исключение не попадает.
tofu/.terraform/
tofu/*.tfstate
tofu/*.tfstate.*
tofu/*.tfplan
tofu/.env
+118 -324
View File
@@ -1,361 +1,155 @@
# 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;
- `playbooks/dashboard.yml``services.yaml` для Homepage-обзора инфраструктуры.
Окружение собрано в 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`.
+124 -11
View File
@@ -24,6 +24,7 @@ SHELL := /bin/sh
MAKEFILE_PATH := $(abspath $(lastword $(MAKEFILE_LIST))) MAKEFILE_PATH := $(abspath $(lastword $(MAKEFILE_LIST)))
ANSIBLE_DIR := $(patsubst %/,%,$(dir $(MAKEFILE_PATH))) ANSIBLE_DIR := $(patsubst %/,%,$(dir $(MAKEFILE_PATH)))
REPO_ROOT := $(abspath $(ANSIBLE_DIR)/..)
VENV_BIN := $(ANSIBLE_DIR)/.venv/bin VENV_BIN := $(ANSIBLE_DIR)/.venv/bin
# Разрешение бинарей: сначала рабочий локальный venv, иначе PATH (nix devshell, # Разрешение бинарей: сначала рабочий локальный venv, иначе PATH (nix devshell,
@@ -42,7 +43,11 @@ YAMLLINT ?= $(call venv_bin,yamllint)
# python3 из PATH, а не из .venv. # python3 из PATH, а не из .venv.
PYTHON ?= python3 PYTHON ?= python3
ENV_FILE ?= .env # Секреты живут в КОРНЕ репозитория, а не в ansible/: их потребляет не только
# Ansible, но и OpenTofu (tofu/), а держать два файла или ходить в подкаталог
# из соседнего инструмента неудобно. Путь абсолютный, поэтому цели работают
# из любого cwd.
ENV_FILE ?= $(REPO_ROOT)/.env
# Дополнительные аргументы ansible-playbook для любой цели. # Дополнительные аргументы ansible-playbook для любой цели.
EXTRA ?= EXTRA ?=
@@ -51,19 +56,19 @@ ASK_BECOME ?= -K
# Спрашивать пароль Ansible Vault. `make gyro ASK_VAULT=` отключает. # Спрашивать пароль Ansible Vault. `make gyro ASK_VAULT=` отключает.
ASK_VAULT ?= --ask-vault-pass ASK_VAULT ?= --ask-vault-pass
# Строгая загрузка ansible/.env: обязательна для Proxmox API и секретов. # Строгая загрузка корневого .env: обязательна для Proxmox API и секретов.
# Каждая строка рецепта make — отдельный шелл, поэтому source и запуск идут одной строкой. # Каждая строка рецепта make — отдельный шелл, поэтому source и запуск идут одной строкой.
REQUIRE_ENV = if [ ! -f '$(ENV_FILE)' ]; then \ REQUIRE_ENV = if [ ! -f '$(ENV_FILE)' ]; then \
printf 'ОШИБКА: не найден %s/%s\n' '$(ANSIBLE_DIR)' '$(ENV_FILE)' >&2; \ printf 'ОШИБКА: не найден %s\n' '$(ENV_FILE)' >&2; \
printf 'Создай его и заполни реальными значениями:\n' >&2; \ printf 'Создай его и заполни реальными значениями:\n' >&2; \
printf ' cp .env.example .env\n' >&2; \ printf ' cp %s/.env.example %s\n' '$(REPO_ROOT)' '$(ENV_FILE)' >&2; \
printf 'Нужны PROXMOX_* (и MONITORING_* / EMERGENCY_* для профильных целей).\n' >&2; \ printf 'Нужны PROXMOX_* (и MONITORING_* / EMERGENCY_* для профильных целей).\n' >&2; \
exit 1; \ exit 1; \
fi; \ fi; \
set -a; . './$(ENV_FILE)'; set +a set -a; . '$(ENV_FILE)'; set +a
# Мягкая загрузка: .env подхватывается если есть, иначе просто предупреждение. # Мягкая загрузка: .env подхватывается если есть, иначе просто предупреждение.
LOAD_ENV = if [ -f '$(ENV_FILE)' ]; then set -a; . './$(ENV_FILE)'; set +a; \ LOAD_ENV = if [ -f '$(ENV_FILE)' ]; then set -a; . '$(ENV_FILE)'; set +a; \
else printf 'ВНИМАНИЕ: %s не найден, продолжаю без него.\n' '$(ENV_FILE)' >&2; fi else printf 'ВНИМАНИЕ: %s не найден, продолжаю без него.\n' '$(ENV_FILE)' >&2; fi
# Защита от случайного запуска опасных плейбуков. # Защита от случайного запуска опасных плейбуков.
@@ -85,7 +90,7 @@ help: ## Показать этот список целей
print "HomeLab Ansible — ручное управление инфраструктурой"; \ print "HomeLab Ansible — ручное управление инфраструктурой"; \
print ""; \ print ""; \
print " Использование: make <цель> [EXTRA=\"--limit host -vv\"]"; \ print " Использование: make <цель> [EXTRA=\"--limit host -vv\"]"; \
print " Плейбуки Proxmox API сами подхватывают ./.env"; \ print " Секреты берутся из .env в КОРНЕ репозитория"; \
} \ } \
/^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5); next } \ /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5); next } \
/^[a-zA-Z0-9_%.-]+:.*##/ { printf " \033[36m%-24s\033[0m %s\n", $$1, $$2 } \ /^[a-zA-Z0-9_%.-]+:.*##/ { printf " \033[36m%-24s\033[0m %s\n", $$1, $$2 } \
@@ -126,10 +131,21 @@ check: ## Проверка связности и ожидаемых IP (playbook
status: ## Сводный статус инфраструктуры (playbooks/status.yml) status: ## Сводный статус инфраструктуры (playbooks/status.yml)
ANSIBLE_CALLBACK_RESULT_FORMAT=yaml $(ANSIBLE_PLAYBOOK) playbooks/status.yml $(EXTRA) ANSIBLE_CALLBACK_RESULT_FORMAT=yaml $(ANSIBLE_PLAYBOOK) playbooks/status.yml $(EXTRA)
# Оба линтера ищут конфиг (.ansible-lint / .yamllint) в текущем каталоге, а не
# у родителей. Запуск из ansible/ конфиг не находил, из-за чего в проверку
# затягивался вендоренный collections/ и цель выдавала около тысячи чужих
# нарушений. Запускаем из корня — ровно как .gitea/workflows/lint.yml.
.PHONY: lint .PHONY: lint
lint: ## Прогнать ansible-lint и yamllint по репозиторию lint: ## Прогнать ansible-lint, yamllint и syntax-check — то же, что делает CI
$(ANSIBLE_LINT) cd '$(REPO_ROOT)' && $(ANSIBLE_LINT)
$(YAMLLINT) . cd '$(REPO_ROOT)' && $(YAMLLINT) .
@rc=0; for f in playbooks/*.yml; do \
if $(ANSIBLE_PLAYBOOK) --syntax-check "$$f" >/dev/null 2>&1; then \
printf 'ok %s\n' "$$f"; \
else \
rc=1; printf 'FAIL %s\n' "$$f"; $(ANSIBLE_PLAYBOOK) --syntax-check "$$f" 2>&1 | sed 's/^/ /'; \
fi; \
done; exit $$rc
.PHONY: docs .PHONY: docs
docs: ## Напечатать markdown-таблицу хостов и групп в stdout docs: ## Напечатать markdown-таблицу хостов и групп в stdout
@@ -139,6 +155,10 @@ docs: ## Напечатать markdown-таблицу хостов и групп
inventory: ## Показать дерево инвентаря (ansible-inventory --graph) inventory: ## Показать дерево инвентаря (ansible-inventory --graph)
@$(INVENTORY_ENV) $(ANSIBLE_INVENTORY_BIN) -i inventory/hosts.yml --graph @$(INVENTORY_ENV) $(ANSIBLE_INVENTORY_BIN) -i inventory/hosts.yml --graph
.PHONY: validate
validate: ## Сверить реестр homelab_services с фактическим состоянием Proxmox (gate)
$(ANSIBLE_PLAYBOOK) playbooks/validate.yml $(EXTRA)
.PHONY: openvpn-check .PHONY: openvpn-check
openvpn-check: ## Проверить OpenVPN-транспорт ru-vps <-> ovpn-mini openvpn-check: ## Проверить OpenVPN-транспорт ru-vps <-> ovpn-mini
$(ANSIBLE_PLAYBOOK) playbooks/openvpn-check.yml $(EXTRA) $(ANSIBLE_PLAYBOOK) playbooks/openvpn-check.yml $(EXTRA)
@@ -159,6 +179,10 @@ deploy-%: ## Применить playbooks/pve-<имя>.yml (пример: make d
backup-jobs: ## Настроить PBS backup jobs (playbooks/pve-backup-jobs.yml) backup-jobs: ## Настроить PBS backup jobs (playbooks/pve-backup-jobs.yml)
@$(REQUIRE_ENV); $(ANSIBLE_PLAYBOOK) playbooks/pve-backup-jobs.yml $(EXTRA) @$(REQUIRE_ENV); $(ANSIBLE_PLAYBOOK) playbooks/pve-backup-jobs.yml $(EXTRA)
.PHONY: pbs-storage
pbs-storage: ## Убрать storage-level prune policy с PBS storage (playbooks/pve-storage-pbs.yml)
$(ANSIBLE_PLAYBOOK) playbooks/pve-storage-pbs.yml $(EXTRA)
.PHONY: bootstrap-pve-token .PHONY: bootstrap-pve-token
bootstrap-pve-token: ## Выпустить Proxmox API-токен прямо с ноды (нужен sudo) bootstrap-pve-token: ## Выпустить Proxmox API-токен прямо с ноды (нужен sudo)
$(ANSIBLE_PLAYBOOK) playbooks/bootstrap-pve-api-token.yml $(ASK_BECOME) $(EXTRA) $(ANSIBLE_PLAYBOOK) playbooks/bootstrap-pve-api-token.yml $(ASK_BECOME) $(EXTRA)
@@ -167,12 +191,24 @@ bootstrap-pve-token: ## Выпустить Proxmox API-токен прямо с
bootstrap-monitoring-token: ## Выпустить read-only PVE-токен для мониторинга bootstrap-monitoring-token: ## Выпустить read-only PVE-токен для мониторинга
$(ANSIBLE_PLAYBOOK) playbooks/bootstrap-monitoring-pve-token.yml $(EXTRA) $(ANSIBLE_PLAYBOOK) playbooks/bootstrap-monitoring-pve-token.yml $(EXTRA)
.PHONY: bootstrap-dashboard-token
bootstrap-dashboard-token: ## Выпустить read-only PVE-токен homepage@pve!dashboard для виджета Proxmox
$(ANSIBLE_PLAYBOOK) playbooks/bootstrap-dashboard-pve-token.yml $(EXTRA)
.PHONY: bootstrap-ansible-user .PHONY: bootstrap-ansible-user
bootstrap-ansible-user: ## Завести сервисный аккаунт ansible на shell-хостах (нужен sudo) bootstrap-ansible-user: ## Завести сервисный аккаунт ansible на shell-хостах (нужен sudo)
$(ANSIBLE_PLAYBOOK) playbooks/bootstrap-ansible-user.yml $(ASK_BECOME) $(EXTRA) $(ANSIBLE_PLAYBOOK) playbooks/bootstrap-ansible-user.yml $(ASK_BECOME) $(EXTRA)
##@ Настройка сервисов ##@ Настройка сервисов
.PHONY: ru-vps-base
ru-vps-base: ## Базовое состояние ru-vps и стек Caddy (закрепляет образ, ставит homelab-caddy.service)
$(ANSIBLE_PLAYBOOK) playbooks/ru-vps-base.yml $(EXTRA)
.PHONY: dry-ru-vps-base
dry-ru-vps-base: ## Предпросмотр ru-vps-base.yml (--check --diff)
$(ANSIBLE_PLAYBOOK) playbooks/ru-vps-base.yml --check --diff $(EXTRA)
.PHONY: reverse-proxy .PHONY: reverse-proxy
reverse-proxy: ## Единый reverse-proxy для gitea/vaultwarden/grimmory reverse-proxy: ## Единый reverse-proxy для gitea/vaultwarden/grimmory
@$(LOAD_ENV); $(ANSIBLE_PLAYBOOK) playbooks/reverse-proxy.yml $(EXTRA) @$(LOAD_ENV); $(ANSIBLE_PLAYBOOK) playbooks/reverse-proxy.yml $(EXTRA)
@@ -198,13 +234,21 @@ emergency-access: ## Настроить emergency reverse-SSH и Telegram-бот
@$(REQUIRE_ENV); $(ANSIBLE_PLAYBOOK) playbooks/emergency-access.yml $(EXTRA) @$(REQUIRE_ENV); $(ANSIBLE_PLAYBOOK) playbooks/emergency-access.yml $(EXTRA)
.PHONY: gyro .PHONY: gyro
gyro: ## Настроить gyro-аллокатор в CT 150 (спрашивает пароль Vault) gyro: ## Настроить gyro-аллокатор в production CT 156 (Tofu-managed); CT 150 — остановленный rollback (спрашивает пароль Vault)
@$(REQUIRE_ENV); $(ANSIBLE_PLAYBOOK) playbooks/gyro.yml $(ASK_VAULT) $(EXTRA) @$(REQUIRE_ENV); $(ANSIBLE_PLAYBOOK) playbooks/gyro.yml $(ASK_VAULT) $(EXTRA)
.PHONY: uptime-kuma .PHONY: uptime-kuma
uptime-kuma: ## Развернуть/обновить Uptime Kuma на monitoring LXC uptime-kuma: ## Развернуть/обновить Uptime Kuma на monitoring LXC
$(ANSIBLE_PLAYBOOK) playbooks/uptime-kuma.yml $(EXTRA) $(ANSIBLE_PLAYBOOK) playbooks/uptime-kuma.yml $(EXTRA)
.PHONY: dashboard
dashboard: ## Дашборд-обзор инфраструктуры (Homepage) на monitoring LXC, конфиг из реестра
@$(LOAD_ENV); $(ANSIBLE_PLAYBOOK) playbooks/dashboard.yml $(EXTRA)
.PHONY: dry-dashboard
dry-dashboard: ## Предпросмотр dashboard.yml (--check --diff)
@$(LOAD_ENV); $(ANSIBLE_PLAYBOOK) playbooks/dashboard.yml --check --diff $(EXTRA)
.PHONY: offsite-restic .PHONY: offsite-restic
offsite-restic: ## Настроить offsite restic-бэкапы на Yandex.Disk offsite-restic: ## Настроить offsite restic-бэкапы на Yandex.Disk
@$(LOAD_ENV); $(ANSIBLE_PLAYBOOK) playbooks/offsite-restic-yadisk.yml $(EXTRA) @$(LOAD_ENV); $(ANSIBLE_PLAYBOOK) playbooks/offsite-restic-yadisk.yml $(EXTRA)
@@ -226,6 +270,65 @@ update-all: ## Последовательно обновить gitea, vaultwarde
$(MAKE) update-mihomo $(MAKE) update-mihomo
$(MAKE) update-grimmory $(MAKE) update-grimmory
##@ OpenTofu (пилот provisioning LXC)
TOFU_DIR := $(REPO_ROOT)/tofu
TOFU ?= tofu
# Tofu, в отличие от Ansible, ходит в Proxmox API напрямую по HTTPS и НЕ умеет
# ProxyJump из ssh_config. Вне локальной сети узлы недоступны (проверено:
# https://192.168.1.10:8006 отдаёт connection failure), поэтому на время команды
# поднимаем SSH-туннель через ru-vps и направляем провайдер в localhost.
# Изнутри LAN это тоже работает — просто лишний хоп, зато поведение одинаково.
TOFU_TUNNEL_PORT ?= 18006
TOFU_SSH_SOCKET := $(ANSIBLE_DIR)/.ansible/tofu-tunnel.sock
# insecure=true здесь не настройка вкуса: сертификат Proxmox выписан на узел,
# а обращаемся мы к 127.0.0.1, так что проверка имени не пройдёт в любом случае.
#
# Способ аутентификации выбирается автоматически: если в корневом .env задан
# PROXMOX_ROOT_PASSWORD — идём как root@pam (только так Proxmox разрешает
# features кроме nesting, device passthrough и bind mount каталога хоста),
# иначе токеном ansible@pve. Выбранный режим печатается в stderr, чтобы из
# вывода было видно, какими правами шёл apply.
TOFU_ENV = $(REQUIRE_ENV); \
mkdir -p '$(dir $(TOFU_SSH_SOCKET))'; \
ssh -F '$(ANSIBLE_DIR)/ssh_config' -M -S '$(TOFU_SSH_SOCKET)' -fN \
-L $(TOFU_TUNNEL_PORT):$$PROXMOX_HOST:$${PROXMOX_PORT:-8006} ru-vps; \
trap "ssh -S '$(TOFU_SSH_SOCKET)' -O exit ru-vps 2>/dev/null || true" EXIT; \
export TF_VAR_pve_endpoint="https://127.0.0.1:$(TOFU_TUNNEL_PORT)"; \
export TF_VAR_pve_insecure=true; \
if [ -n "$${PROXMOX_ROOT_PASSWORD:-}" ] && [ "$${PROXMOX_ROOT_PASSWORD}" != 'replace-me' ]; then \
export TF_VAR_pve_username="$${PROXMOX_ROOT_USER:-root@pam}"; \
export TF_VAR_pve_password="$$PROXMOX_ROOT_PASSWORD"; \
export TF_VAR_pve_api_token=""; \
printf 'Proxmox: аутентификация %s (привилегированный режим)\n' "$${PROXMOX_ROOT_USER:-root@pam}" >&2; \
else \
export TF_VAR_pve_api_token="$$PROXMOX_USER!$$PROXMOX_TOKEN_ID=$$PROXMOX_TOKEN_SECRET"; \
export TF_VAR_pve_username=""; \
export TF_VAR_pve_password=""; \
printf 'Proxmox: аутентификация токеном %s (features кроме nesting и dev-passthrough недоступны)\n' "$$PROXMOX_USER" >&2; \
fi
.PHONY: tofu-init
tofu-init: ## Скачать провайдер и инициализировать рабочий каталог tofu/
@cd '$(TOFU_DIR)' && $(TOFU) init -input=false
.PHONY: tofu-plan
tofu-plan: ## Показать план (read-only, ничего не меняет)
@$(TOFU_ENV); cd '$(TOFU_DIR)' && $(TOFU) plan -input=false
.PHONY: tofu-apply
tofu-apply: ## Применить план — СОЗДАЁТ гостей в Proxmox (требует CONFIRM=1)
@$(REQUIRE_CONFIRM)
@$(TOFU_ENV); cd '$(TOFU_DIR)' && $(TOFU) apply -input=false -auto-approve
.PHONY: tofu-destroy
tofu-destroy: ## УНИЧТОЖИТЬ всё, что создано в tofu/ (требует CONFIRM=1)
@$(REQUIRE_CONFIRM)
@printf 'Удаляет гостей Proxmox из состояния tofu вместе с их дисками.\n' >&2
@$(TOFU_ENV); cd '$(TOFU_DIR)' && $(TOFU) destroy -input=false -auto-approve
##@ Опасное (только осознанно, требует CONFIRM=1) ##@ Опасное (только осознанно, требует CONFIRM=1)
.PHONY: mihomo-harden .PHONY: mihomo-harden
@@ -234,6 +337,16 @@ mihomo-harden: ## РОТИРУЕТ живые SOCKS-креды Mihomo на ru-vp
@printf 'Ротация кредов необратима для клиентов без отката бэкапа конфига.\n' >&2 @printf 'Ротация кредов необратима для клиентов без отката бэкапа конфига.\n' >&2
$(ANSIBLE_PLAYBOOK) playbooks/ru-vps-mihomo-harden.yml -e ru_vps_mihomo_harden_confirm=true $(EXTRA) $(ANSIBLE_PLAYBOOK) playbooks/ru-vps-mihomo-harden.yml -e ru_vps_mihomo_harden_confirm=true $(EXTRA)
.PHONY: zerotier-decommission
zerotier-decommission: ## Вывести ZeroTier с ru-vps и почистить его правила UFW (требует CONFIRM=1)
@$(REQUIRE_CONFIRM)
@printf 'Останавливает контейнер zerotier и удаляет правила UFW. Данные и файлы остаются.\n' >&2
$(ANSIBLE_PLAYBOOK) playbooks/ru-vps-zerotier-decommission.yml $(EXTRA)
.PHONY: dry-zerotier-decommission
dry-zerotier-decommission: ## Предпросмотр вывода ZeroTier (--check --diff)
$(ANSIBLE_PLAYBOOK) playbooks/ru-vps-zerotier-decommission.yml --check --diff $(EXTRA)
.PHONY: monitoring .PHONY: monitoring
monitoring: ## ЗАМОРОЖЕН: стек Prometheus. Запускать только при восстановлении мониторинга monitoring: ## ЗАМОРОЖЕН: стек Prometheus. Запускать только при восстановлении мониторинга
@$(REQUIRE_CONFIRM) @$(REQUIRE_CONFIRM)
+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
+10
View File
@@ -13,6 +13,16 @@ ansible_become: true
homelab_lan_cidr: 192.168.1.0/24 homelab_lan_cidr: 192.168.1.0/24
homelab_service_range: 192.168.1.5-192.168.1.40 homelab_service_range: 192.168.1.5-192.168.1.40
# Публичный адрес, с которого обе ноды PVE выходят в интернет (домашний NAT).
# Нужен для правила UFW на ru-vps, открывающего corosync-qnetd (5403/tcp):
# арбитр кластера обязан быть достижим по пути, не зависящему ни от одной ноды,
# поэтому ходим напрямую, а не через OpenVPN-туннель (он живёт в CT 160 на
# mini-pc — при падении mini-pc арбитр исчез бы вместе с ним).
# ВНИМАНИЕ: адрес динамический. Если он сменится, qdevice замолчит так же тихо,
# как это уже случилось после вывода ZeroTier. Признак — `pvecm status`:
# Total votes меньше Expected votes и флаг NV у узлов.
homelab_pve_egress_ip: 85.143.112.108
openvpn_network_cidr: 10.78.0.0/30 openvpn_network_cidr: 10.78.0.0/30
openvpn_listen_port: 8443 openvpn_listen_port: 8443
openvpn_forwarded_services: openvpn_forwarded_services:
+117 -81
View File
@@ -13,9 +13,13 @@
# ansible/roles/*, ansible/inventory/hosts.yml). Ничего не выдумано. # ansible/roles/*, ansible/inventory/hosts.yml). Ничего не выдумано.
# Там, где факта в коде нет, стоит комментарий "# нет в коде", а не догадка. # Там, где факта в коде нет, стоит комментарий "# нет в коде", а не догадка.
# #
# КТО ЭТО ПОТРЕБЛЯЕТ (на текущем этапе) # КТО ЭТО ПОТРЕБЛЯЕТ
# * ansible/playbooks/reverse-proxy.yml — итерируется по сервисам, # * playbooks/reverse-proxy.yml — Caddyfile по блокам `proxy`.
# у которых задан блок `proxy`, и собирает Caddyfile на ru-vps. # * playbooks/ru-vps-base.yml — стек Caddy и закреплённый образ.
# * playbooks/pve-backup-jobs.yml — списки VMID заданий по полю `backup.job`.
# * roles/backup_audit — VMID по флагу `monitoring.backup_audit_vmid`.
# * playbooks/validate.yml — сверка с фактическим состоянием Proxmox.
# * playbooks/dashboard.yml — services.yaml для Homepage-обзора инфры.
# * Человек — как справочник вместо чтения семи playbook'ов по 200-400 строк. # * Человек — как справочник вместо чтения семи playbook'ов по 200-400 строк.
# #
# Существующие pve-*.yml пока НЕ читают этот реестр: их перевод на # Существующие pve-*.yml пока НЕ читают этот реестр: их перевод на
@@ -57,13 +61,51 @@
homelab_reverse_proxy_host: ru-vps homelab_reverse_proxy_host: ru-vps
homelab_reverse_proxy_dir: /opt/services/ru-vps/caddy homelab_reverse_proxy_dir: /opt/services/ru-vps/caddy
homelab_reverse_proxy_caddyfile: /opt/services/ru-vps/caddy/Caddyfile homelab_reverse_proxy_caddyfile: /opt/services/ru-vps/caddy/Caddyfile
# ВНИМАНИЕ: сам контейнер Caddy на ru-vps ansible'ом НЕ управляется —
# в репозитории нет плейбука его установки. Управляется только Caddyfile.
homelab_reverse_proxy_container: caddy homelab_reverse_proxy_container: caddy
# Стек Caddy разворачивает playbooks/ru-vps-base.yml (роль compose_service).
# Содержимое Caddyfile принадлежит playbooks/reverse-proxy.yml — ru-vps-base
# его не трогает, только проверяет наличие.
# Образ закреплён по digest: до принятия в Ansible на ru-vps крутился
# floating-тег caddy:2-alpine (на момент фиксации — v2.11.4).
homelab_reverse_proxy_image: >-
caddy:2-alpine@sha256:5f5c8640aae01df9654968d946d8f1a56c497f1dd5c5cda4cf95ab7c14d58648
# Systemd-юнит намеренно называется homelab-caddy, а не caddy: на ru-vps уже
# есть отключённый caddy.service от APT-пакета, и одноимённый unit в
# /etc/systemd/system его бы затенил.
homelab_reverse_proxy_unit: homelab-caddy
# Адрес центрального хоста мониторинга (источник scrape для node-exporter). # Адрес центрального хоста мониторинга (источник scrape для node-exporter).
homelab_monitoring_host_ip: 192.168.1.30 homelab_monitoring_host_ip: 192.168.1.30
# --- Дашборд-обзор инфраструктуры (Homepage, gethomepage.dev) ----------------
# Потребитель реестра: playbooks/dashboard.yml рендерит services.yaml из
# homelab_services (плитка на сервис, ссылка на его UI, группировка по узлу).
# Живёт compose-стеком на LXC monitoring рядом с Uptime Kuma; наружу НЕ
# публикуется — доступ только из LAN и по OpenVPN.
homelab_dashboard_host: monitoring
homelab_dashboard_dir: /opt/homepage
homelab_dashboard_bind_ip: 192.168.1.30
# Порт на хосте: 3000 в модели реестра занят замороженной Grafana, 3001 —
# Uptime Kuma, поэтому Homepage слушает 8082 -> контейнерный 3000.
homelab_dashboard_port: 8082
# Образ закреплён по digest (закреплён 2026-09-03 после первого pull на CT 155).
# Обновление: docker pull <новый тег> на monitoring, взять digest
# docker inspect --format '{{ index .RepoDigests 0 }}' <image>
# и вписать сюда. playbooks/dashboard.yml падает на assert'е без @sha256
# (разовый обход — `-e dashboard_allow_floating_tag=true`).
homelab_dashboard_image: >-
ghcr.io/gethomepage/homepage:v2.2.0@sha256:753eeb0cc22ab7baad39ed47cbd1aae14e193dd1b264e965f193a9ea1d1e1bdd
# HOMEPAGE_ALLOWED_HOSTS: Homepage >=1.0 отклоняет запросы с прочих Host-ов.
homelab_dashboard_allowed_hosts: "192.168.1.30:8082"
# Виджет proxmox: URL любого узла кластера (без node: -> среднее по кластеру).
# Секрет — read-only токен homepage@pve!dashboard (роль PVEAuditor), выпускается
# playbooks/bootstrap-dashboard-pve-token.yml в корневой .env как DASHBOARD_PVE_*.
# Пустой URL -> плитка Proxmox не рендерится.
homelab_dashboard_proxmox_url: "https://192.168.1.10:8006"
# slug опубликованной Status Page в Uptime Kuma для виджета сводки up/down
# (мониторы и статус-страницы Kuma живут только в её UI — их тут не завести).
homelab_dashboard_kuma_slug: homelab
homelab_services: homelab_services:
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@@ -99,20 +141,22 @@ homelab_services:
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
ovpn-mini: ovpn-mini:
vmid: 132 vmid: 160
node: mini-pc node: mini-pc
ip: 192.168.1.23 ip: 192.168.1.23
hostname: ovpn-mini hostname: ovpn-mini
role: OpenVPN-шлюз в LAN (клиент ru-vps) role: OpenVPN-шлюз в LAN (клиент ru-vps)
provisioner: pve_lxc # playbooks/pve-ovpn-mini.yml provisioner: tofu # tofu/svc-ovpn-mini.tf, переезд 2026-09-03 (migration-tofu.md)
lxc: lxc:
cores: 1 # из roles/pve_lxc/defaults cores: 1
memory: 256 # из roles/pve_lxc/defaults memory: 256
swap: 128 # из roles/pve_lxc/defaults swap: 128
disk: local-lvm:8 disk: local-lvm:8
startup: order=30 # из roles/pve_lxc/defaults startup: order=30
features: "nesting=1" features: "nesting=1" # keyctl/fuse намеренно не заданы
unprivileged: true unprivileged: true
# /dev/net/tun — блок device_passthrough в Tofu (dev0: path=/dev/net/tun)
# вместо прежних сырых lxc.* строк из pve-ovpn-mini.yml. Эффект тот же.
devices: ["/dev/net/tun"] devices: ["/dev/net/tun"]
ports: ports:
- {name: openvpn, port: 8443, proto: tcp, note: "туннель к ru-vps 10.78.0.1"} - {name: openvpn, port: 8443, proto: tcp, note: "туннель к ru-vps 10.78.0.1"}
@@ -129,19 +173,20 @@ homelab_services:
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
vaultwarden: vaultwarden:
vmid: 140 vmid: 154
node: mini-pc node: mini-pc
ip: 192.168.1.24 ip: 192.168.1.24
hostname: vaultwarden hostname: vaultwarden
role: Менеджер паролей Vaultwarden role: Менеджер паролей Vaultwarden
provisioner: pct_ssh # playbooks/pve-vaultwarden.yml provisioner: tofu # tofu/svc-vaultwarden.tf, переезд 2026-09-02 (migration-tofu.md)
lxc: lxc:
cores: 2 cores: 2
memory: 1024 memory: 1024
swap: 512 swap: 512
disk: local-lvm:16 disk: local-lvm:16
startup: order=40 startup: order=40
features: "nesting=1,keyctl=1" # fuse=1 — штатный флаг PVE вместо прежнего обхода сырыми lxc.* строками.
features: "fuse=1,keyctl=1,nesting=1"
unprivileged: true unprivileged: true
ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
devices: ["/dev/fuse"] devices: ["/dev/fuse"]
@@ -182,25 +227,30 @@ homelab_services:
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
gitea: gitea:
vmid: 141 vmid: 153
node: cloud-pc node: cloud-pc
ip: 192.168.1.25 ip: 192.168.1.25
hostname: gitea hostname: gitea
role: Git-хостинг Gitea role: Git-хостинг Gitea
provisioner: pct_ssh # playbooks/pve-gitea.yml provisioner: tofu # tofu/svc-gitea.tf, переезд 2026-09-02 (migration-tofu.md)
lxc: lxc:
cores: 2 cores: 2
memory: 2048 memory: 2048
swap: 1024 swap: 1024
disk: data:32 disk: data:32
startup: order=50 startup: order=50
features: "nesting=1,keyctl=1" # fuse=1 — штатный флаг PVE вместо прежнего обхода сырыми lxc.* строками.
features: "fuse=1,keyctl=1,nesting=1"
unprivileged: true unprivileged: true
ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
devices: ["/dev/fuse"] devices: ["/dev/fuse"]
mounts: mounts:
# mp0 на cloud-pc; каталог на хосте принадлежит uid/gid 101000 # Был bind mount каталога ноды /opt/data/gitea. С переезда 2026-09-02 —
- {host_path: /opt/data/gitea, container_path: /opt/gitea/data, host_uid: 101000, host_gid: 101000} # независимый volume на datastore: mp0 data:153/vm-153-disk-1.raw.
# Побочный выигрыш: bind mount vzdump ИСКЛЮЧАЛ из бэкапа ("not a
# volume"), то есть данные gitea в PBS никогда не попадали. Volume
# попадает.
- {volume: data, size: 32G, container_path: /opt/gitea/data}
data_dir: /opt/gitea/data data_dir: /opt/gitea/data
ports: ports:
- {name: http, port: 3000, proto: tcp} - {name: http, port: 3000, proto: tcp}
@@ -215,12 +265,13 @@ homelab_services:
storage: pbs storage: pbs
restic: restic:
profile: gitea profile: gitea
# restic-профиль выполняется на cloud-pc, а не внутри LXC # С переезда 2026-09-02 профиль выполняется ВНУТРИ LXC, как у
run_on: cloud-pc # vaultwarden: пути ноды больше не существуют, данные в volume.
run_on: gitea
repository: rclone:yadisk:System/Backups/HomeLab/restic/gitea repository: rclone:yadisk:System/Backups/HomeLab/restic/gitea
source_path: /opt/data/gitea source_path: /opt/gitea/data
schedule: "*-*-* 04:15:00" schedule: "*-*-* 04:15:00"
sqlite_db: /opt/data/gitea/gitea/gitea.db sqlite_db: /opt/gitea/data/gitea/gitea.db
monitoring: monitoring:
node_exporter: true node_exporter: true
blackbox: true blackbox: true
@@ -238,54 +289,24 @@ homelab_services:
status_code: [200] status_code: [200]
follow_redirects: none # соответствует `curl -fsS` без -L в старом плейбуке follow_redirects: none # соответствует `curl -fsS` без -L в старом плейбуке
# --------------------------------------------------------------------------
memoir-bot:
vmid: 142
node: mini-pc
ip: 192.168.1.26
hostname: memoir-bot
role: Telegram-бот дневника в Obsidian-хранилище
provisioner: pct_ssh # playbooks/pve-memoir-bot.yml
lxc:
cores: 1
memory: 512
swap: 512
disk: local-lvm:16
startup: order=60
features: "nesting=1,keyctl=1"
unprivileged: true
ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
devices: ["/dev/fuse"]
data_dir: /srv/memoir-bot
ports: [] # портов не публикует, только исходящие подключения к Telegram
images:
- memoir-bot:local # собирается на месте `docker build`, digest отсутствует
runtime: docker-run-systemd # /etc/systemd/system/memoir-bot.service
backup:
kind: pbs
job: homelab-pbs-daily-mini
schedule: "02:40"
storage: pbs
monitoring:
node_exporter: true
blackbox: false
backup_audit_vmid: true
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
mihomo: mihomo:
vmid: 143 vmid: 159
node: mini-pc node: mini-pc
ip: 192.168.1.27 ip: 192.168.1.27
hostname: mihomo hostname: mihomo
role: Локальный прокси Mihomo + веб-интерфейс MetaCubeXD role: Локальный прокси Mihomo + веб-интерфейс MetaCubeXD
provisioner: pct_ssh # playbooks/pve-mihomo.yml provisioner: tofu # tofu/svc-mihomo.tf, переезд 2026-09-03 (migration-tofu.md)
lxc: lxc:
cores: 1 cores: 1
memory: 512 memory: 512
swap: 512 swap: 512
disk: local-lvm:8 disk: local-lvm:8
startup: order=70 startup: order=70
features: "nesting=1,keyctl=1" # fuse=1 — штатный флаг PVE вместо прежнего обхода сырыми lxc.* строками.
# /dev/net/tun — блок device_passthrough в Tofu (dev0: path=/dev/net/tun),
# тоже вместо сырых lxc.* строк. Эффект тот же, запись в pct config иная.
features: "fuse=1,keyctl=1,nesting=1"
unprivileged: true unprivileged: true
ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
devices: ["/dev/fuse", "/dev/net/tun"] devices: ["/dev/fuse", "/dev/net/tun"]
@@ -311,19 +332,21 @@ homelab_services:
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
adguard: adguard:
vmid: 144 vmid: 158
node: mini-pc node: mini-pc
ip: 192.168.1.28 ip: 192.168.1.28
hostname: adguard hostname: adguard
role: AdGuard Home — DNS с фильтрацией role: AdGuard Home — DNS с фильтрацией
provisioner: pct_ssh # playbooks/pve-adguard.yml provisioner: tofu # tofu/svc-adguard.tf, переезд 2026-09-03 (migration-tofu.md)
lxc: lxc:
cores: 1 cores: 1
memory: 512 memory: 512
swap: 512 swap: 512
disk: local-lvm:8 disk: local-lvm:8
startup: order=40 startup: order=40
features: "nesting=1,keyctl=1" # fuse=1 — штатный флаг PVE вместо прежнего обхода сырыми lxc.* строками
# (lineinfile по /etc/pve/lxc/144.conf в pve-adguard.yml). Эффект тот же.
features: "fuse=1,keyctl=1,nesting=1"
unprivileged: true unprivileged: true
ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
devices: ["/dev/fuse"] devices: ["/dev/fuse"]
@@ -348,19 +371,22 @@ homelab_services:
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
docker-test: docker-test:
vmid: 145 vmid: 152
node: cloud-pc node: cloud-pc
ip: 192.168.1.29 ip: 192.168.1.29
hostname: docker-test hostname: docker-test
role: Песочница для проверки Docker в непривилегированном LXC role: Песочница для проверки Docker в непривилегированном LXC
provisioner: pct_ssh # playbooks/pve-docker-test.yml provisioner: tofu # tofu/svc-docker-test.tf, переезд 2026-09-02 (migration-tofu.md)
lxc: lxc:
cores: 1 cores: 1
memory: 512 memory: 512
swap: 512 swap: 512
disk: data:8 disk: data:8
startup: order=50 startup: order=50
features: "nesting=1,keyctl=1" # fuse=1 появился после переезда на Tofu: раньше /dev/fuse пробрасывался
# сырыми lxc.* строками через lineinfile, теперь это штатный флаг PVE.
# Эффект тот же, запись в pct config другая.
features: "fuse=1,keyctl=1,nesting=1"
unprivileged: true unprivileged: true
ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
devices: ["/dev/fuse"] devices: ["/dev/fuse"]
@@ -378,23 +404,33 @@ homelab_services:
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
monitoring: monitoring:
vmid: 146 vmid: 155
node: cloud-pc node: cloud-pc
ip: 192.168.1.30 ip: 192.168.1.30
hostname: monitoring hostname: monitoring
role: Prometheus + Alertmanager + Grafana + blackbox + pve-exporter + Uptime Kuma # До переезда 2026-09-02 контейнер нёс ещё и замороженный стек
provisioner: pve_lxc # playbooks/pve-monitoring.yml # Prometheus/Alertmanager/Grafana/blackbox/pve-exporter. В новый контейнер
# он сознательно не разворачивался (migration-tofu.md, п.5.5), поэтому
# фактически здесь работает только Uptime Kuma. Возврат стека — отдельное
# architecture decision, см. legacy-warning.md.
role: Uptime Kuma (замороженный стек Prometheus больше не развёрнут)
provisioner: tofu # tofu/svc-monitoring.tf, переезд 2026-09-02 (migration-tofu.md)
lxc: lxc:
cores: 2 cores: 2
memory: 4096 memory: 4096
swap: 512 swap: 512
disk: data:24 disk: data:24
# Ресурсы оставлены как были, под замороженный стек. Фактическое
# потребление после переезда — 184 МБ RAM и 1.8 ГБ диска. Уменьшение —
# отдельное решение, не часть переезда.
startup: order=80 startup: order=80
# создаётся с nesting=1, keyctl=1 добавляется отдельной задачей `pct set` features: "keyctl=1,nesting=1"
features: "nesting=1,keyctl=1"
unprivileged: true unprivileged: true
ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
data_dir: /opt/monitoring # Данные Uptime Kuma лежат в /opt/uptime-kuma, а НЕ в /opt/monitoring:
# последний принадлежал замороженному стеку и на новом контейнере
# отсутствует.
data_dir: /opt/uptime-kuma
ports: ports:
- {name: grafana, port: 3000, proto: tcp, bind: 192.168.1.30} - {name: grafana, port: 3000, proto: tcp, bind: 192.168.1.30}
- {name: pushgateway, port: 9091, proto: tcp, bind: 192.168.1.30} - {name: pushgateway, port: 9091, proto: tcp, bind: 192.168.1.30}
@@ -454,12 +490,12 @@ homelab_services:
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
emergency-bot: emergency-bot:
vmid: 148 vmid: 151
node: mini-pc node: mini-pc
ip: 192.168.1.32 ip: 192.168.1.32
hostname: emergency-bot hostname: emergency-bot
role: Telegram-бот аварийного доступа role: Telegram-бот аварийного доступа
provisioner: pve_lxc # playbooks/pve-emergency-bot.yml provisioner: tofu # tofu/services.tf, переезд 2026-09-02 (migration-tofu.md)
lxc: lxc:
cores: 1 cores: 1
memory: 512 memory: 512
@@ -480,20 +516,20 @@ homelab_services:
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
grimmory: grimmory:
vmid: 149 vmid: 157
node: cloud-pc node: cloud-pc
ip: 192.168.1.34 ip: 192.168.1.34
hostname: grimmory hostname: grimmory
role: Библиотека книг Grimmory (+ MariaDB), OPDS/KOReader role: Библиотека книг Grimmory (+ MariaDB), OPDS/KOReader
provisioner: pve_lxc # playbooks/pve-grimmory.yml provisioner: tofu # tofu/svc-grimmory.tf, переезд 2026-09-02 (migration-tofu.md)
lxc: lxc:
cores: 2 cores: 2
memory: 4096 memory: 4096
swap: 1024 swap: 1024
disk: data:64 disk: data:64
startup: order=100 startup: order=100
# создаётся с nesting=1, keyctl=1 добавляется отдельной задачей `pct set` # fuse=1 — штатный флаг PVE вместо прежнего обхода сырыми lxc.* строками.
features: "nesting=1,keyctl=1" features: "fuse=1,keyctl=1,nesting=1"
unprivileged: true unprivileged: true
ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
devices: ["/dev/fuse"] devices: ["/dev/fuse"]
@@ -576,12 +612,12 @@ homelab_services:
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
gyro: gyro:
vmid: 150 vmid: 156
node: mini-pc node: mini-pc
ip: 192.168.1.35 ip: 192.168.1.35
hostname: gyro hostname: gyro
role: Изолированный контейнер под задачу gyro (доступ в сеть только через mihomo) role: Изолированный контейнер под задачу gyro (доступ в сеть только через mihomo)
provisioner: pve_lxc # playbooks/pve-gyro.yml provisioner: tofu # tofu/svc-gyro.tf
lxc: lxc:
cores: 1 cores: 1
memory: 512 memory: 512
@@ -591,7 +627,7 @@ homelab_services:
features: "" # pve_lxc_features: [] — nesting отключён намеренно features: "" # pve_lxc_features: [] — nesting отключён намеренно
unprivileged: true unprivileged: true
ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
firewall: proxmox # /etc/pve/firewall/150.fw, policy_in DROP firewall: proxmox # /etc/pve/firewall/156.fw, policy_in DROP
ports: [] ports: []
images: [] # роль gyro образы не закрепляет images: [] # роль gyro образы не закрепляет
backup: backup:
-5
View File
@@ -64,10 +64,6 @@ all:
ansible_host: 192.168.1.25 ansible_host: 192.168.1.25
expected_lan_ip: 192.168.1.25 expected_lan_ip: 192.168.1.25
memoir-bot:
ansible_host: 192.168.1.26
expected_lan_ip: 192.168.1.26
mihomo: mihomo:
ansible_host: 192.168.1.27 ansible_host: 192.168.1.27
expected_lan_ip: 192.168.1.27 expected_lan_ip: 192.168.1.27
@@ -103,7 +99,6 @@ all:
ovpn-mini: ovpn-mini:
vaultwarden: vaultwarden:
gitea: gitea:
memoir-bot:
mihomo: mihomo:
adguard: adguard:
monitoring: monitoring:
+8 -6
View File
@@ -2,19 +2,23 @@
- name: Create and verify current PBS audit before AdGuard update - name: Create and verify current PBS audit before AdGuard update
hosts: mini-pc hosts: mini-pc
gather_facts: false gather_facts: false
vars:
adguard_vmid: "{{ homelab_services['adguard'].vmid }}"
tasks: tasks:
- name: Verify AdGuard VMID ownership before backup - name: Verify AdGuard VMID ownership before backup
ansible.builtin.command: pct config 144 ansible.builtin.command: "pct config {{ adguard_vmid }}"
register: adguard_pct_config register: adguard_pct_config
changed_when: false changed_when: false
failed_when: false failed_when: false
- name: Refuse to back up a foreign VMID 144 - name: Refuse to back up a foreign AdGuard VMID
ansible.builtin.assert: ansible.builtin.assert:
that: that:
- adguard_pct_config.rc == 0 - adguard_pct_config.rc == 0
- adguard_existing_hostname == 'adguard' - adguard_existing_hostname == 'adguard'
fail_msg: VMID 144 is not the AdGuard container. fail_msg: >-
VMID {{ adguard_vmid }} from the service registry is not the
AdGuard container.
vars: vars:
adguard_existing_hostname: >- adguard_existing_hostname: >-
{{ adguard_pct_config.stdout_lines {{ adguard_pct_config.stdout_lines
@@ -41,13 +45,11 @@
ansible.builtin.command: ansible.builtin.command:
argv: argv:
- vzdump - vzdump
- "144" - "{{ adguard_vmid }}"
- --storage - --storage
- pbs - pbs
- --mode - --mode
- snapshot - snapshot
- --prune-backups
- keep-all=1
- --exclude-path - --exclude-path
- /var/lib/docker/fuse-overlayfs/*/merged - /var/lib/docker/fuse-overlayfs/*/merged
@@ -0,0 +1,93 @@
---
# ============================================================================
# Выпуск read-only Proxmox-токена для виджета Proxmox в Homepage-дашборде.
#
# Создаёт пользователя homepage@pve, роль PVEAuditor на / и privsep-токен
# homepage@pve!dashboard. Секрет пишется в КОРНЕВОЙ .env как
# DASHBOARD_PVE_API_USER / DASHBOARD_PVE_API_TOKEN_ID /
# DASHBOARD_PVE_API_TOKEN_SECRET — оттуда его читает playbooks/dashboard.yml
# через lookup('env', ...).
#
# Парный к playbooks/bootstrap-monitoring-pve-token.yml. Отдельный принципал,
# чтобы дашборд не зависел от кредов замороженного стека Prometheus.
#
# Запуск: make bootstrap-dashboard-token
# ============================================================================
- name: Create read-only Proxmox token for the Homepage dashboard
hosts: mini-pc
gather_facts: false
vars:
dashboard_pve_user: homepage@pve
dashboard_pve_token_id: dashboard
# .env лежит в корне репозитория, playbook_dir — это ansible/playbooks.
dashboard_pve_env_file: "{{ playbook_dir }}/../../.env"
dashboard_pve_rotate_existing_token: false
tasks:
- name: Read existing Proxmox users
ansible.builtin.command: pveum user list --output-format json
register: dashboard_pve_users_raw
changed_when: false
- name: Create the dashboard Proxmox user
ansible.builtin.command: >-
pveum user add {{ dashboard_pve_user }}
--comment 'Read-only Homepage dashboard user'
when: dashboard_pve_user not in (dashboard_pve_users_raw.stdout | from_json | map(attribute='userid') | list)
- name: Grant PVEAuditor role to the dashboard user
ansible.builtin.command: >-
pveum acl modify / -user {{ dashboard_pve_user }} -role PVEAuditor
changed_when: false
- name: Read the dashboard user tokens
ansible.builtin.command: >-
pveum user token list {{ dashboard_pve_user }} --output-format json
register: dashboard_pve_tokens_raw
changed_when: false
- name: Refuse to overwrite an existing dashboard token
ansible.builtin.assert:
that:
- dashboard_pve_token_id not in (dashboard_pve_tokens_raw.stdout | from_json | map(attribute='tokenid') | list)
fail_msg: >-
Existing dashboard token secret cannot be recovered safely. Rotate it
explicitly (-e dashboard_pve_rotate_existing_token=true) before rerunning.
when: not dashboard_pve_rotate_existing_token | bool
- name: Rotate the existing dashboard token explicitly
ansible.builtin.command: >-
pveum user token remove {{ dashboard_pve_user }} {{ dashboard_pve_token_id }}
when:
- dashboard_pve_rotate_existing_token | bool
- dashboard_pve_token_id in (dashboard_pve_tokens_raw.stdout | from_json | map(attribute='tokenid') | list)
- name: Create the separated dashboard token
ansible.builtin.command: >-
pveum user token add {{ dashboard_pve_user }} {{ dashboard_pve_token_id }}
--privsep 1 --comment 'Homepage Proxmox widget' --output-format json
register: dashboard_pve_token_created
no_log: true
- name: Grant PVEAuditor role to the separated dashboard token
ansible.builtin.command: >-
pveum acl modify / -token {{ dashboard_pve_user }}!{{ dashboard_pve_token_id }} -role PVEAuditor
changed_when: false
- name: Store the dashboard token variables in the root .env
ansible.builtin.lineinfile:
path: "{{ dashboard_pve_env_file }}"
regexp: "^export {{ item.name }}="
line: "export {{ item.name }}='{{ item.value }}'"
create: false
loop:
- name: DASHBOARD_PVE_API_USER
value: "{{ dashboard_pve_user }}"
- name: DASHBOARD_PVE_API_TOKEN_ID
value: "{{ dashboard_pve_token_id }}"
- name: DASHBOARD_PVE_API_TOKEN_SECRET
value: "{{ (dashboard_pve_token_created.stdout | from_json).value }}"
delegate_to: localhost
vars:
ansible_connection: local
ansible_become: false
no_log: true
@@ -5,7 +5,8 @@
vars: vars:
monitoring_pve_user: monitoring@pve monitoring_pve_user: monitoring@pve
monitoring_pve_token_id: prometheus monitoring_pve_token_id: prometheus
monitoring_pve_env_file: "{{ playbook_dir }}/../.env" # .env лежит в корне репозитория, playbook_dir — это ansible/playbooks.
monitoring_pve_env_file: "{{ playbook_dir }}/../../.env"
monitoring_pve_rotate_existing_token: false monitoring_pve_rotate_existing_token: false
tasks: tasks:
- name: Read existing Proxmox users - name: Read existing Proxmox users
@@ -37,10 +37,17 @@
pve_token_data: "{{ pve_token_add.stdout | from_json }}" pve_token_data: "{{ pve_token_add.stdout | from_json }}"
no_log: true no_log: true
- name: Write local ansible/.env # ВНИМАНИЕ: задача переписывает файл ЦЕЛИКОМ, а не правит отдельные строки.
# Всё, что добавлено вручную или другими целями — MONITORING_*, EMERGENCY_*,
# PROXMOX_ROOT_PASSWORD — будет потеряно. Поэтому backup: true.
# Строки пишутся с префиксом `export`: по нему bootstrap-monitoring-pve-token.yml
# находит их своим lineinfile (regexp: "^export NAME="), и он же позволяет
# просто сделать `source .env` в обычном шелле.
- name: Write the local .env in the repository root
ansible.builtin.copy: ansible.builtin.copy:
dest: "{{ playbook_dir }}/../.env" dest: "{{ playbook_dir }}/../../.env"
mode: "0600" mode: "0600"
backup: true
content: | content: |
export PROXMOX_HOST={{ ansible_host }} export PROXMOX_HOST={{ ansible_host }}
export PROXMOX_USER='{{ pve_api_user }}' export PROXMOX_USER='{{ pve_api_user }}'
+131
View File
@@ -0,0 +1,131 @@
---
# ============================================================================
# Дашборд-обзор всей инфраструктуры HomeLab (Homepage, gethomepage.dev).
#
# ОБЛАСТЬ ОТВЕТСТВЕННОСТИ
# Разворачивает compose-стек Homepage на LXC monitoring (рядом с Uptime
# Kuma) и ГЕНЕРИРУЕТ его конфиги из реестра homelab_services: одна плитка
# на сервис, ссылка на его UI, группировка по узлу Proxmox. Добавили
# сервис в реестр -> плитка появилась сама, второй список вести не нужно.
#
# Наружу дашборд НЕ публикуется: контейнер слушает только LAN-адрес CT 155,
# доступ из локальной сети или по OpenVPN. Внутренняя топология (IP, VMID,
# раскладка по нодам) на публичный периметр не выносится.
#
# ГРАНИЦА С reverse-proxy.yml
# Никакой общей области. Дашборд — отдельный стек на другом хосте, Caddyfile
# он не трогает.
#
# ЗАПУСК
# make dashboard (ansible-playbook playbooks/dashboard.yml)
# make dry-dashboard (--check --diff)
# Первый прогон, пока образ не закреплён по digest:
# ansible-playbook playbooks/dashboard.yml -e dashboard_allow_floating_tag=true
#
# СЕКРЕТЫ
# Виджет proxmox требует read-only токен homepage@pve!dashboard (роль
# PVEAuditor). Выпускается playbooks/bootstrap-dashboard-pve-token.yml,
# секрет кладётся в корневой .env как DASHBOARD_PVE_API_USER /
# DASHBOARD_PVE_API_TOKEN_ID / DASHBOARD_PVE_API_TOKEN_SECRET и подхватывается
# отсюда через lookup('env', ...). Без него дашборд работает — просто плитка
# "Proxmox кластер" показывает ошибку виджета, это не блокер.
#
# ТАРГЕТ
# По умолчанию homelab_dashboard_host (monitoring). Переопределяется ради
# blue-green: `-e dashboard_config_target=monitoring-new --limit monitoring-new`
# (голый --limit play не перенацеливает, а обнуляет — см. uptime-kuma.yml).
# ============================================================================
- name: Deploy the HomeLab infrastructure dashboard (Homepage) on the monitoring LXC
hosts: "{{ dashboard_config_target | default(homelab_dashboard_host | default('monitoring')) }}"
gather_facts: true
vars:
dash_root: "{{ homelab_dashboard_dir | default('/opt/homepage') }}"
dash_config_dir: "{{ (homelab_dashboard_dir | default('/opt/homepage')) ~ '/config' }}"
dash_bind: "{{ homelab_dashboard_bind_ip | default(expected_lan_ip) }}"
dash_port: "{{ homelab_dashboard_port | default(8082) }}"
dash_image: "{{ homelab_dashboard_image | default('') }}"
dash_allowed_hosts: >-
{{ homelab_dashboard_allowed_hosts | default(dash_bind ~ ':' ~ dash_port) }}
# Список узлов Proxmox, встречающихся в реестре, — по нему строятся группы.
dashboard_nodes: >-
{{ homelab_services | dict2items | map(attribute='value.node')
| unique | sort | list }}
# Секрет Proxmox-виджета из окружения (make load .env). Пусто -> плитка
# рендерится без данных. no_log на задаче, которая это пишет.
dash_pve_user: "{{ lookup('ansible.builtin.env', 'DASHBOARD_PVE_API_USER') }}"
dash_pve_token_id: "{{ lookup('ansible.builtin.env', 'DASHBOARD_PVE_API_TOKEN_ID') }}"
dash_pve_token_secret: "{{ lookup('ansible.builtin.env', 'DASHBOARD_PVE_API_TOKEN_SECRET') }}"
pre_tasks:
- name: Require the dashboard registry variables
ansible.builtin.assert:
that:
- dash_image | length > 0
- dash_bind | length > 0
fail_msg: >-
Не заданы homelab_dashboard_* в
inventory/group_vars/all/services.yml.
- name: Require the Homepage image to be pinned by digest
ansible.builtin.assert:
that:
- "'@sha256:' in dash_image or dashboard_allow_floating_tag | default(false) | bool"
fail_msg: >-
{{ dash_image }} не закреплён по digest. Выполни на
{{ inventory_hostname }}:
docker pull {{ dash_image }}
docker inspect --format '{{ '{{' }} index .RepoDigests 0 {{ '}}' }}' {{ dash_image }}
и пропиши tag@sha256 в homelab_dashboard_image. Разовый обход для
первого прогона: -e dashboard_allow_floating_tag=true
- name: Ensure the Homepage config directory exists
ansible.builtin.file:
path: "{{ dash_config_dir }}"
state: directory
owner: root
group: root
mode: "0755"
- name: Render Homepage configuration files from the registry
ansible.builtin.template:
src: "{{ item.src }}"
dest: "{{ dash_config_dir }}/{{ item.dest }}"
owner: root
group: root
mode: "0644"
loop:
- {src: homepage-settings.yaml.j2, dest: settings.yaml}
- {src: homepage-services.yaml.j2, dest: services.yaml}
- {src: homepage-widgets.yaml.j2, dest: widgets.yaml}
- {src: homepage-bookmarks.yaml.j2, dest: bookmarks.yaml}
loop_control:
label: "{{ item.dest }}"
register: dash_config_files
- name: Render the Homepage widget secrets file
ansible.builtin.template:
src: homepage.env.j2
dest: "{{ dash_root }}/homepage.env"
owner: root
group: root
mode: "0600"
register: dash_env_file
no_log: true
roles:
- role: compose_service
compose_service_name: homelab-homepage
compose_service_description: HomeLab infrastructure dashboard (Homepage)
compose_service_root: "{{ dash_root }}"
compose_service_root_mode: "0755"
compose_service_compose_file: docker-compose.yml
compose_service_compose_template: homepage-compose.yml.j2
# | bool обязателен: роль фильтрует триггеры по truthiness, а строка
# "False" из "{{ ... is changed }}" тоже истинна.
compose_service_restart_triggers:
- "{{ (dash_config_files is changed) | bool }}"
- "{{ (dash_env_file is changed) | bool }}"
compose_service_health_url: "http://{{ dash_bind }}:{{ dash_port }}/"
compose_service_health_status: [200]
compose_service_health_follow_redirects: none
+7 -1
View File
@@ -1,6 +1,6 @@
--- ---
- name: Create and verify Gitea backup before update - name: Create and verify Gitea backup before update
hosts: cloud-pc hosts: gitea
gather_facts: false gather_facts: false
tasks: tasks:
- name: Create a fresh Gitea offsite backup - name: Create a fresh Gitea offsite backup
@@ -12,6 +12,10 @@
- homelab-restic-offsite-gitea.service - homelab-restic-offsite-gitea.service
changed_when: true changed_when: true
- name: Run Gitea backup audit
hosts: cloud-pc
gather_facts: false
tasks:
- name: Run Gitea offsite backup audit - name: Run Gitea offsite backup audit
ansible.builtin.command: ansible.builtin.command:
argv: argv:
@@ -22,6 +26,8 @@
changed_when: true changed_when: true
- import_playbook: pve-gitea.yml - import_playbook: pve-gitea.yml
vars:
pve_provisioning_enabled: false
- name: Verify Gitea public endpoints after update - name: Verify Gitea public endpoints after update
hosts: ru-vps hosts: ru-vps
+9 -6
View File
@@ -24,23 +24,26 @@
- name: Create a fresh Grimmory PBS backup - name: Create a fresh Grimmory PBS backup
hosts: cloud-pc hosts: cloud-pc
gather_facts: false gather_facts: false
vars:
grimmory_vmid: "{{ homelab_services['grimmory'].vmid }}"
tasks: tasks:
- name: Read Grimmory LXC config - name: Read Grimmory LXC config
ansible.builtin.command: ansible.builtin.command:
argv: argv:
- pct - pct
- config - config
- "149" - "{{ grimmory_vmid }}"
register: grimmory_pct_config register: grimmory_pct_config
changed_when: false changed_when: false
- name: Assert VMID 149 belongs to Grimmory - name: Assert registry VMID belongs to Grimmory
ansible.builtin.assert: ansible.builtin.assert:
that: that:
- grimmory_pct_hostname_line != "" - grimmory_pct_hostname_line != ""
- grimmory_pct_hostname == "grimmory" - grimmory_pct_hostname == "grimmory"
fail_msg: >- fail_msg: >-
Refusing to run vzdump 149 because pct config hostname is not grimmory: Refusing to run vzdump {{ grimmory_vmid }} because pct config
hostname is not grimmory:
{{ grimmory_pct_hostname_line | default('missing hostname line') }} {{ grimmory_pct_hostname_line | default('missing hostname line') }}
vars: vars:
grimmory_pct_hostname_line: >- grimmory_pct_hostname_line: >-
@@ -66,13 +69,11 @@
ansible.builtin.command: ansible.builtin.command:
argv: argv:
- vzdump - vzdump
- "149" - "{{ grimmory_vmid }}"
- --storage - --storage
- pbs - pbs
- --mode - --mode
- snapshot - snapshot
- --prune-backups
- keep-all=1
- --exclude-path - --exclude-path
- /var/lib/docker/fuse-overlayfs/*/merged - /var/lib/docker/fuse-overlayfs/*/merged
@@ -90,6 +91,8 @@
changed_when: true changed_when: true
- import_playbook: pve-grimmory.yml - import_playbook: pve-grimmory.yml
vars:
pve_provisioning_enabled: false
- name: Verify Grimmory public endpoint after update - name: Verify Grimmory public endpoint after update
hosts: ru-vps hosts: ru-vps
+10 -1
View File
@@ -1,6 +1,15 @@
--- ---
- name: Configure Gyro investment allocator host - name: Configure Gyro investment allocator host
hosts: gyro # Таргет переопределяем ради blue-green переезда на OpenTofu: во время
# миграции этот play нужно прогнать против нового контейнера на временном
# адресе. Просто `--limit gyro-new` для этого НЕ годится — лимит
# пересекается с паттерном play и даёт ноль хостов, а не перенацеливание
# (проверено 2026-09-02 через --list-hosts на всех плейбуках).
# Использовать вместе с --limit, чтобы play создания контейнера отсеялся:
# ansible-playbook playbooks/gyro.yml \
# -e pve_config_target=gyro-new --limit gyro-new
# По умолчанию поведение не меняется.
hosts: "{{ pve_config_target | default('gyro') }}"
gather_facts: true gather_facts: true
roles: roles:
- role: gyro - role: gyro
+9 -7
View File
@@ -2,19 +2,23 @@
- name: Verify Mihomo PBS audit before update - name: Verify Mihomo PBS audit before update
hosts: mini-pc hosts: mini-pc
gather_facts: false gather_facts: false
vars:
mihomo_vmid: "{{ homelab_services['mihomo'].vmid }}"
tasks: tasks:
- name: Read VMID 143 configuration - name: Read Mihomo VMID configuration
ansible.builtin.command: "pct config 143" ansible.builtin.command: "pct config {{ mihomo_vmid }}"
register: mihomo_pct_config register: mihomo_pct_config
changed_when: false changed_when: false
failed_when: false failed_when: false
- name: Refuse to run backup unless VMID 143 is Mihomo - name: Refuse to run backup unless registry VMID is Mihomo
ansible.builtin.assert: ansible.builtin.assert:
that: that:
- mihomo_pct_config.rc == 0 - mihomo_pct_config.rc == 0
- mihomo_update_hostname == 'mihomo' - mihomo_update_hostname == 'mihomo'
fail_msg: VMID 143 must be the Mihomo container before backup. fail_msg: >-
VMID {{ mihomo_vmid }} from the service registry must be the Mihomo
container before backup.
vars: vars:
mihomo_update_hostname: >- mihomo_update_hostname: >-
{{ mihomo_pct_config.stdout_lines {{ mihomo_pct_config.stdout_lines
@@ -41,13 +45,11 @@
ansible.builtin.command: ansible.builtin.command:
argv: argv:
- vzdump - vzdump
- "143" - "{{ mihomo_vmid }}"
- --storage - --storage
- pbs - pbs
- --mode - --mode
- snapshot - snapshot
- --prune-backups
- keep-all=1
- --exclude-path - --exclude-path
- /var/lib/docker/fuse-overlayfs/*/merged - /var/lib/docker/fuse-overlayfs/*/merged
+23 -10
View File
@@ -1,23 +1,36 @@
# Профиль выполняется ВНУТРИ контейнера gitea, а не на ноде cloud-pc.
# До переезда на OpenTofu (2026-09-02) данные жили в bind mount каталога ноды
# /opt/data/gitea, и профиль работал на самой ноде. После переезда данные —
# это volume контейнера (mp0 на datastore), снаружи LXC такого пути больше нет,
# поэтому профиль переехал внутрь по образцу vaultwarden. Репозиторий restic
# тот же, цепочка снапшотов продолжается; изменились только пути внутри них.
- name: Configure Gitea offsite backup to Yandex Disk - name: Configure Gitea offsite backup to Yandex Disk
hosts: cloud-pc hosts: gitea
gather_facts: false gather_facts: false
vars: vars:
ansible_become: false
offsite_profile: gitea offsite_profile: gitea
offsite_repository: rclone:yadisk:System/Backups/HomeLab/restic/gitea offsite_repository: rclone:yadisk:System/Backups/HomeLab/restic/gitea
offsite_source_path: /opt/data/gitea offsite_source_path: /opt/gitea/data
offsite_sqlite_db: /opt/data/gitea/gitea/gitea.db offsite_sqlite_db: /opt/gitea/data/gitea/gitea.db
offsite_backup_tag: gitea,cloud-pc,yadisk offsite_backup_tag: gitea,cloud-pc,yadisk
offsite_timer_oncalendar: "*-*-* 04:15:00" offsite_timer_oncalendar: "*-*-* 04:15:00"
offsite_rclone_config_local: ~/.config/rclone/rclone.conf offsite_rclone_config_local: ~/.config/rclone/rclone.conf
offsite_restic_password_local: "{{ playbook_dir }}/../generated/restic-offsite-password" offsite_restic_password_local: "{{ playbook_dir }}/../generated/restic-offsite-password"
offsite_excludes: offsite_excludes:
- /opt/data/gitea/gitea/gitea.db - /opt/gitea/data/gitea/gitea.db
- /opt/data/gitea/gitea/gitea.db-shm - /opt/gitea/data/gitea/gitea.db-shm
- /opt/data/gitea/gitea/gitea.db-wal - /opt/gitea/data/gitea/gitea.db-wal
- /opt/data/gitea/gitea/log/** - /opt/gitea/data/gitea/log/**
- /opt/data/gitea/gitea/sessions/** - /opt/gitea/data/gitea/sessions/**
- /opt/data/gitea/gitea/queues/** - /opt/gitea/data/gitea/queues/**
- /opt/data/gitea/gitea/tmp/** - /opt/gitea/data/gitea/tmp/**
# lost+found появился вместе с переходом на volume: это свежая ext4, и
# каталог принадлежит uid 0 хоста, что внутри unprivileged LXC видно как
# nobody:nogroup и нечитаемо. Без исключения restic отдаёт exit 3
# ("at least one source file could not be read") и юнит падает каждую
# ночь, хотя снапшот при этом сохраняется. Найдено 2026-09-02.
- /opt/gitea/data/lost+found/**
tasks: tasks:
- name: Configure restic offsite profile - name: Configure restic offsite profile
ansible.builtin.include_tasks: ../tasks/offsite-restic-profile.yml ansible.builtin.include_tasks: ../tasks/offsite-restic-profile.yml
+21 -1
View File
@@ -3,6 +3,13 @@
hosts: mini-pc hosts: mini-pc
gather_facts: false gather_facts: false
vars: vars:
# После переезда на OpenTofu (tofu/svc-adguard.tf, VMID 158) этот play —
# legacy: он таргетит СТАРЫЙ VMID 144 и его handler сделал бы pct start 144,
# подняв остановленный откат на боевом адресе. Пропускаем, пока реестр
# говорит provisioner: tofu. Для намеренного legacy rollback:
# -e pve_adguard_legacy_provisioning_enabled=true
pve_adguard_legacy_provisioning_enabled: >-
{{ homelab_services['adguard'].provisioner != 'tofu' }}
adguard_vmid: 144 adguard_vmid: 144
adguard_hostname: adguard adguard_hostname: adguard
adguard_ip: 192.168.1.28/24 adguard_ip: 192.168.1.28/24
@@ -15,6 +22,10 @@
- name: restart adguard lxc - name: restart adguard lxc
ansible.builtin.shell: "pct stop {{ adguard_vmid }} || true; pct start {{ adguard_vmid }}" ansible.builtin.shell: "pct stop {{ adguard_vmid }} || true; pct start {{ adguard_vmid }}"
changed_when: true changed_when: true
pre_tasks:
- name: Skip legacy AdGuard provisioning after cutover
ansible.builtin.meta: end_play
when: not (pve_adguard_legacy_provisioning_enabled | bool)
tasks: tasks:
- name: Check if AdGuard LXC exists - name: Check if AdGuard LXC exists
ansible.builtin.command: "pct config {{ adguard_vmid }}" ansible.builtin.command: "pct config {{ adguard_vmid }}"
@@ -93,7 +104,16 @@
ansible_become: false ansible_become: false
- name: Configure AdGuard Home inside LXC - name: Configure AdGuard Home inside LXC
hosts: adguard # Таргет переопределяем ради blue-green переезда на OpenTofu: во время
# миграции этот play нужно прогнать против нового контейнера на временном
# адресе. Просто `--limit adguard-new` для этого НЕ годится — лимит
# пересекается с паттерном play и даёт ноль хостов, а не перенацеливание
# (проверено 2026-09-02 через --list-hosts на всех плейбуках).
# Использовать вместе с --limit, чтобы play создания контейнера отсеялся:
# ansible-playbook playbooks/pve-adguard.yml \
# -e pve_config_target=adguard-new --limit adguard-new
# По умолчанию поведение не меняется.
hosts: "{{ pve_config_target | default('adguard') }}"
gather_facts: true gather_facts: true
vars: vars:
ansible_become: false ansible_become: false
+16 -3
View File
@@ -1,3 +1,7 @@
# Списки VMID выводятся из реестра homelab_services по полю backup.job, а не
# перечисляются вручную. Раньше добавление сервиса требовало отдельной правки
# здесь, и о ней легко было забыть — CT 148 emergency-bot до сих пор без бэкапа
# именно по этой причине.
- name: Configure Proxmox backup jobs - name: Configure Proxmox backup jobs
hosts: mini-pc hosts: mini-pc
gather_facts: false gather_facts: false
@@ -6,7 +10,10 @@
- id: homelab-pbs-daily-cloud - id: homelab-pbs-daily-cloud
comment: Daily PBS backup for cloud-pc service containers comment: Daily PBS backup for cloud-pc service containers
node: cloud-pc node: cloud-pc
vmid: 141,145,146,147,149 vmid: >-
{{ homelab_services.values() | selectattr('backup.job', 'defined')
| selectattr('backup.job', 'eq', 'homelab-pbs-daily-cloud')
| map(attribute='vmid') | sort | join(',') }}
storage: pbs storage: pbs
schedule: "02:10" schedule: "02:10"
mode: snapshot mode: snapshot
@@ -14,7 +21,10 @@
- id: homelab-pbs-daily-mini - id: homelab-pbs-daily-mini
comment: Daily PBS backup for mini-pc service containers comment: Daily PBS backup for mini-pc service containers
node: mini-pc node: mini-pc
vmid: 132,140,142,143,144,150 vmid: >-
{{ homelab_services.values() | selectattr('backup.job', 'defined')
| selectattr('backup.job', 'eq', 'homelab-pbs-daily-mini')
| map(attribute='vmid') | sort | join(',') }}
storage: pbs storage: pbs
schedule: "02:40" schedule: "02:40"
mode: snapshot mode: snapshot
@@ -22,7 +32,10 @@
- id: homelab-local-weekly-pbs - id: homelab-local-weekly-pbs
comment: Weekly local backup for PBS container rootfs/config comment: Weekly local backup for PBS container rootfs/config
node: cloud-pc node: cloud-pc
vmid: 120 vmid: >-
{{ homelab_services.values() | selectattr('backup.job', 'defined')
| selectattr('backup.job', 'eq', 'homelab-local-weekly-pbs')
| map(attribute='vmid') | sort | join(',') }}
storage: backup storage: backup
schedule: "Sun 03:30" schedule: "Sun 03:30"
mode: snapshot mode: snapshot
+21 -1
View File
@@ -3,6 +3,13 @@
hosts: cloud-pc hosts: cloud-pc
gather_facts: false gather_facts: false
vars: vars:
# После переезда на OpenTofu (tofu/svc-docker-test.tf, VMID 152) этот play —
# legacy: он таргетит СТАРЫЙ VMID 145 и его handler/"Start" сделали бы
# pct start 145, подняв остановленный откат на боевом адресе 192.168.1.29.
# Пропускаем, пока реестр говорит provisioner: tofu. Для намеренного
# legacy rollback: -e pve_docker_test_legacy_provisioning_enabled=true
pve_docker_test_legacy_provisioning_enabled: >-
{{ homelab_services['docker-test'].provisioner != 'tofu' }}
docker_test_vmid: 145 docker_test_vmid: 145
docker_test_hostname: docker-test docker_test_hostname: docker-test
docker_test_ip: 192.168.1.29/24 docker_test_ip: 192.168.1.29/24
@@ -14,6 +21,10 @@
- name: restart docker test lxc - name: restart docker test lxc
ansible.builtin.shell: "pct stop {{ docker_test_vmid }} || true; pct start {{ docker_test_vmid }}" ansible.builtin.shell: "pct stop {{ docker_test_vmid }} || true; pct start {{ docker_test_vmid }}"
changed_when: true changed_when: true
pre_tasks:
- name: Skip legacy docker-test provisioning after cutover
ansible.builtin.meta: end_play
when: not (pve_docker_test_legacy_provisioning_enabled | bool)
tasks: tasks:
- name: Check if Docker test LXC exists - name: Check if Docker test LXC exists
ansible.builtin.command: "pct config {{ docker_test_vmid }}" ansible.builtin.command: "pct config {{ docker_test_vmid }}"
@@ -79,7 +90,16 @@
ansible_become: false ansible_become: false
- name: Configure Docker test host - name: Configure Docker test host
hosts: docker-test # Таргет переопределяем ради blue-green переезда на OpenTofu: во время
# миграции этот play нужно прогнать против нового контейнера на временном
# адресе. Просто `--limit docker-test-new` для этого НЕ годится — лимит
# пересекается с паттерном play и даёт ноль хостов, а не перенацеливание
# (проверено 2026-09-02 через --list-hosts на всех плейбуках).
# Использовать вместе с --limit, чтобы play создания контейнера отсеялся:
# ansible-playbook playbooks/pve-docker-test.yml \
# -e pve_config_target=docker-test-new --limit docker-test-new
# По умолчанию поведение не меняется.
hosts: "{{ pve_config_target | default('docker-test') }}"
gather_facts: true gather_facts: true
vars: vars:
ansible_become: false ansible_become: false
+19
View File
@@ -1,4 +1,10 @@
--- ---
# После переезда на OpenTofu (tofu/services.tf, VMID 151) обе play здесь —
# legacy: они таргетят СТАРЫЙ VMID 148 на боевом адресе 192.168.1.32.
# Пропускаем, пока реестр говорит provisioner: tofu. Конфигурация emergency-bot
# в этот плейбук не входит — она в playbooks/emergency-access.yml. Для
# намеренного legacy rollback: -e pve_emergency_bot_legacy_provisioning_enabled=true
- name: Create emergency-bot LXC on mini-pc - name: Create emergency-bot LXC on mini-pc
hosts: localhost hosts: localhost
connection: local connection: local
@@ -7,6 +13,8 @@
vars: vars:
ansible_become: false ansible_become: false
ansible_python_interpreter: "{{ ansible_playbook_python }}" ansible_python_interpreter: "{{ ansible_playbook_python }}"
pve_emergency_bot_legacy_provisioning_enabled: >-
{{ homelab_services['emergency-bot'].provisioner != 'tofu' }}
pve_lxc_vmid: 148 pve_lxc_vmid: 148
pve_lxc_node: mini-pc pve_lxc_node: mini-pc
pve_lxc_hostname: emergency-bot pve_lxc_hostname: emergency-bot
@@ -18,12 +26,23 @@
pve_lxc_swap: 256 pve_lxc_swap: 256
pve_lxc_startup: order=70 pve_lxc_startup: order=70
pve_lxc_ostemplate: "{{ lookup('env', 'PVE_LXC_OSTEMPLATE') | default('local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst', true) }}" pve_lxc_ostemplate: "{{ lookup('env', 'PVE_LXC_OSTEMPLATE') | default('local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst', true) }}"
pre_tasks:
- name: Skip legacy emergency-bot provisioning after cutover
ansible.builtin.meta: end_play
when: not (pve_emergency_bot_legacy_provisioning_enabled | bool)
roles: roles:
- role: pve_lxc - role: pve_lxc
- name: Wait for emergency-bot SSH - name: Wait for emergency-bot SSH
hosts: emergency-bot hosts: emergency-bot
gather_facts: false gather_facts: false
vars:
pve_emergency_bot_legacy_provisioning_enabled: >-
{{ homelab_services['emergency-bot'].provisioner != 'tofu' }}
pre_tasks:
- name: Skip legacy emergency-bot provisioning after cutover
ansible.builtin.meta: end_play
when: not (pve_emergency_bot_legacy_provisioning_enabled | bool)
tasks: tasks:
- name: Wait for emergency-bot to accept SSH connections - name: Wait for emergency-bot to accept SSH connections
ansible.builtin.wait_for_connection: ansible.builtin.wait_for_connection:
+14 -1
View File
@@ -1,6 +1,10 @@
- name: Create Gitea LXC on cloud-pc - name: Create Gitea LXC on cloud-pc
hosts: cloud-pc hosts: cloud-pc
gather_facts: false gather_facts: false
pre_tasks:
- name: Skip legacy Gitea provisioning for runtime-only calls
ansible.builtin.meta: end_play
when: not (pve_provisioning_enabled | default(true) | bool)
vars: vars:
gitea_vmid: 141 gitea_vmid: 141
gitea_hostname: gitea gitea_hostname: gitea
@@ -111,7 +115,16 @@
ansible_become: false ansible_become: false
- name: Configure Docker and Gitea inside LXC - name: Configure Docker and Gitea inside LXC
hosts: gitea # Таргет переопределяем ради blue-green переезда на OpenTofu: во время
# миграции этот play нужно прогнать против нового контейнера на временном
# адресе. Просто `--limit gitea-new` для этого НЕ годится — лимит
# пересекается с паттерном play и даёт ноль хостов, а не перенацеливание
# (проверено 2026-09-02 через --list-hosts на всех плейбуках).
# Использовать вместе с --limit, чтобы play создания контейнера отсеялся:
# ansible-playbook playbooks/pve-gitea.yml \
# -e pve_config_target=gitea-new --limit gitea-new
# По умолчанию поведение не меняется.
hosts: "{{ pve_config_target | default('gitea') }}"
gather_facts: true gather_facts: true
vars: vars:
ansible_become: false ansible_become: false
+37 -6
View File
@@ -2,6 +2,10 @@
- name: Guard Grimmory VMID before API updates - name: Guard Grimmory VMID before API updates
hosts: cloud-pc hosts: cloud-pc
gather_facts: false gather_facts: false
pre_tasks:
- name: Skip legacy Grimmory provisioning for runtime-only calls
ansible.builtin.meta: end_play
when: not (pve_provisioning_enabled | default(true) | bool)
tasks: tasks:
- name: Read existing VMID 149 configuration - name: Read existing VMID 149 configuration
ansible.builtin.command: pct config 149 ansible.builtin.command: pct config 149
@@ -28,6 +32,10 @@
connection: local connection: local
become: false become: false
gather_facts: false gather_facts: false
pre_tasks:
- name: Skip legacy Grimmory provisioning for runtime-only calls
ansible.builtin.meta: end_play
when: not (pve_provisioning_enabled | default(true) | bool)
vars: vars:
ansible_become: false ansible_become: false
ansible_python_interpreter: "{{ ansible_playbook_python }}" ansible_python_interpreter: "{{ ansible_playbook_python }}"
@@ -52,6 +60,10 @@
- name: Configure Grimmory LXC devices - name: Configure Grimmory LXC devices
hosts: cloud-pc hosts: cloud-pc
gather_facts: false gather_facts: false
pre_tasks:
- name: Skip legacy Grimmory provisioning for runtime-only calls
ansible.builtin.meta: end_play
when: not (pve_provisioning_enabled | default(true) | bool)
vars: vars:
grimmory_vmid: 149 grimmory_vmid: 149
handlers: handlers:
@@ -119,13 +131,32 @@
ansible_become: false ansible_become: false
- name: Configure Grimmory runtime - name: Configure Grimmory runtime
hosts: grimmory # Таргет переопределяем ради blue-green переезда на OpenTofu: во время
# миграции этот play нужно прогнать против нового контейнера на временном
# адресе. Просто `--limit grimmory-new` для этого НЕ годится — лимит
# пересекается с паттерном play и даёт ноль хостов, а не перенацеливание
# (проверено 2026-09-02 через --list-hosts на всех плейбуках).
# Использовать вместе с --limit, чтобы play создания контейнера отсеялся:
# ansible-playbook playbooks/pve-grimmory.yml \
# -e pve_config_target=grimmory-new --limit grimmory-new
# По умолчанию поведение не меняется.
hosts: "{{ pve_config_target | default('grimmory') }}"
gather_facts: true gather_facts: true
vars: vars:
ansible_become: false ansible_become: false
grimmory_root: /opt/grimmory grimmory_root: /opt/grimmory
grimmory_image: grimmory/grimmory:v3.2.4@sha256:dfa7afdfcf25d649fd664497a62385dd00cd9678c37546e182c172e41c8e80cb grimmory_image: grimmory/grimmory:v3.2.4@sha256:dfa7afdfcf25d649fd664497a62385dd00cd9678c37546e182c172e41c8e80cb
grimmory_mariadb_image: lscr.io/linuxserver/mariadb:11.4.8@sha256:91de7f701bc7fc3a424b81beafca7a7c6c4c5b7c8be6afd2ae148698695c0b0c grimmory_mariadb_image: lscr.io/linuxserver/mariadb:11.4.8@sha256:91de7f701bc7fc3a424b81beafca7a7c6c4c5b7c8be6afd2ae148698695c0b0c
# Адрес, на который биндится порт, на который смотрят правила DOCKER-USER и
# по которому проверяется health. Раньше во всех трёх местах был зашит
# боевой 192.168.1.34, из-за чего play нельзя было прогнать против другого
# контейнера: Docker не биндит чужой адрес и роняет grimmory.service, а
# health-check уходил по сети в БОЕВОЙ сервис и давал ложный успех.
# expected_lan_ip — уже принятая в репозитории идиома для "адрес этого
# хоста в LAN" (её же использует roles/uptime_kuma). На боевом grimmory она
# равна 192.168.1.34, поэтому рендер там не меняется. Найдено 2026-09-02
# при blue-green переезде на OpenTofu.
grimmory_bind_ip: "{{ expected_lan_ip }}"
tasks: tasks:
- name: Install Grimmory runtime packages - name: Install Grimmory runtime packages
ansible.builtin.apt: ansible.builtin.apt:
@@ -298,7 +329,7 @@
mariadb: mariadb:
condition: service_healthy condition: service_healthy
ports: ports:
- "192.168.1.34:6060:6060" - "{{ grimmory_bind_ip }}:6060:6060"
volumes: volumes:
- ./data:/app/data - ./data:/app/data
- ./books:/books - ./books:/books
@@ -338,9 +369,9 @@
iptables -N GRIMMORY-FILTER 2>/dev/null || true iptables -N GRIMMORY-FILTER 2>/dev/null || true
iptables -F GRIMMORY-FILTER iptables -F GRIMMORY-FILTER
iptables -A GRIMMORY-FILTER -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT iptables -A GRIMMORY-FILTER -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
iptables -A GRIMMORY-FILTER -s {{ homelab_lan_cidr }} -p tcp -m conntrack --ctorigdst 192.168.1.34 --ctorigdstport 6060 -j ACCEPT iptables -A GRIMMORY-FILTER -s {{ homelab_lan_cidr }} -p tcp -m conntrack --ctorigdst {{ grimmory_bind_ip }} --ctorigdstport 6060 -j ACCEPT
iptables -A GRIMMORY-FILTER -s {{ openvpn_network_cidr }} -p tcp -m conntrack --ctorigdst 192.168.1.34 --ctorigdstport 6060 -j ACCEPT iptables -A GRIMMORY-FILTER -s {{ openvpn_network_cidr }} -p tcp -m conntrack --ctorigdst {{ grimmory_bind_ip }} --ctorigdstport 6060 -j ACCEPT
iptables -A GRIMMORY-FILTER -p tcp -m conntrack --ctorigdst 192.168.1.34 --ctorigdstport 6060 -j DROP iptables -A GRIMMORY-FILTER -p tcp -m conntrack --ctorigdst {{ grimmory_bind_ip }} --ctorigdstport 6060 -j DROP
iptables -A GRIMMORY-FILTER -j RETURN iptables -A GRIMMORY-FILTER -j RETURN
iptables -C DOCKER-USER -j GRIMMORY-FILTER 2>/dev/null || iptables -I DOCKER-USER 1 -j GRIMMORY-FILTER iptables -C DOCKER-USER -j GRIMMORY-FILTER 2>/dev/null || iptables -I DOCKER-USER 1 -j GRIMMORY-FILTER
register: grimmory_firewall_script register: grimmory_firewall_script
@@ -417,7 +448,7 @@
- name: Wait for Grimmory health endpoint - name: Wait for Grimmory health endpoint
ansible.builtin.uri: ansible.builtin.uri:
url: http://192.168.1.34:6060/api/v1/healthcheck url: http://{{ grimmory_bind_ip }}:6060/api/v1/healthcheck
status_code: 200 status_code: 200
register: grimmory_health register: grimmory_health
retries: 120 retries: 120
+16
View File
@@ -2,6 +2,12 @@
- name: Guard Gyro VMID before API updates - name: Guard Gyro VMID before API updates
hosts: mini-pc hosts: mini-pc
gather_facts: false gather_facts: false
vars:
pve_gyro_legacy_provisioning_enabled: "{{ homelab_services['gyro'].provisioner != 'tofu' }}"
pre_tasks:
- name: Skip legacy Gyro provisioning after cutover
ansible.builtin.meta: end_play
when: not (pve_gyro_legacy_provisioning_enabled | bool)
tasks: tasks:
- name: Read existing VMID 150 configuration - name: Read existing VMID 150 configuration
ansible.builtin.command: pct config 150 ansible.builtin.command: pct config 150
@@ -29,6 +35,7 @@
become: false become: false
gather_facts: false gather_facts: false
vars: vars:
pve_gyro_legacy_provisioning_enabled: "{{ homelab_services['gyro'].provisioner != 'tofu' }}"
ansible_become: false ansible_become: false
ansible_python_interpreter: "{{ ansible_playbook_python }}" ansible_python_interpreter: "{{ ansible_playbook_python }}"
pve_lxc_vmid: 150 pve_lxc_vmid: 150
@@ -45,6 +52,10 @@
pve_lxc_update: false pve_lxc_update: false
pve_lxc_features: [] pve_lxc_features: []
pve_lxc_ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst pve_lxc_ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
pre_tasks:
- name: Skip legacy Gyro provisioning after cutover
ansible.builtin.meta: end_play
when: not (pve_gyro_legacy_provisioning_enabled | bool)
roles: roles:
- role: pve_lxc - role: pve_lxc
@@ -52,7 +63,12 @@
hosts: mini-pc hosts: mini-pc
gather_facts: false gather_facts: false
vars: vars:
pve_gyro_legacy_provisioning_enabled: "{{ homelab_services['gyro'].provisioner != 'tofu' }}"
gyro_vmid: 150 gyro_vmid: 150
pre_tasks:
- name: Skip legacy Gyro provisioning after cutover
ansible.builtin.meta: end_play
when: not (pve_gyro_legacy_provisioning_enabled | bool)
tasks: tasks:
- name: Read Gyro LXC configuration - name: Read Gyro LXC configuration
ansible.builtin.command: "pct config {{ gyro_vmid }}" ansible.builtin.command: "pct config {{ gyro_vmid }}"
-305
View File
@@ -1,305 +0,0 @@
---
- name: Create memoir-bot LXC on mini-pc
hosts: mini-pc
gather_facts: false
vars:
memoir_bot_vmid: 142
memoir_bot_hostname: memoir-bot
memoir_bot_ip: 192.168.1.26/24
memoir_bot_gateway: 192.168.1.1
memoir_bot_bridge: vmbr0
memoir_bot_rootfs: local-lvm:16
memoir_bot_ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
memoir_bot_pubkey_file: ~/.ssh/id_ed25519_homelab.pub
handlers:
- name: restart memoir-bot lxc
ansible.builtin.shell: "pct stop {{ memoir_bot_vmid }} || true; pct start {{ memoir_bot_vmid }}"
changed_when: true
tasks:
- name: Check if memoir-bot LXC exists
ansible.builtin.command: "pct config {{ memoir_bot_vmid }}"
register: memoir_bot_pct_config
changed_when: false
failed_when: false
- name: Install memoir-bot LXC SSH public key on PVE host
ansible.builtin.copy:
dest: /tmp/memoir-bot-lxc.pub
owner: root
group: root
mode: "0600"
content: "{{ lookup('file', memoir_bot_pubkey_file) }}\n"
when: memoir_bot_pct_config.rc != 0
- name: Create memoir-bot LXC
ansible.builtin.command: >-
pct create {{ memoir_bot_vmid }} {{ memoir_bot_ostemplate }}
--hostname {{ memoir_bot_hostname }}
--rootfs {{ memoir_bot_rootfs }}
--cores 1
--memory 512
--swap 512
--net0 name=eth0,bridge={{ memoir_bot_bridge }},gw={{ memoir_bot_gateway }},ip={{ memoir_bot_ip }},firewall=1
--nameserver 1.1.1.1
--unprivileged 1
--features nesting=1,keyctl=1
--onboot 1
--startup order=60
--cmode shell
--ssh-public-keys /tmp/memoir-bot-lxc.pub
when: memoir_bot_pct_config.rc != 0
- name: Start memoir-bot LXC
ansible.builtin.command: "pct start {{ memoir_bot_vmid }}"
register: memoir_bot_pct_start
changed_when: memoir_bot_pct_start.rc == 0
failed_when: memoir_bot_pct_start.rc not in [0, 255]
- name: Allow FUSE device in memoir-bot LXC config
ansible.builtin.lineinfile:
path: "/etc/pve/lxc/{{ memoir_bot_vmid }}.conf"
line: "lxc.cgroup2.devices.allow: c 10:229 rwm"
state: present
notify: restart memoir-bot lxc
- name: Bind mount FUSE device in memoir-bot LXC config
ansible.builtin.lineinfile:
path: "/etc/pve/lxc/{{ memoir_bot_vmid }}.conf"
line: "lxc.mount.entry: /dev/fuse dev/fuse none bind,create=file"
state: present
notify: restart memoir-bot lxc
- name: Apply pending LXC config changes
ansible.builtin.meta: flush_handlers
- name: Wait for memoir-bot SSH through ru-vps
ansible.builtin.wait_for_connection:
timeout: 120
delegate_to: memoir-bot
vars:
ansible_become: false
- name: Configure Docker and memoir-bot inside LXC
hosts: memoir-bot
gather_facts: true
vars:
ansible_become: false
memoir_bot_source_dir: /home/ada/Documents/Projects/Other/memoir_bot/
memoir_bot_app_dir: /opt/memoir-bot/app
memoir_bot_state_dir: /srv/memoir-bot
memoir_bot_env_file: /srv/memoir-bot/.env
memoir_bot_env_source: /home/ada/Documents/Projects/Other/memoir_bot/.env
memoir_bot_ssh_private_key_file: ~/.ssh/id_ed25519
memoir_bot_ssh_public_key_file: ~/.ssh/id_ed25519.pub
memoir_bot_vault_repo: git@github.com:ada-dmitry/SecondBrain.git
memoir_bot_vault_dir: /srv/memoir-bot/vault
memoir_bot_image: memoir-bot:local
memoir_bot_container_name: memoir-bot
tasks:
- name: Install Docker and deploy dependencies
ansible.builtin.apt:
name:
- docker.io
- fuse-overlayfs
- git
- openssh-client
- rsync
- ca-certificates
- curl
state: present
update_cache: true
- name: Ensure Docker config directory exists
ansible.builtin.file:
path: /etc/docker
state: directory
owner: root
group: root
mode: "0755"
- name: Configure Docker storage driver for unprivileged LXC
ansible.builtin.copy:
dest: /etc/docker/daemon.json
owner: root
group: root
mode: "0644"
content: |
{
"storage-driver": "fuse-overlayfs"
}
register: docker_daemon_config
- name: Ensure Docker service is enabled and running
ansible.builtin.systemd:
name: docker
state: "{{ 'restarted' if docker_daemon_config.changed else 'started' }}"
enabled: true
- name: Ensure memoir-bot directories exist
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: root
group: root
mode: "0750"
loop:
- "{{ memoir_bot_app_dir }}"
- "{{ memoir_bot_state_dir }}"
- "{{ memoir_bot_state_dir }}/ssh"
- name: Copy memoir-bot env example
ansible.builtin.copy:
dest: "{{ memoir_bot_state_dir }}/.env.example"
owner: root
group: root
mode: "0640"
content: |
BOT_TOKEN=123456:telegram-token
ALLOWED_USER_IDS=123456789
OBSIDIAN_VAULT_PATH=/srv/obsidian/vault
DAILY_NOTES_DIR=03 Journal
NOTE_PATH_FORMAT=%Y/%m/%d.%m.%y.md
MESSAGE_TIME_FORMAT=%H:%M
TELEGRAM_PROXY=
REMINDER_MIN_HOURS=2
REMINDER_MAX_HOURS=3
REMINDER_START_HOUR=7
REMINDER_END_HOUR=23
REMINDER_TIMEZONE=Europe/Moscow
- name: Copy memoir-bot env file when present locally
ansible.builtin.copy:
src: "{{ memoir_bot_env_source }}"
dest: "{{ memoir_bot_env_file }}"
owner: root
group: root
mode: "0600"
when: lookup('ansible.builtin.fileglob', memoir_bot_env_source) | length > 0
no_log: true
- name: Install GitHub SSH private key for memoir-bot
ansible.builtin.copy:
src: "{{ memoir_bot_ssh_private_key_file }}"
dest: "{{ memoir_bot_state_dir }}/ssh/id_ed25519"
owner: root
group: root
mode: "0600"
no_log: true
- name: Install GitHub SSH public key for memoir-bot when present locally
ansible.builtin.copy:
src: "{{ memoir_bot_ssh_public_key_file }}"
dest: "{{ memoir_bot_state_dir }}/ssh/id_ed25519.pub"
owner: root
group: root
mode: "0644"
when: lookup('ansible.builtin.fileglob', memoir_bot_ssh_public_key_file) | length > 0
- name: Scan GitHub SSH host key
ansible.builtin.command: ssh-keyscan github.com
register: memoir_bot_github_host_key
changed_when: false
- name: Trust GitHub SSH host key for memoir-bot
ansible.builtin.known_hosts:
path: "{{ memoir_bot_state_dir }}/ssh/known_hosts"
name: github.com
key: "{{ memoir_bot_github_host_key.stdout }}"
state: present
- name: Clone SecondBrain vault
ansible.builtin.git:
repo: "{{ memoir_bot_vault_repo }}"
dest: "{{ memoir_bot_vault_dir }}"
key_file: "{{ memoir_bot_state_dir }}/ssh/id_ed25519"
accept_hostkey: true
update: true
version: main
register: memoir_bot_vault_checkout
- name: Configure memoir-bot vault Git author name
ansible.builtin.command: git config user.name memoir-bot
args:
chdir: "{{ memoir_bot_vault_dir }}"
changed_when: false
- name: Configure memoir-bot vault Git author email
ansible.builtin.command: git config user.email memoir-bot@homelab.local
args:
chdir: "{{ memoir_bot_vault_dir }}"
changed_when: false
- name: Sync memoir-bot source code
ansible.posix.synchronize:
src: "{{ memoir_bot_source_dir }}"
dest: "{{ memoir_bot_app_dir }}/"
delete: true
rsync_opts:
- "--exclude=.env"
- "--exclude=.git"
- "--exclude=.venv"
- "--exclude=__pycache__"
- "--exclude=*.pyc"
register: memoir_bot_source_sync
- name: Check if memoir-bot image exists
ansible.builtin.command: "docker image inspect {{ memoir_bot_image }}"
register: memoir_bot_image_inspect
changed_when: false
failed_when: false
- name: Build memoir-bot image
ansible.builtin.command: "docker build -t {{ memoir_bot_image }} {{ memoir_bot_app_dir }}"
when: memoir_bot_source_sync.changed or memoir_bot_image_inspect.rc != 0
register: memoir_bot_image_build
changed_when: memoir_bot_image_build.rc == 0
- name: Install memoir-bot systemd unit
ansible.builtin.copy:
dest: /etc/systemd/system/memoir-bot.service
owner: root
group: root
mode: "0644"
content: |
[Unit]
Description=Memoir Telegram bot container
After=docker.service
Requires=docker.service
ConditionPathExists={{ memoir_bot_env_file }}
[Service]
Restart=always
RestartSec=10
ExecStartPre=-/usr/bin/docker rm -f {{ memoir_bot_container_name }}
ExecStart=/usr/bin/docker run --rm \
--name {{ memoir_bot_container_name }} \
--env-file {{ memoir_bot_env_file }} \
-v {{ memoir_bot_state_dir }}/vault:/srv/obsidian/vault \
-v {{ memoir_bot_state_dir }}/ssh:/root/.ssh:ro \
{{ memoir_bot_image }}
ExecStop=/usr/bin/docker stop {{ memoir_bot_container_name }}
[Install]
WantedBy=multi-user.target
register: memoir_bot_unit
- name: Reload systemd when memoir-bot unit changes
ansible.builtin.systemd:
daemon_reload: true
when: memoir_bot_unit.changed
- name: Check memoir-bot env file
ansible.builtin.stat:
path: "{{ memoir_bot_env_file }}"
register: memoir_bot_env
- name: Enable memoir-bot service
ansible.builtin.systemd:
name: memoir-bot
enabled: true
- name: Start memoir-bot when env file exists
ansible.builtin.systemd:
name: memoir-bot
state: "{{ 'restarted' if memoir_bot_source_sync.changed or memoir_bot_image_build.changed or memoir_bot_unit.changed or memoir_bot_vault_checkout.changed else 'started' }}"
when: memoir_bot_env.stat.exists
+21 -1
View File
@@ -3,6 +3,13 @@
hosts: mini-pc hosts: mini-pc
gather_facts: false gather_facts: false
vars: vars:
# После переезда на OpenTofu (tofu/svc-mihomo.tf, VMID 159) этот play —
# legacy: он таргетит СТАРЫЙ VMID 143 и его handler сделал бы pct start 143,
# подняв остановленный откат на боевом адресе. Пропускаем, пока реестр
# говорит provisioner: tofu. Для намеренного legacy rollback:
# -e pve_mihomo_legacy_provisioning_enabled=true
pve_mihomo_legacy_provisioning_enabled: >-
{{ homelab_services['mihomo'].provisioner != 'tofu' }}
mihomo_vmid: 143 mihomo_vmid: 143
mihomo_hostname: mihomo mihomo_hostname: mihomo
mihomo_ip: 192.168.1.27/24 mihomo_ip: 192.168.1.27/24
@@ -15,6 +22,10 @@
- name: restart mihomo lxc - name: restart mihomo lxc
ansible.builtin.shell: "pct stop {{ mihomo_vmid }} || true; pct start {{ mihomo_vmid }}" ansible.builtin.shell: "pct stop {{ mihomo_vmid }} || true; pct start {{ mihomo_vmid }}"
changed_when: true changed_when: true
pre_tasks:
- name: Skip legacy mihomo provisioning after cutover
ansible.builtin.meta: end_play
when: not (pve_mihomo_legacy_provisioning_enabled | bool)
tasks: tasks:
- name: Check if mihomo LXC exists - name: Check if mihomo LXC exists
ansible.builtin.command: "pct config {{ mihomo_vmid }}" ansible.builtin.command: "pct config {{ mihomo_vmid }}"
@@ -107,7 +118,16 @@
ansible_become: false ansible_become: false
- name: Prepare mihomo runtime host - name: Prepare mihomo runtime host
hosts: mihomo # Таргет переопределяем ради blue-green переезда на OpenTofu: во время
# миграции этот play нужно прогнать против нового контейнера на временном
# адресе. Просто `--limit mihomo-new` для этого НЕ годится — лимит
# пересекается с паттерном play и даёт ноль хостов, а не перенацеливание
# (проверено 2026-09-02 через --list-hosts на всех плейбуках).
# Использовать вместе с --limit, чтобы play создания контейнера отсеялся:
# ansible-playbook playbooks/pve-mihomo.yml \
# -e pve_config_target=mihomo-new --limit mihomo-new
# По умолчанию поведение не меняется.
hosts: "{{ pve_config_target | default('mihomo') }}"
gather_facts: true gather_facts: true
vars: vars:
ansible_become: false ansible_become: false
+21
View File
@@ -1,4 +1,12 @@
--- ---
# После переезда на OpenTofu (tofu/svc-monitoring.tf, VMID 155) обе play здесь —
# legacy: они таргетят СТАРЫЙ VMID 146 на боевом адресе 192.168.1.30, а вторая
# делает pct reboot 146. Пропускаем, пока реестр говорит provisioner: tofu.
# Конфигурация (Uptime Kuma) в этот плейбук не входит — она в
# playbooks/uptime-kuma.yml. Замороженный Prometheus-стек в новый контейнер не
# разворачивался. Для намеренного legacy rollback:
# -e pve_monitoring_legacy_provisioning_enabled=true
- name: Create monitoring LXC on cloud-pc - name: Create monitoring LXC on cloud-pc
hosts: localhost hosts: localhost
connection: local connection: local
@@ -7,6 +15,8 @@
vars: vars:
ansible_become: false ansible_become: false
ansible_python_interpreter: "{{ ansible_playbook_python }}" ansible_python_interpreter: "{{ ansible_playbook_python }}"
pve_monitoring_legacy_provisioning_enabled: >-
{{ homelab_services['monitoring'].provisioner != 'tofu' }}
pve_lxc_vmid: 146 pve_lxc_vmid: 146
pve_lxc_node: cloud-pc pve_lxc_node: cloud-pc
pve_lxc_hostname: monitoring pve_lxc_hostname: monitoring
@@ -20,12 +30,23 @@
pve_lxc_features: pve_lxc_features:
- nesting=1 - nesting=1
pve_lxc_ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst pve_lxc_ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
pre_tasks:
- name: Skip legacy monitoring provisioning after cutover
ansible.builtin.meta: end_play
when: not (pve_monitoring_legacy_provisioning_enabled | bool)
roles: roles:
- role: pve_lxc - role: pve_lxc
- name: Enable Docker keyctl feature for monitoring LXC - name: Enable Docker keyctl feature for monitoring LXC
hosts: cloud-pc hosts: cloud-pc
gather_facts: false gather_facts: false
vars:
pve_monitoring_legacy_provisioning_enabled: >-
{{ homelab_services['monitoring'].provisioner != 'tofu' }}
pre_tasks:
- name: Skip legacy monitoring provisioning after cutover
ansible.builtin.meta: end_play
when: not (pve_monitoring_legacy_provisioning_enabled | bool)
tasks: tasks:
- name: Read monitoring LXC configuration - name: Read monitoring LXC configuration
ansible.builtin.command: pct config 146 ansible.builtin.command: pct config 146
+23
View File
@@ -1,7 +1,23 @@
---
# После переезда на OpenTofu (tofu/svc-ovpn-mini.tf, VMID 160) обе play здесь —
# legacy: они таргетят СТАРЫЙ VMID 132, а их handlers сделали бы pct start 132,
# подняв остановленный откат на боевом адресе 192.168.1.23. Пропускаем, пока
# реестр говорит provisioner: tofu. Конфигурация OpenVPN-шлюза в этот плейбук
# никогда не входила — она в playbooks/openvpn-vps-mini.yml (роль
# openvpn_gateway, группа vpn_openvpn). Для намеренного legacy rollback:
# -e pve_ovpn_mini_legacy_provisioning_enabled=true
- name: Create ovpn-mini LXC on mini-pc - name: Create ovpn-mini LXC on mini-pc
hosts: localhost hosts: localhost
connection: local connection: local
gather_facts: false gather_facts: false
vars:
pve_ovpn_mini_legacy_provisioning_enabled: >-
{{ homelab_services['ovpn-mini'].provisioner != 'tofu' }}
pre_tasks:
- name: Skip legacy ovpn-mini provisioning after cutover
ansible.builtin.meta: end_play
when: not (pve_ovpn_mini_legacy_provisioning_enabled | bool)
roles: roles:
- role: pve_lxc - role: pve_lxc
vars: vars:
@@ -18,10 +34,17 @@
hosts: mini-pc hosts: mini-pc
gather_facts: false gather_facts: false
become: true become: true
vars:
pve_ovpn_mini_legacy_provisioning_enabled: >-
{{ homelab_services['ovpn-mini'].provisioner != 'tofu' }}
handlers: handlers:
- name: restart ovpn-mini lxc - name: restart ovpn-mini lxc
ansible.builtin.shell: pct stop 132 || true; pct start 132 ansible.builtin.shell: pct stop 132 || true; pct start 132
changed_when: true changed_when: true
pre_tasks:
- name: Skip legacy ovpn-mini provisioning after cutover
ansible.builtin.meta: end_play
when: not (pve_ovpn_mini_legacy_provisioning_enabled | bool)
tasks: tasks:
- name: Allow /dev/net/tun device - name: Allow /dev/net/tun device
ansible.builtin.lineinfile: ansible.builtin.lineinfile:
+70
View File
@@ -0,0 +1,70 @@
---
- name: Remove storage-level PBS prune policy from pbs storage
hosts: mini-pc
gather_facts: false
tasks:
- name: Read current PBS storage configuration
ansible.builtin.command:
argv:
- pvesh
- get
- /storage/pbs
- --output-format
- json
register: pbs_storage_current_raw
changed_when: false
no_log: true
- name: Parse current PBS storage configuration
ansible.builtin.set_fact:
pbs_storage_current: "{{ pbs_storage_current_raw.stdout | from_json }}"
no_log: true
- name: Assert current PBS storage is safe to modify
ansible.builtin.assert:
that:
- pbs_storage_current.storage | default('') == 'pbs'
- pbs_storage_current.type | default('') == 'pbs'
- pbs_storage_current.digest | default('') | length > 0
fail_msg: Refusing to modify /storage/pbs because the returned storage metadata is unexpected or missing a digest.
no_log: true
- name: Remove storage-level prune policy from PBS storage
ansible.builtin.command:
argv:
- pvesh
- set
- /storage/pbs
- --delete
- prune-backups
- --digest
- "{{ pbs_storage_current.digest }}"
when: pbs_storage_current.get('prune-backups') is not none
changed_when: true
no_log: true
- name: Read back PBS storage configuration
ansible.builtin.command:
argv:
- pvesh
- get
- /storage/pbs
- --output-format
- json
register: pbs_storage_after_raw
changed_when: false
no_log: true
- name: Parse read-back PBS storage configuration
ansible.builtin.set_fact:
pbs_storage_after: "{{ pbs_storage_after_raw.stdout | from_json }}"
no_log: true
- name: Assert storage-level prune policy is absent
ansible.builtin.assert:
that:
- pbs_storage_after.storage | default('') == 'pbs'
- pbs_storage_after.type | default('') == 'pbs'
- pbs_storage_after.get('prune-backups') is none
fail_msg: Storage-level prune policy still exists on /storage/pbs after the declarative fix.
no_log: true
+18 -1
View File
@@ -2,6 +2,10 @@
- name: Guard Vaultwarden VMID before API updates - name: Guard Vaultwarden VMID before API updates
hosts: mini-pc hosts: mini-pc
gather_facts: false gather_facts: false
pre_tasks:
- name: Skip legacy Vaultwarden provisioning for runtime-only calls
ansible.builtin.meta: end_play
when: not (pve_provisioning_enabled | default(true) | bool)
tasks: tasks:
- name: Read existing VMID 140 configuration - name: Read existing VMID 140 configuration
ansible.builtin.command: pct config 140 ansible.builtin.command: pct config 140
@@ -25,6 +29,10 @@
- name: Create Vaultwarden LXC on mini-pc - name: Create Vaultwarden LXC on mini-pc
hosts: mini-pc hosts: mini-pc
gather_facts: false gather_facts: false
pre_tasks:
- name: Skip legacy Vaultwarden provisioning for runtime-only calls
ansible.builtin.meta: end_play
when: not (pve_provisioning_enabled | default(true) | bool)
vars: vars:
vaultwarden_vmid: 140 vaultwarden_vmid: 140
vaultwarden_hostname: vaultwarden vaultwarden_hostname: vaultwarden
@@ -103,7 +111,16 @@
ansible_become: false ansible_become: false
- name: Configure Docker and Vaultwarden inside LXC - name: Configure Docker and Vaultwarden inside LXC
hosts: vaultwarden # Таргет переопределяем ради blue-green переезда на OpenTofu: во время
# миграции этот play нужно прогнать против нового контейнера на временном
# адресе. Просто `--limit vaultwarden-new` для этого НЕ годится — лимит
# пересекается с паттерном play и даёт ноль хостов, а не перенацеливание
# (проверено 2026-09-02 через --list-hosts на всех плейбуках).
# Использовать вместе с --limit, чтобы play создания контейнера отсеялся:
# ansible-playbook playbooks/pve-vaultwarden.yml \
# -e pve_config_target=vaultwarden-new --limit vaultwarden-new
# По умолчанию поведение не меняется.
hosts: "{{ pve_config_target | default('vaultwarden') }}"
gather_facts: true gather_facts: true
vars: vars:
ansible_become: false ansible_become: false
+137
View File
@@ -0,0 +1,137 @@
---
# Базовое состояние публичной VPS и стек Caddy.
#
# ОБЛАСТЬ ОТВЕТСТВЕННОСТИ
# Этот плейбук ПРИНИМАЕТ существующий ru-vps под управление Ansible, а не
# разворачивает его с нуля. Docker и compose-плагин уже установлены вручную
# (Docker CE 29.x из upstream-репозитория, не Ubuntu'шный docker.io), поэтому
# плейбук их проверяет, но не ставит: `apt install docker.io` поверх Docker CE
# на боевой VPS сломал бы все стеки сразу.
#
# Управляется здесь ТОЛЬКО стек Caddy. Остальные каталоги в
# /opt/services/ru-vps (gitea-runner, mihomo, resticprofile, squid, zerotier)
# развёрнуты вручную и осознанно оставлены вне управления — решение по каждому
# принимается отдельно, см. docs/ai/plan.md.
#
# ГРАНИЦА С reverse-proxy.yml
# Содержимое Caddyfile принадлежит playbooks/reverse-proxy.yml. Здесь файл
# только проверяется на наличие: стартовать Caddy без конфига бессмысленно,
# а перезаписать его отсюда — значит потерять все маршруты.
#
# ЧТО МЕНЯЕТСЯ НА ЖИВОМ ХОСТЕ ПРИ ПЕРВОМ ПРОГОНЕ
# 1. Образ caddy:2-alpine закрепляется по digest -> контейнер пересоздаётся.
# 2. Появляется systemd-юнит homelab-caddy.service: до сих пор жизненным
# циклом стека управляла только политика restart=unless-stopped, то есть
# после `docker compose down` его никто не поднимал.
# Это кратковременный перерыв в публичном HTTPS. Прогонять осознанно.
#
# ЗАМЕЧАНИЕ ПРО --check
# До первого реального прогона `--check` заканчивается ошибкой
# "Could not find the requested service homelab-caddy": в check-режиме юнит
# на диск не пишется, поэтому systemd его не видит. Это артефакт проверки,
# а не дефект плейбука. Diff'ы задач выше при этом достоверны.
- name: Adopt the ru-vps Caddy stack into Ansible
hosts: ru-vps
gather_facts: true
# roles: выполняются РАНЬШЕ tasks:, поэтому все проверки идут в pre_tasks —
# иначе стек разложился бы до проверки Caddyfile.
pre_tasks:
- name: Verify the container runtime is present
vars:
ru_vps_docker_binary: /usr/bin/docker
block:
- name: Check the Docker binary
ansible.builtin.command: "{{ ru_vps_docker_binary }} version --format '{{ '{{' }}.Server.Version{{ '}}' }}'"
register: ru_vps_docker_version
changed_when: false
# Read-only проверка, поэтому выполняется и в --check.
check_mode: false
- name: Check the Compose plugin
ansible.builtin.command: "{{ ru_vps_docker_binary }} compose version --short"
register: ru_vps_compose_version
changed_when: false
# Read-only проверка, поэтому выполняется и в --check.
check_mode: false
- name: Report the detected runtime
ansible.builtin.debug:
msg: >-
docker={{ ru_vps_docker_version.stdout | trim }}
compose={{ ru_vps_compose_version.stdout | trim }}
# --- Арбитр кластера ---------------------------------------------------
# corosync-qnetd на ru-vps даёт двухнодовому кластеру третий голос.
# Ноды подключаются к нему по публичному адресу (см. corosync.conf,
# host: 157.22.231.198), а старое правило пускало 5403 только из
# 10.122.62.0/24 — сети ZeroTier, выведенной в июле. После этого qdevice
# молча перестал голосовать.
- name: Allow corosync-qnetd from the PVE nodes
community.general.ufw:
rule: allow
port: "5403"
proto: tcp
src: "{{ homelab_pve_egress_ip }}"
comment: corosync-qnetd for the HomeLab PVE cluster
- name: Drop the qnetd rule for the retired ZeroTier network
community.general.ufw:
rule: allow
port: "5403"
proto: tcp
src: 10.122.62.0/24
delete: true
# Caddyfile принадлежит reverse-proxy.yml. Без него стек стартует пустым,
# поэтому сначала проверяем наличие и только потом раскладываем стек.
- name: Check the Caddyfile managed by reverse-proxy.yml
ansible.builtin.stat:
path: "{{ homelab_reverse_proxy_caddyfile }}"
register: ru_vps_caddyfile
- name: Require the Caddyfile before touching the stack
ansible.builtin.assert:
that:
- ru_vps_caddyfile.stat.exists
- ru_vps_caddyfile.stat.size > 0
fail_msg: >-
{{ homelab_reverse_proxy_caddyfile }} отсутствует или пуст.
Сначала прогони playbooks/reverse-proxy.yml, иначе Caddy стартует
без маршрутов и публичные сервисы лягут.
roles:
- role: compose_service
compose_service_name: "{{ homelab_reverse_proxy_unit }}"
compose_service_description: Caddy reverse proxy for public HomeLab services
compose_service_root: "{{ homelab_reverse_proxy_dir }}"
# Каталог создан вручную с 0755 — сохраняем, чтобы прогон не давал
# косметического diff'а на боевом хосте.
compose_service_root_mode: "0755"
# Имя файла сохраняем: имя compose-проекта выводится из каталога, а сам
# файл должен остаться тем же, иначе стек станет orphan и поднимется
# второй контейнер на тех же 80/443.
compose_service_compose_file: docker-compose.yml
# /opt/data/caddy и /opt/configs/caddy принадлежат ada:ada и содержат
# выпущенные сертификаты. Роль их владельца не трогает намеренно.
compose_service_directories: []
compose_service_compose_content: |
services:
caddy:
image: {{ homelab_reverse_proxy_image | trim }}
container_name: {{ homelab_reverse_proxy_container }}
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- /opt/data/caddy:/data
- /opt/configs/caddy:/config
networks: {}
# Caddy на голый IP отвечает 308 (редирект http -> https), а не 200.
compose_service_health_url: http://127.0.0.1:80/
compose_service_health_status: [308]
compose_service_health_follow_redirects: none
@@ -0,0 +1,183 @@
---
# Вывод ZeroTier с ru-vps и чистка оставшихся от него правил UFW.
#
# ПОЧЕМУ ЭТО БЕЗОПАСНО
# Проверено 2026-09-02: ZT-интерфейса на хосте нет, маршрутов через него нет,
# на ZT-адресах никто не слушает. Контейнер `zerotier` работает, но подключён
# в никуда (потому и unhealthy). `ssh-zt22.service` в состоянии failed: его
# sshd настроен на ListenAddress 10.122.62.65, которого больше не существует.
#
# Из Ansible ZeroTier выведен ещё в июле 2026 — на хосте остался хвост.
#
# ЧТО НЕ УДАЛЯЕТСЯ
# Каталог /opt/services/ru-vps/zerotier, данные /opt/data/zerotier и файл
# /etc/ssh/sshd_config_zt22 остаются на месте. `docker compose down` идёт БЕЗ
# -v, то есть identity узла ZeroTier сохраняется. Удаление этих файлов —
# отдельный осознанный шаг оператора после того, как всё устоялось.
#
# make zerotier-decommission CONFIRM=1
- name: Decommission ZeroTier on ru-vps
hosts: ru-vps
gather_facts: false
vars:
zt_dir: /opt/services/ru-vps/zerotier
zt_interface: zt6q3dmi2d
zt_legacy_cidr: 10.122.62.0/24
# Правила UFW для сервисов, которые на хосте ничего не слушают (проверено
# через ss): squid не запущен, danted слушает 1081 а не 1080, на 993 и 7892
# слушателей нет. Это НЕ наследие ZeroTier, поэтому по умолчанию не трогаем.
zt_cleanup_unrelated_stale_rules: false
tasks:
# --- Предполётные проверки ---------------------------------------------
- name: List the network interfaces
ansible.builtin.command: ip -br addr show
register: zt_ifaces
changed_when: false
check_mode: false
- name: Read the current firewall rules
ansible.builtin.command: ufw status
register: zt_ufw
changed_when: false
check_mode: false
- name: Refuse to run while a ZeroTier interface is up
ansible.builtin.assert:
that:
- zt_interface not in zt_ifaces.stdout
fail_msg: >-
Интерфейс {{ zt_interface }} присутствует на хосте — значит ZeroTier
снова используется. Плейбук рассчитан на вывод мёртвого стека и
отказывается работать: сначала подтверди, что сеть больше не нужна.
# Управляющая сессия идёт по 3422/tcp. Если глобального разрешения на этот
# порт нет, чистка правил может оборвать доступ — лучше не начинать.
- name: Require the management SSH rule to survive the cleanup
ansible.builtin.assert:
that:
- "'3422/tcp' in zt_ufw.stdout"
fail_msg: >-
В UFW нет правила для 3422/tcp. Прерываюсь, чтобы не остаться без
управляющего доступа.
# --- Контейнер ----------------------------------------------------------
- name: Check whether the ZeroTier stack is present
ansible.builtin.stat:
path: "{{ zt_dir }}/docker-compose.yml"
register: zt_compose
- name: Stop and remove the ZeroTier stack
ansible.builtin.command:
cmd: /usr/bin/docker compose -f {{ zt_dir }}/docker-compose.yml down
chdir: "{{ zt_dir }}"
register: zt_down
when: zt_compose.stat.exists
changed_when: "'Removing' in zt_down.stderr or 'Stopping' in zt_down.stderr"
# --- sshd на ZeroTier ---------------------------------------------------
- name: Disable the ZeroTier-only sshd unit
ansible.builtin.systemd:
name: ssh-zt22.service
state: stopped
enabled: false
failed_when: false
# `stop` не снимает состояние failed, и юнит навсегда остался бы в секции
# FAILED SYSTEMD UNITS отчёта `make status`. homelab-pve-routes.service —
# фантом той же эпохи: файла юнита уже нет, а запись о падении осталась.
- name: List the units currently in a failed state
ansible.builtin.command: systemctl list-units --state=failed --no-legend --plain --no-pager
register: zt_failed
changed_when: false
check_mode: false
- name: Clear the leftover failed state of the ZeroTier-era units
ansible.builtin.command: "systemctl reset-failed {{ item }}"
loop:
- ssh-zt22.service
- homelab-pve-routes.service
# Без этого условия reset-failed рапортует changed на каждом прогоне:
# его код возврата нулевой и когда сбрасывать нечего.
when: item in zt_failed.stdout
register: zt_reset
changed_when: zt_reset.rc == 0
failed_when: false
# --- UFW ----------------------------------------------------------------
- name: Remove the ZeroTier interface rules
community.general.ufw:
rule: allow
direction: "{{ item }}"
interface: "{{ zt_interface }}"
delete: true
loop: [in, out]
- name: Remove the ZeroTier port rules
community.general.ufw:
rule: "{{ item.rule }}"
port: "{{ item.port }}"
proto: "{{ item.proto | default('any') }}"
interface: "{{ item.interface | default(omit) }}"
direction: "{{ 'in' if item.interface is defined else omit }}"
delete: true
loop:
- {rule: allow, port: "9993", proto: udp} # ZeroTier UDP
- {rule: deny, port: "9001"} # ZeroTier controller
- {rule: allow, port: "9001", interface: "zt+"} # он же, но с ZT-сетей
loop_control:
label: "{{ item.rule }} {{ item.port }}{{ '/' ~ item.proto if item.proto is defined else '' }}"
- name: Remove rules scoped to the retired ZeroTier subnet
community.general.ufw:
rule: allow
port: "{{ item }}"
proto: tcp
src: "{{ zt_legacy_cidr }}"
delete: true
loop: ["22", "3422"]
- name: Remove stale rules for services that no longer listen
community.general.ufw:
rule: allow
port: "{{ item.port }}"
proto: tcp
delete: true
loop:
- {port: "3128"} # squid: стек есть, контейнер не запущен
- {port: "1080"} # danted слушает 1081, не 1080
- {port: "993"} # слушателей нет
- {port: "7892"} # слушателей нет
loop_control:
label: "{{ item.port }}/tcp"
when: zt_cleanup_unrelated_stale_rules | bool
# --- Проверка -----------------------------------------------------------
- name: List the running containers after the cleanup
ansible.builtin.command: docker ps --format {{ '{{' }}.Names{{ '}}' }}
register: zt_after_ps
changed_when: false
check_mode: false
- name: Read the firewall after the cleanup
ansible.builtin.command: ufw status
register: zt_after_ufw
changed_when: false
check_mode: false
- name: Confirm ZeroTier is gone and management access survived
ansible.builtin.assert:
that:
- "'zerotier' not in zt_after_ps.stdout_lines"
- zt_interface not in zt_after_ufw.stdout
- "'3422/tcp' in zt_after_ufw.stdout"
success_msg: ZeroTier выведен, правило для 3422/tcp на месте.
# В --check контейнер не останавливается и правила не удаляются, поэтому
# проверка результата заведомо не прошла бы. Diff'ы задач выше достоверны.
when: not ansible_check_mode
- name: Show the resulting firewall
ansible.builtin.debug:
var: zt_after_ufw.stdout_lines
+28 -3
View File
@@ -26,7 +26,6 @@
# grimmory{,-docker-firewall} playbooks/pve-grimmory.yml # grimmory{,-docker-firewall} playbooks/pve-grimmory.yml
# gyro.timer roles/gyro (gyro.service is oneshot -> see jobs) # gyro.timer roles/gyro (gyro.service is oneshot -> see jobs)
# hermes-ai-tun-proxy.service playbooks/pve-hermes-ai.yml # hermes-ai-tun-proxy.service playbooks/pve-hermes-ai.yml
# memoir-bot.service playbooks/pve-memoir-bot.yml
# mihomo{,-ui}.service playbooks/pve-mihomo.yml # mihomo{,-ui}.service playbooks/pve-mihomo.yml
# uptime-kuma.service roles/uptime_kuma # uptime-kuma.service roles/uptime_kuma
# vaultwarden.service playbooks/pve-vaultwarden.yml # vaultwarden.service playbooks/pve-vaultwarden.yml
@@ -39,7 +38,6 @@
grimmory: [docker.service, grimmory.service, grimmory-docker-firewall.service] grimmory: [docker.service, grimmory.service, grimmory-docker-firewall.service]
gyro: [gyro.timer] gyro: [gyro.timer]
hermes-ai: [docker.service, hermes-ai-tun-proxy.service] hermes-ai: [docker.service, hermes-ai-tun-proxy.service]
memoir-bot: [docker.service, memoir-bot.service]
mihomo: [docker.service, mihomo.service, mihomo-ui.service] mihomo: [docker.service, mihomo.service, mihomo-ui.service]
monitoring: [docker.service, uptime-kuma.service] monitoring: [docker.service, uptime-kuma.service]
ru-vps: [docker.service] ru-vps: [docker.service]
@@ -51,8 +49,9 @@
# Yandex Disk over the network, so we only read what systemd already knows. # Yandex Disk over the network, so we only read what systemd already knows.
status_job_units: status_job_units:
cloud-pc: cloud-pc:
- homelab-restic-offsite-gitea.service
- homelab-backup-audit-gitea.service - homelab-backup-audit-gitea.service
gitea:
- homelab-restic-offsite-gitea.service
grimmory: grimmory:
- homelab-restic-offsite-grimmory.service - homelab-restic-offsite-grimmory.service
- homelab-backup-audit-grimmory.service - homelab-backup-audit-grimmory.service
@@ -136,6 +135,18 @@
failed_when: false failed_when: false
when: status_units | length > 0 when: status_units | length > 0
# Двухнодовый кластер держится на арбитре corosync-qnetd (ru-vps:5403).
# Когда арбитр молчит, Total votes < Expected votes и падение ЛЮБОЙ ноды
# оставляет выжившую без кворума. Один раз это уже сломалось молча —
# после вывода ZeroTier правило UFW для 5403 осталось на мёртвой сети.
- name: Read cluster quorum state
ansible.builtin.shell:
cmd: LC_ALL=C pvecm status 2>/dev/null | grep -E 'Quorate:|Expected votes|Total votes' | tr -s ' ' | tr '\n' ' '
register: status_quorum
changed_when: false
failed_when: false
when: inventory_hostname in (groups['pve_nodes'] | default([]))
- name: List failed systemd units - name: List failed systemd units
ansible.builtin.shell: ansible.builtin.shell:
cmd: >- cmd: >-
@@ -232,6 +243,7 @@
if inventory_hostname in (groups['vpn_openvpn'] | default([])) else '-' }} if inventory_hostname in (groups['vpn_openvpn'] | default([])) else '-' }}
vpn_peer_ip: "{{ openvpn_peer_ip | default('-') }}" vpn_peer_ip: "{{ openvpn_peer_ip | default('-') }}"
vpn_probes: "{{ status_vpn_probe.stdout_lines | default([]) }}" vpn_probes: "{{ status_vpn_probe.stdout_lines | default([]) }}"
quorum: "{{ status_quorum.stdout | default('') | trim }}"
- name: Print HomeLab status summary - name: Print HomeLab status summary
@@ -331,6 +343,19 @@
loop: "{{ status_hosts }}" loop: "{{ status_hosts }}"
when: hostvars[item].status_record.vpn_peer != '-' when: hostvars[item].status_record.vpn_peer != '-'
- name: Add cluster quorum section header
ansible.builtin.set_fact:
status_report: >-
{{ status_report + ['', 'CLUSTER QUORUM (qdevice = третий голос)', '-' * 80] }}
- name: Add quorum line per PVE node
ansible.builtin.set_fact:
status_report: >-
{{ status_report + [' ' ~ item ~ ': '
~ (hostvars[item].status_record.quorum | default('') | trim | default('нет данных', true))] }}
loop: "{{ status_hosts }}"
when: hostvars[item].status_record.quorum | default('') | length > 0
- name: Add backup and job section header - name: Add backup and job section header
ansible.builtin.set_fact: ansible.builtin.set_fact:
status_report: >- status_report: >-
@@ -0,0 +1,16 @@
#jinja2: trim_blocks: True, lstrip_blocks: True
# {{ ansible_managed }}
# Статические админ-ссылки (не из реестра). Рендерится playbooks/dashboard.yml.
- Управление:
- Proxmox cloud-pc:
- abbr: PVE
href: https://192.168.1.5:8006
- Proxmox mini-pc:
- abbr: PVE
href: https://192.168.1.10:8006
- Proxmox Backup Server:
- abbr: PBS
href: https://192.168.1.20:8007
- Uptime Kuma:
- abbr: UK
href: http://{{ dash_bind }}:3001
@@ -0,0 +1,21 @@
#jinja2: trim_blocks: True, lstrip_blocks: True
# {{ ansible_managed }}
# Рендерится playbooks/dashboard.yml (роль compose_service). Правь шаблон,
# а не файл на хосте: следующий прогон перезапишет.
services:
homepage:
image: {{ dash_image }}
container_name: homelab-homepage
restart: unless-stopped
env_file:
- ./homepage.env
environment:
HOMEPAGE_ALLOWED_HOSTS: "{{ dash_allowed_hosts | trim }}"
ports:
# Только LAN-адрес CT 155 -> контейнерный 3000. Наружу не публикуется.
- "{{ dash_bind }}:{{ dash_port }}:3000"
volumes:
- ./config:/app/config
# Docker-сокет намеренно НЕ монтируется: сервисы живут на других хостах,
# а лишний доступ к сокету на LAN-видимом контейнере не нужен.
networks: {}
@@ -0,0 +1,70 @@
#jinja2: trim_blocks: True, lstrip_blocks: True
# {{ ansible_managed }}
# Рендерится playbooks/dashboard.yml ИЗ реестра homelab_services. НЕ править
# на хосте: меняй сервисы в ansible/inventory/group_vars/all/services.yml,
# затем `make dashboard`.
#
# Правило ссылки на сервис:
# * есть proxy.domain -> https://<domain> (+ siteMonitor)
# * иначе первый веб-порт -> http(s)://<ip>:<port> (+ siteMonitor для http)
# приоритет имён портов: http > ui > setup > uptime-kuma > grafana >
# controller > pbs-api
# * ни того, ни другого -> группа Headless, только ICMP ping по ip
{% set port_priority = ['http', 'ui', 'setup', 'uptime-kuma', 'grafana', 'controller', 'pbs-api'] %}
{% if homelab_dashboard_proxmox_url | default('') | length > 0 %}
- Infrastructure:
- Proxmox кластер:
description: cloud-pc + mini-pc
widget:
type: proxmox
url: {{ homelab_dashboard_proxmox_url }}
username: "{{ '{{HOMEPAGE_VAR_PROXMOX_USER}}' }}"
password: "{{ '{{HOMEPAGE_VAR_PROXMOX_TOKEN}}' }}"
{% endif %}
{% for node in dashboard_nodes %}
- {{ node }}:
{% for name, svc in homelab_services | dictsort %}
{% if svc.node == node %}
{% set proxy = svc.proxy | default({}) %}
{% set ns = namespace(scheme='', port=0) %}
{% for pref in port_priority %}
{% for p in svc.ports | default([]) %}
{% if ns.port == 0 and p.name == pref %}
{% set ns.port = p.port %}
{% set ns.scheme = 'https' if pref == 'pbs-api' else 'http' %}
{% endif %}
{% endfor %}
{% endfor %}
{% if proxy.domain is defined %}
- {{ name }}:
href: https://{{ proxy.domain }}
description: {{ svc.role }}
siteMonitor: https://{{ proxy.domain }}
{% elif ns.port > 0 %}
- {{ name }}:
href: {{ ns.scheme }}://{{ svc.ip }}:{{ ns.port }}
description: {{ svc.role }}
{% if ns.scheme == 'http' %}
siteMonitor: http://{{ svc.ip }}:{{ ns.port }}
{% endif %}
{% endif %}
{% endif %}
{% endfor %}
{% endfor %}
- Headless:
{% for name, svc in homelab_services | dictsort %}
{% set proxy = svc.proxy | default({}) %}
{% set ns = namespace(web=false) %}
{% for pref in port_priority %}
{% for p in svc.ports | default([]) %}
{% if p.name == pref %}
{% set ns.web = true %}
{% endif %}
{% endfor %}
{% endfor %}
{% if proxy.domain is not defined and not ns.web %}
- {{ name }}:
description: {{ svc.role }}
ping: {{ svc.ip }}
{% endif %}
{% endfor %}
@@ -0,0 +1,21 @@
#jinja2: trim_blocks: True, lstrip_blocks: True
# {{ ansible_managed }}
# Рендерится playbooks/dashboard.yml. Порядок и имена групп ОБЯЗАНЫ совпадать
# с группами в services.yaml — иначе Homepage покажет их в алфавитном порядке.
title: HomeLab
headerStyle: boxed
color: slate
layout:
{% if homelab_dashboard_proxmox_url | default('') | length > 0 %}
Infrastructure:
style: row
columns: 3
{% endif %}
{% for node in dashboard_nodes %}
{{ node }}:
style: row
columns: 4
{% endfor %}
Headless:
style: row
columns: 4
@@ -0,0 +1,18 @@
#jinja2: trim_blocks: True, lstrip_blocks: True
# {{ ansible_managed }}
# Верхняя панель Homepage. Рендерится playbooks/dashboard.yml.
- datetime:
text_size: xl
format:
timeStyle: short
dateStyle: short
- resources:
label: monitoring CT
cpu: true
memory: true
disk: /
- uptimekuma:
# Требует опубликованной Status Page в Uptime Kuma с этим slug
# (мониторы и статус-страницы Kuma живут только в её UI).
url: http://{{ dash_bind }}:3001
slug: {{ homelab_dashboard_kuma_slug | default('homelab') }}
@@ -0,0 +1,12 @@
#jinja2: trim_blocks: True, lstrip_blocks: True
# {{ ansible_managed }}
# Секреты виджетов Homepage. mode 0600, в Git не попадает. Значения берутся
# из корневого .env через lookup('env', ...) в playbooks/dashboard.yml.
# Homepage подставляет их в widgets/services по имени {{ '{{HOMEPAGE_VAR_*}}' }}.
HOMEPAGE_VAR_PROXMOX_URL={{ homelab_dashboard_proxmox_url | default('') }}
{% if dash_pve_user | length > 0 and dash_pve_token_id | length > 0 %}
HOMEPAGE_VAR_PROXMOX_USER={{ dash_pve_user }}!{{ dash_pve_token_id }}
{% else %}
HOMEPAGE_VAR_PROXMOX_USER=
{% endif %}
HOMEPAGE_VAR_PROXMOX_TOKEN={{ dash_pve_token_secret | default('') }}
+8 -1
View File
@@ -1,6 +1,13 @@
--- ---
- name: Freeze Prometheus monitoring and configure Uptime Kuma - name: Freeze Prometheus monitoring and configure Uptime Kuma
hosts: monitoring_server # Таргет переопределяем ради blue-green переезда на OpenTofu: конфигурацию
# нужно прогнать против нового контейнера на временном адресе. `--limit`
# сам по себе не перенацеливает play, а обнуляет его (проверено 2026-09-02).
# Использовать вместе с --limit:
# ansible-playbook playbooks/uptime-kuma.yml \
# -e pve_config_target=monitoring-new --limit monitoring-new
# По умолчанию поведение не меняется.
hosts: "{{ pve_config_target | default('monitoring_server') }}"
gather_facts: false gather_facts: false
roles: roles:
- role: uptime_kuma - role: uptime_kuma
+85
View File
@@ -0,0 +1,85 @@
---
# Дрейф между реестром homelab_services и фактическим состоянием Proxmox.
#
# ЗАЧЕМ
# Реестр — источник правды, но программно его потребляют лишь несколько мест.
# VMID, адрес и ресурсы легко разъезжаются с реальностью, и заметить это
# раньше было нечем: `make status` показывает состояние, но не сверяет его
# с задекларированным, и его exit code намеренно не является gate'ом.
#
# ОТЛИЧИЕ ОТ status.yml
# Этот плейбук — именно gate: при любом расхождении он завершается ошибкой.
# Годится для cron и CI. Строго read-only: только `pct config`.
#
# make validate
# make validate EXTRA="--limit cloud-pc"
- name: Read actual LXC configuration from the Proxmox nodes
hosts: pve_nodes
gather_facts: false
tasks:
- name: Read pct config for the services declared on this node
ansible.builtin.command: "pct config {{ item.value.vmid }}"
loop: >-
{{ homelab_services | dict2items
| selectattr('value.node', 'eq', inventory_hostname) | list }}
loop_control:
label: "{{ item.key }} ({{ item.value.vmid }})"
register: validate_pct
changed_when: false
failed_when: false
check_mode: false
# Сравниваются только однозначные поля. rootfs намеренно пропущен: в реестре
# он записан как "data:32", а pct отдаёт "data:vm-141-disk-0,size=32G" —
# это разные представления, и их сверка требует отдельного парсера.
- name: Collect drift for this node
ansible.builtin.set_fact:
validate_drift: "{{ validate_drift | default([]) + item_drift }}"
loop: "{{ validate_pct.results }}"
loop_control:
label: "{{ item.item.key }}"
vars:
svc: "{{ item.item.value }}"
sname: "{{ item.item.key }}"
gone: "{{ item.rc | default(1) != 0 }}"
out: "{{ item.stdout | default('') }}"
a_host: "{{ out | regex_search('(?m)^hostname: (\\S+)', '\\1') | default([''], true) | first }}"
a_cores: "{{ out | regex_search('(?m)^cores: (\\d+)', '\\1') | default([''], true) | first }}"
a_mem: "{{ out | regex_search('(?m)^memory: (\\d+)', '\\1') | default([''], true) | first }}"
a_swap: "{{ out | regex_search('(?m)^swap: (\\d+)', '\\1') | default([''], true) | first }}"
a_ip: "{{ out | regex_search('ip=([0-9.]+)', '\\1') | default([''], true) | first }}"
item_drift: >-
{{ ([sname ~ ': VMID ' ~ svc.vmid ~ ' отсутствует на узле ' ~ inventory_hostname] if gone else [])
+ ([sname ~ ': hostname=' ~ a_host ~ ', в реестре ' ~ svc.hostname]
if (not gone and a_host != svc.hostname) else [])
+ ([sname ~ ': ip=' ~ a_ip ~ ', в реестре ' ~ svc.ip]
if (not gone and a_ip != svc.ip) else [])
+ ([sname ~ ': cores=' ~ a_cores ~ ', в реестре ' ~ svc.lxc.cores]
if (not gone and svc.lxc.cores is not none and a_cores | string != svc.lxc.cores | string) else [])
+ ([sname ~ ': memory=' ~ a_mem ~ ', в реестре ' ~ svc.lxc.memory]
if (not gone and svc.lxc.memory is not none and a_mem | string != svc.lxc.memory | string) else [])
+ ([sname ~ ': swap=' ~ a_swap ~ ', в реестре ' ~ svc.lxc.swap]
if (not gone and svc.lxc.swap is not none and a_swap | string != svc.lxc.swap | string) else []) }}
- name: Report registry drift
hosts: pve_nodes
gather_facts: false
run_once: true
tasks:
- name: Fail when the registry disagrees with Proxmox
ansible.builtin.assert:
that:
- all_drift | length == 0
success_msg: >-
Реестр совпадает с Proxmox: проверено сервисов —
{{ homelab_services | dict2items
| selectattr('value.node', 'in', groups['pve_nodes']) | list | length }}.
fail_msg: >-
{{ ['Реестр разошёлся с Proxmox:']
+ (all_drift | map('regex_replace', '^', ' - ') | list) }}
vars:
all_drift: >-
{{ groups['pve_nodes']
| map('extract', hostvars, 'validate_drift')
| select('defined') | flatten }}
+11 -7
View File
@@ -24,18 +24,22 @@
- name: Create and verify Vaultwarden PBS backup before update - name: Create and verify Vaultwarden PBS backup before update
hosts: mini-pc hosts: mini-pc
gather_facts: false gather_facts: false
vars:
vaultwarden_vmid: "{{ homelab_services['vaultwarden'].vmid }}"
tasks: tasks:
- name: Read existing VMID 140 configuration - name: Read existing Vaultwarden VMID configuration
ansible.builtin.command: pct config 140 ansible.builtin.command: "pct config {{ vaultwarden_vmid }}"
register: vaultwarden_existing_vmid register: vaultwarden_existing_vmid
changed_when: false changed_when: false
- name: Refuse to modify a foreign VMID 140 - name: Refuse to modify a foreign Vaultwarden VMID
ansible.builtin.assert: ansible.builtin.assert:
that: that:
- vaultwarden_existing_vmid.rc == 0 - vaultwarden_existing_vmid.rc == 0
- vaultwarden_existing_hostname == 'vaultwarden' - vaultwarden_existing_hostname == 'vaultwarden'
fail_msg: VMID 140 already exists and is not the Vaultwarden container. fail_msg: >-
VMID {{ vaultwarden_vmid }} from the service registry is not the
Vaultwarden container.
vars: vars:
vaultwarden_existing_hostname: >- vaultwarden_existing_hostname: >-
{{ vaultwarden_existing_vmid.stdout_lines {{ vaultwarden_existing_vmid.stdout_lines
@@ -62,13 +66,11 @@
ansible.builtin.command: ansible.builtin.command:
argv: argv:
- vzdump - vzdump
- "140" - "{{ vaultwarden_vmid }}"
- --storage - --storage
- pbs - pbs
- --mode - --mode
- snapshot - snapshot
- --prune-backups
- keep-all=1
- --exclude-path - --exclude-path
- /var/lib/docker/fuse-overlayfs/*/merged - /var/lib/docker/fuse-overlayfs/*/merged
@@ -82,6 +84,8 @@
changed_when: true changed_when: true
- import_playbook: pve-vaultwarden.yml - import_playbook: pve-vaultwarden.yml
vars:
pve_provisioning_enabled: false
- name: Verify Vaultwarden public endpoint after update - name: Verify Vaultwarden public endpoint after update
hosts: ru-vps hosts: ru-vps
+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) по-прежнему
дублирует значения и должно меняться согласованно.
+16 -23
View File
@@ -3,29 +3,22 @@ backup_audit_log_file: /var/log/homelab-backup-audit.log
backup_audit_timer_oncalendar: "*-*-* 06:00:00" backup_audit_timer_oncalendar: "*-*-* 06:00:00"
backup_audit_timer_randomized_delay: 10m backup_audit_timer_randomized_delay: 10m
backup_audit_pbs_vmids: # Единый порог свежести для всех PBS-снапшотов. Вынесен отдельно, потому что
- vmid: 132 # сам список VMID теперь выводится из реестра и per-vmid значения в нём нет.
max_age_hours: 48 backup_audit_pbs_max_age_hours: 48
- vmid: 140
max_age_hours: 48 # VMID берутся из homelab_services по флагу monitoring.backup_audit_vmid, а не
- vmid: 141 # перечисляются вручную. Форма записи ({vmid, max_age_hours}) сохранена, чтобы
max_age_hours: 48 # вызывающий playbook при необходимости мог передать свой список с иными
- vmid: 142 # порогами — шаблон audit-pbs.sh.j2 читает именно эти два поля.
max_age_hours: 48 backup_audit_pbs_vmids: >-
- vmid: 143 {{ homelab_services.values()
max_age_hours: 48 | selectattr('monitoring.backup_audit_vmid', 'defined')
- vmid: 144 | selectattr('monitoring.backup_audit_vmid')
max_age_hours: 48 | map(attribute='vmid') | sort
- vmid: 145 | map('community.general.dict_kv', 'vmid')
max_age_hours: 48 | map('combine', {'max_age_hours': backup_audit_pbs_max_age_hours})
- vmid: 146 | list }}
max_age_hours: 48
- vmid: 147
max_age_hours: 48
- vmid: 149
max_age_hours: 48
- vmid: 150
max_age_hours: 48
backup_audit_restic_profiles: [] backup_audit_restic_profiles: []
backup_audit_metrics_dir: /var/lib/node_exporter/textfile_collector backup_audit_metrics_dir: /var/lib/node_exporter/textfile_collector
+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 строк копипасты.
## Что делает ## Что делает
@@ -18,7 +18,6 @@ lxc_docker_host_packages:
# gitea: [sqlite3, rsync] # gitea: [sqlite3, rsync]
# adguard: [dnsutils] # adguard: [dnsutils]
# mihomo: [git] # mihomo: [git]
# memoir-bot: [git, openssh-client, rsync]
# grimmory: [mariadb-client, openssl] # grimmory: [mariadb-client, openssl]
lxc_docker_host_extra_packages: [] lxc_docker_host_extra_packages: []
@@ -12,7 +12,7 @@
- monitoring_pve_api_token_secret != 'replace-me' - monitoring_pve_api_token_secret != 'replace-me'
- monitoring_grafana_admin_password | length > 0 - monitoring_grafana_admin_password | length > 0
- monitoring_grafana_admin_password != 'change-me' - monitoring_grafana_admin_password != 'change-me'
fail_msg: Load monitoring secrets from ignored ansible/.env or Ansible Vault before running this playbook. fail_msg: Load monitoring secrets from the ignored repository-root .env or Ansible Vault before running this playbook.
no_log: true no_log: true
- name: Install monitoring runtime packages - name: Install monitoring runtime packages
@@ -24,7 +24,6 @@ scrape_configs:
- "192.168.1.23:9100" - "192.168.1.23:9100"
- "192.168.1.24:9100" - "192.168.1.24:9100"
- "192.168.1.25:9100" - "192.168.1.25:9100"
- "192.168.1.26:9100"
- "192.168.1.27:9100" - "192.168.1.27:9100"
- "192.168.1.28:9100" - "192.168.1.28:9100"
- "192.168.1.30:9100" - "192.168.1.30:9100"
+1 -1
View File
@@ -6,7 +6,7 @@
- lookup('env', 'PROXMOX_USER') | length > 0 - lookup('env', 'PROXMOX_USER') | length > 0
- lookup('env', 'PROXMOX_TOKEN_ID') | length > 0 - lookup('env', 'PROXMOX_TOKEN_ID') | length > 0
- lookup('env', 'PROXMOX_TOKEN_SECRET') | length > 0 - lookup('env', 'PROXMOX_TOKEN_SECRET') | length > 0
fail_msg: Load ansible/.env with PROXMOX_HOST, PROXMOX_USER, PROXMOX_TOKEN_ID and PROXMOX_TOKEN_SECRET. fail_msg: Load the repository-root .env with PROXMOX_HOST, PROXMOX_USER, PROXMOX_TOKEN_ID and PROXMOX_TOKEN_SECRET.
- name: Create or update LXC container - name: Create or update LXC container
community.proxmox.proxmox: community.proxmox.proxmox:
+1 -1
View File
@@ -6,4 +6,4 @@ uptime_kuma_lan_cidr: 192.168.1.0/24
uptime_kuma_listen_address: "{{ expected_lan_ip }}" uptime_kuma_listen_address: "{{ expected_lan_ip }}"
uptime_kuma_openvpn_gateway_ip: 192.168.1.23 uptime_kuma_openvpn_gateway_ip: 192.168.1.23
uptime_kuma_http_proxy: http://192.168.1.27:7890 uptime_kuma_http_proxy: http://192.168.1.27:7890
uptime_kuma_no_proxy: "localhost,127.0.0.1,::1,ada-dev.ru,.ada-dev.ru,192.168.1.5,192.168.1.10,192.168.1.20,192.168.1.23,192.168.1.24,192.168.1.25,192.168.1.26,192.168.1.27,192.168.1.28,192.168.1.29,192.168.1.30,192.168.1.31,192.168.1.32,192.168.1.34,192.168.1.100" uptime_kuma_no_proxy: "localhost,127.0.0.1,::1,ada-dev.ru,.ada-dev.ru,192.168.1.5,192.168.1.10,192.168.1.20,192.168.1.23,192.168.1.24,192.168.1.25,192.168.1.27,192.168.1.28,192.168.1.29,192.168.1.30,192.168.1.31,192.168.1.32,192.168.1.34,192.168.1.100"
+10
View File
@@ -137,11 +137,21 @@
delay: 5 delay: 5
until: uptime_kuma_health.status == 200 until: uptime_kuma_health.status == 200
# На хосте, где замороженный стек никогда не разворачивался (например, новый
# контейнер blue-green переезда), юнита homelab-monitoring нет, и systemd-модуль
# упал бы с "Could not find the requested service". Смысл шага — "legacy-стек не
# должен работать", а на чистом хосте это уже так. Проверено 2026-09-02.
- name: Detect the legacy monitoring unit
ansible.builtin.stat:
path: /etc/systemd/system/homelab-monitoring.service
register: uptime_kuma_legacy_unit
- name: Freeze the legacy Prometheus monitoring stack after Uptime Kuma is healthy - name: Freeze the legacy Prometheus monitoring stack after Uptime Kuma is healthy
ansible.builtin.systemd: ansible.builtin.systemd:
name: homelab-monitoring name: homelab-monitoring
enabled: false enabled: false
state: stopped state: stopped
when: uptime_kuma_legacy_unit.stat.exists
- name: Remove legacy monitoring firewall rules - name: Remove legacy monitoring firewall rules
community.general.ufw: community.general.ufw:
+17 -9
View File
@@ -39,12 +39,21 @@ Host cloud-pc mini-pc 192.168.1.5 192.168.1.10
IdentityFile ~/.ssh/id_ed25519_homelab_ansible IdentityFile ~/.ssh/id_ed25519_homelab_ansible
ProxyJump ru-vps ProxyJump ru-vps
# ── LXC, доступные напрямую по LAN (без ProxyJump) ────────────────────── # ── LXC pbs и ovpn-mini ─────────────────────────────────────────────────
# pbs идёт через ProxyJump ru-vps (агрегированная строка ниже): вне LAN это
# его единственный путь, а `make check`/`status`/`backup-audit` иначе падали.
Host pbs 192.168.1.20 Host pbs 192.168.1.20
HostName 192.168.1.20 HostName 192.168.1.20
# ovpn-mini — ProxyJump none НАМЕРЕННО: путь через ru-vps шёл бы по туннелю,
# который сам ovpn-mini и терминирует. Пока туннель жив это работало, но при
# его реконфигурации (или переезде контейнера) транспорт закольцовывается на
# мёртвый туннель. Из LAN хост доступен напрямую; вне LAN управление —
# через консоль ноды (pct exec) или при заранее поднятом туннеле.
# Первая строка блока побеждает по правилу "первое значение опции".
Host ovpn-mini 192.168.1.23 Host ovpn-mini 192.168.1.23
HostName 192.168.1.23 HostName 192.168.1.23
ProxyJump none
# ── LXC за jump-хостом ────────────────────────────────────────────────── # ── LXC за jump-хостом ──────────────────────────────────────────────────
Host vaultwarden 192.168.1.24 Host vaultwarden 192.168.1.24
@@ -53,9 +62,6 @@ Host vaultwarden 192.168.1.24
Host gitea 192.168.1.25 Host gitea 192.168.1.25
HostName 192.168.1.25 HostName 192.168.1.25
Host memoir-bot 192.168.1.26
HostName 192.168.1.26
Host mihomo 192.168.1.27 Host mihomo 192.168.1.27
HostName 192.168.1.27 HostName 192.168.1.27
@@ -80,23 +86,25 @@ Host grimmory 192.168.1.34
Host gyro 192.168.1.35 Host gyro 192.168.1.35
HostName 192.168.1.35 HostName 192.168.1.35
# ── Групповые опции. ssh_config не поддерживает перенос строк, поэтому # ── Групповые опции. ssh_config не поддерживает перенос строк, поэтому
# списки паттернов длинные: имя хоста (для `ssh gitea`) + IP (Ansible # списки паттернов длинные: имя хоста (для `ssh gitea`) + IP (Ansible
# подключается по ansible_host). ───────────────────────────────────── # подключается по ansible_host). ─────────────────────────────────────
# ProxyJump только для LXC, до которых нет прямого доступа # ProxyJump для всех LXC. ОСТОРОЖНО: путь до ovpn-mini идёт через туннель,
Host vaultwarden gitea memoir-bot mihomo adguard docker-test monitoring hermes-ai emergency-bot grimmory gyro 192.168.1.24 192.168.1.25 192.168.1.26 192.168.1.27 192.168.1.28 192.168.1.29 192.168.1.30 192.168.1.31 192.168.1.32 192.168.1.34 192.168.1.35 # который сам ovpn-mini и терминирует. Для чтения это безопасно, но
# playbooks/openvpn-vps-mini.yml вне LAN может оборвать себе транспорт на
# середине прогона — переконфигурацию OpenVPN выполняй из локальной сети.
Host pbs ovpn-mini vaultwarden gitea mihomo adguard docker-test monitoring hermes-ai emergency-bot grimmory gyro 192.168.1.20 192.168.1.23 192.168.1.24 192.168.1.25 192.168.1.27 192.168.1.28 192.168.1.29 192.168.1.30 192.168.1.31 192.168.1.32 192.168.1.34 192.168.1.35
ProxyJump ru-vps ProxyJump ru-vps
# Все LXC — под root с ключом id_ed25519_homelab # Все LXC — под root с ключом id_ed25519_homelab
Host pbs ovpn-mini vaultwarden gitea memoir-bot mihomo adguard docker-test monitoring hermes-ai emergency-bot grimmory gyro 192.168.1.20 192.168.1.23 192.168.1.24 192.168.1.25 192.168.1.26 192.168.1.27 192.168.1.28 192.168.1.29 192.168.1.30 192.168.1.31 192.168.1.32 192.168.1.34 192.168.1.35 Host pbs ovpn-mini vaultwarden gitea mihomo adguard docker-test monitoring hermes-ai emergency-bot grimmory gyro 192.168.1.20 192.168.1.23 192.168.1.24 192.168.1.25 192.168.1.27 192.168.1.28 192.168.1.29 192.168.1.30 192.168.1.31 192.168.1.32 192.168.1.34 192.168.1.35
User root User root
IdentityFile ~/.ssh/id_ed25519_homelab IdentityFile ~/.ssh/id_ed25519_homelab
# Общие опции для хостов HomeLab (намеренно не `Host *`, чтобы не влиять # Общие опции для хостов HomeLab (намеренно не `Host *`, чтобы не влиять
# на остальные записи ~/.ssh/config пользователя) # на остальные записи ~/.ssh/config пользователя)
Host ru-vps vps cloud-pc mini-pc pbs ovpn-mini vaultwarden gitea memoir-bot mihomo adguard docker-test monitoring hermes-ai emergency-bot grimmory gyro 157.22.231.198 192.168.1.5 192.168.1.10 192.168.1.20 192.168.1.23 192.168.1.24 192.168.1.25 192.168.1.26 192.168.1.27 192.168.1.28 192.168.1.29 192.168.1.30 192.168.1.31 192.168.1.32 192.168.1.34 192.168.1.35 Host ru-vps vps cloud-pc mini-pc pbs ovpn-mini vaultwarden gitea mihomo adguard docker-test monitoring hermes-ai emergency-bot grimmory gyro 157.22.231.198 192.168.1.5 192.168.1.10 192.168.1.20 192.168.1.23 192.168.1.24 192.168.1.25 192.168.1.27 192.168.1.28 192.168.1.29 192.168.1.30 192.168.1.31 192.168.1.32 192.168.1.34 192.168.1.35
IdentitiesOnly yes IdentitiesOnly yes
StrictHostKeyChecking accept-new StrictHostKeyChecking accept-new
ControlMaster auto ControlMaster auto
+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.
+156
View File
@@ -0,0 +1,156 @@
# Архитектура
## Контекст и точки входа
Репозиторий является 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;
- `playbooks/dashboard.yml``services.yaml` для Homepage-обзора инфраструктуры.
Остальное (`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 заморожен.
`playbooks/dashboard.yml` (`make dashboard`) поднимает Homepage вторым
compose-стеком на том же LXC `monitoring`: обзорная стартовая страница со
списком всех сервисов, ссылками на их UI и виджетами Proxmox/Uptime Kuma.
`services.yaml`, `settings.yaml`, `widgets.yaml`, `bookmarks.yaml` рендерятся
из `homelab_services` шаблонами `playbooks/templates/homepage-*.j2` — отдельный
список сервисов не ведётся. Контейнер слушает только LAN-адрес CT 155
(`homelab_dashboard_bind_ip`), наружу через Caddy не публикуется. Виджет
Proxmox использует read-only токен `homepage@pve!dashboard` (роль `PVEAuditor`,
`make bootstrap-dashboard-token`), секрет — в корневом `.env` как
`DASHBOARD_PVE_*`. Образ Homepage закрепляется по digest как остальные active
images; до первого закрепления плейбук падает на assert'е (обход —
`-e dashboard_allow_floating_tag=true`).
## 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 (неделя ожидания) не
сокращается.
+546
View File
@@ -0,0 +1,546 @@
# План работ
## Активные задачи
### Дашборд-обзор инфраструктуры (Homepage)
Решение от 2026-09-03: поднять стартовую страницу-обзор всей инфры (список
сервисов + переходы + виджеты Proxmox/Uptime Kuma).
Выбор: **Homepage** (gethomepage.dev) вторым compose-стеком на LXC
`monitoring` (CT 155) рядом с Uptime Kuma; доступ только LAN/OpenVPN, наружу
не публикуется. Конфиг Homepage **генерируется из `homelab_services`**
`playbooks/dashboard.yml` + шаблоны `playbooks/templates/homepage-*.j2`,
новый потребитель реестра. Плитка на сервис: `href` = `https://<proxy.domain>`
для публичных, иначе `http(s)://<ip>:<порт>`; беспортовые — в группу Headless
с ICMP ping.
**Развёрнуто на CT 155 2026-09-03.** Контейнер `homelab-homepage` (`Up healthy`),
`homelab-homepage.service` enabled, `http://192.168.1.30:8082/` -> 200. Прогон
`make dashboard` идемпотентен (третий заход `changed=0`), `make validate` и
`make lint` зелёные. Образ закреплён по digest
`sha256:753eeb0c…1e1bdd`. Порт 8082 (3000 в модели реестра занят замороженной
Grafana, 3001 — Kuma) -> контейнерный 3000.
Состав:
- `playbooks/dashboard.yml`, шаблоны `homepage-{settings,services,widgets,bookmarks}.yaml.j2`,
`homepage-compose.yml.j2`, `homepage.env.j2`;
- `playbooks/bootstrap-dashboard-pve-token.yml` — read-only токен
`homepage@pve!dashboard` (роль `PVEAuditor`), пишет `DASHBOARD_PVE_*` в корневой `.env`;
- реестр: блок `homelab_dashboard_*` в `group_vars/all/services.yml`, запись в
списках потребителей (`services.yml`, `AGENTS.md`, `architecture.md`);
- `Makefile`: цели `dashboard`, `dry-dashboard`, `bootstrap-dashboard-token`.
Оставшиеся шаги оператора:
1. `make bootstrap-dashboard-token` — выпустить PVE-токен для виджета Proxmox.
(В сессии-агенте был заблокирован auto-mode-классификатором как создание
credential; репозиторная часть готова, плейбук идемпотентен и сам допишет
`DASHBOARD_PVE_*` в корневой `.env`.) После — `make dashboard` заново, чтобы
`homepage.env` подхватил секрет. До этого плитка «Proxmox кластер» показывает
ошибку авторизации — не блокер.
2. Добавить те же три ключа-заглушки в `.env.example` (в сессии путь `.env*`
заблокирован в permissions).
3. В UI Uptime Kuma опубликовать Status Page со slug `homelab` — иначе виджет
сводки up/down показывает ошибку. Не блокер.
### Вывод 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 не влияет, не чинилось (можно
`systemctl mask polkit.service` — ничего от него не зависит).
**`onboot: 0` на всех остановленных OLD-контейнерах (2026-09-03).** Найдено:
132/140/141/142/143/144/145/146/148/149/150 стояли с `onboot: 1` и держат
боевые адреса в `pct config` — ребут любой ноды поднял бы их в конфликт с
живыми NEW (151-160). Снято через `pct set <id> --onboot 0`. Это временная
мера на окно отката: `pct destroy` (шаг 4.7) уберёт их целиком через неделю.
Обратимо (`--onboot 1`) для намеренного продвижения OLD обратно.
**Коммиты (2026-09-03).** Всё рабочее дерево (накопление сессий 2026-09-02/03)
разложено в 11 тематических коммитов на `main` (`22394cb`..`d2e1e68`),
не запушено. Границы — по инициативам: build-env, .env-в-корень+Make,
tofu scaffold, validate gate, registry-driven backups, вывод memoir-bot,
ru-vps base+кворум+ZeroTier, PBS storage prune, blue-green миграция на Tofu,
фикс VMID в update-плейбуках, docs. Несколько файлов (`services.yml`,
`status.yml`, `ssh_config`) несут вторичные правки других инициатив — отмечено
в телах коммитов.
**НЕ сделано (осознанно):** уборка раздела 6 `migration-tofu.md` — удаление
`roles/pve_lxc` (ещё используется creation-play пяти сервисов батча 2026-09-02
через `- role: pve_lxc`), стрип/полное гейчение creation plays, правки
`architecture.md`/`legacy-warning.md`, расширение `validate.yml` (features,
devices, mount points). Это отдельная задача; переездом сервисов 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
не включены.
+2
View File
@@ -38,6 +38,8 @@
pkgs.openssh pkgs.openssh
pkgs.curl pkgs.curl
pkgs.gnumake pkgs.gnumake
# Provisioning LXC на этапе пилота: см. tofu/README.md.
pkgs.opentofu
]; ];
shellHook = '' shellHook = ''
+36
View File
@@ -0,0 +1,36 @@
# This file is maintained automatically by "tofu init".
# Manual edits may be lost in future updates.
provider "registry.opentofu.org/bpg/proxmox" {
version = "0.111.1"
constraints = "~> 0.84"
hashes = [
"h1:0qABXcf7ulwKRhKGoK4LYZ7ObyoFkPxHmCrL6jRhMgE=",
"h1:0xM4HmXdu0hLrauA8ugcxjZvYHA+PuRybUxrXZEAdRI=",
"h1:ML2D3UUZTM99yrll/EBXj7wBYMb8xmQgomqFNybEoxY=",
"h1:PFGpaC7xcdC8w9jpxZPLJ/FFbVK5hd/CArUY+oXXTu8=",
"h1:XoYUWDsGWvxSlir/sYiGzx1MkcPVUtWIgc6OWEdf/C8=",
"h1:Zn7LyCWL/Xn0yCHiigXFYEp3MIv0MOVzUfL657QOplY=",
"h1:dIGBUSoC89e/PbpBEjbZ/YOzAkzrG1kF6EYcqk+8IqE=",
"h1:eERpiB94PyhN2SqE9E1+6sgpk0Kn6qjy555R2cjfpug=",
"h1:ejD4OSOL98W/SA4jLDvxEwCW8NSdVGKLyKmgfAUROK8=",
"h1:iTQv4FVFhMVl2juw6lgrVTpGFrdmdNPj+NFY2Dms0SE=",
"h1:jcqEv/zW+heFIPq5xwXxgS9EuBmbjIM1MriwQjx75WE=",
"h1:xF+AQJqpRf30WbrfHgSeuLqDBptWhTIdVoMyv7j6yVo=",
"h1:xWV1Y6ItiFXNCJO9OtyINMx8cqT5XiTOK2Rne7Jyu3w=",
"zh:18fb7c31a08dde6bffa1a4d4a211e604d6d17eec7092fd59331b3db3c6f3742c",
"zh:1cd60761538289d4dd2a1086b3ae62a7b0bdd4b1a2f824e9a44e243413168dba",
"zh:2eb76f6fc8299b6820ff678c8252332cc3366e226b5ae2e61748fd2449c1ed92",
"zh:45e6f7ebd0bf48911d37060359a4f359b5743b3092e985295733990e406d0416",
"zh:4aa8ba912eae37975d2e983394d173e595ca34fc76b5bf220b37d0e99d76e98c",
"zh:58e0789923103a77d502a0a9fc3eb920625e8eb935ec2d4ac0d006aebd1d186c",
"zh:6df8aa85fb8865915537e946c19b02538ad188018a629759c213c6f03730f642",
"zh:6ed47bc00d0913a1d0880618fa1376115e9edab6b4a658c081061a7f0e4ca360",
"zh:c5b10ff4f33df7e4c29e8f1127d49845b561b37b57517e844fb0954d7923d65e",
"zh:d016510e14b738499f0db9d9b3aafe82fc6877fb4ab4e9f831fb68a8d70a1385",
"zh:d941f394069bbf24351b363da1c64383f487067aaee0a84f9b96476d4912e212",
"zh:ddf271dbc2632ae8ffa8de3972f243ee47d260cb2ac90aa784f2746d98e21a0f",
"zh:ed0caa3501c42f611b7e9622c9b1df69fd85dc25a3cd88d3076381829688cd62",
"zh:f26e0763dbe6a6b2195c94b44696f2110f7f55433dc142839be16b9697fa5597",
]
}
+172
View File
@@ -0,0 +1,172 @@
# OpenTofu — пилот provisioning LXC
Экспериментальный каталог. Ни один боевой контейнер здесь не описан: цель —
выяснить, что провайдер `bpg/proxmox` реально может в этой инфраструктуре,
прежде чем принимать решение о переезде.
Активных ресурсов сейчас нет: пилотный контейнер снесён, а его описание лежит
в `pilot.tf.example` — расширение не даёт Tofu его загрузить. Это рабочий
образец формы ресурса для реальных сервисов, см.
[`../docs/ai/migration-tofu.md`](../docs/ai/migration-tofu.md).
```bash
make -C ansible tofu-init
make -C ansible tofu-plan
make -C ansible tofu-apply CONFIRM=1
make -C ansible tofu-destroy CONFIRM=1
```
## Результат пилота (2026-09-02, provider 0.84, PVE-кластер homelab)
Всё проверено на живом кластере контейнером VMID 199 `tofu-pilot`.
### Что API-токен МОЖЕТ
| Возможность | Проверено |
|---|---|
| Создание LXC: vmid, node, hostname, cores, memory, swap, unprivileged | да |
| Сеть: bridge, статический IP, шлюз, firewall | да |
| rootfs на datastore | да |
| **Mount point как volume на datastore** | да — `mp0: data:199/vm-199-disk-1.raw,mp=/opt/pilot-data,size=4G` |
| `features { nesting }` | да |
| onboot, startup order, tags | да |
| Идемпотентность (`plan` -> `No changes`) | да |
Строка про mount point — ответ на вопрос про `mp0` у gitea. Bind mount каталога
хоста (`mp0: /opt/data/gitea,mp=...`) токеном недоступен, а **volume на
datastore — доступен**. То есть при пересоздании gitea хранилище можно
перевести на volume и снять зависимость от root@pam.
### Чего API-токен НЕ МОЖЕТ
Proxmox отвечает HTTP 403:
```
configuring device passthrough is only allowed for root@pam
changing feature flags (except nesting) is only allowed for root@pam
```
То есть `keyctl`, `fuse`, `mount` и `device_passthrough` (`dev0:`) требуют
root@pam. Это ограничение **Proxmox, а не Tofu**: Ansible упирается в ровно ту
же стену, поэтому в `pve-monitoring.yml`, `pve-grimmory.yml` и
`pve-hermes-ai.yml` есть отдельный шаг `pct set <vmid> --features
nesting=1,keyctl=1` по SSH под root, а `/dev/fuse` и `/dev/net/tun`
пробрасываются правкой `/etc/pve/lxc/<vmid>.conf`.
Вывод: по этому пункту Tofu не хуже нынешнего состояния — ему нужен такой же
дошаг.
### Ловушка гибридной схемы
Если Tofu создаёт контейнер, а Ansible потом добавляет `keyctl` через
`pct set`, Tofu видит это как дрейф: `plan` даёт `1 to change` и следующий
`apply` откатил бы features обратно, лишив контейнер возможности запускать
Docker. Проверено на пилоте.
Обход — `lifecycle { ignore_changes = [features] }`, он в `pilot.tf`. После
него `plan` снова даёт `No changes`, а `keyctl=1` на узле сохраняется. Плата:
features перестают управляться декларативно.
### `cmode` — не в списке атрибутов ресурса, но провайдер его отслеживает через `console`
Обнаружено 2026-09-02 при переезде `emergency-bot` (сервис №1), исправлено в
тот же день. `roles/pve_lxc` всем создаваемым контейнерам ставит
`cmode: shell` (`pct console` заходит сразу в root shell, а не в getty-логин)
`pve_lxc_cmode` в `roles/pve_lxc/defaults/main.yml`. У ресурса
`proxmox_virtual_environment_container` провайдера `bpg/proxmox` нет
top-level атрибута `cmode` — он скрыт в блоке `console { type = ... }`,
который в plan/apply при создании не показывается, если не задан явно.
Первая ошибка: контейнер создан без блока `console`, получил дефолт Proxmox
(`cmode: tty`), исправлено одноразовым `pct set <vmid> --cmode shell` по SSH.
Это была ошибка: следующий `tofu-plan` (уже на шаге 4.5 этого же сервиса)
показал `console { type = "shell" -> null }` — провайдер ЗАМЕТИЛ ручную правку
через refresh state и на следующем apply откатил бы её обратно на `tty`. То
есть `cmode` ведёт себя ровно как `keyctl` в гибридной схеме ниже: правится
вручную — ловит drift.
Правильное решение — объявить блок в ресурсе явно:
console {
type = "shell"
}
После этого `tofu-plan` не показывает `console` как diff, ручная правка не
нужна. Добавлять этот блок в описание любого сервиса на шаге 4.2 сразу же,
не как отдельный ручной пост-шаг.
### Рутовый токен не помогает — проверено по исходникам
Проверка в `/usr/share/perl5/PVE/LXC.pm:1658` буквальная:
return 1 if $authuser eq 'root@pam';
А `$authuser` при токенной аутентификации — это полный token-ID. Из
`/usr/share/perl5/PVE/HTTPServer.pm:86`:
# the token-ID `<user>@<realm>!<tokenname>` is the user for token based authentication
$username = eval { PVE::AccessControl::verify_token($api_token); };
`verify_token` возвращает `$tokenid`, то есть `user@realm!tokenname`. Строка
`root@pam!mytoken` не равна `root@pam`, поэтому токен root проверку НЕ проходит.
Следствия:
- выдать токену больше прав бесполезно: это не проверка привилегий, а сравнение
имени пользователя, ACL на неё не влияют;
- единственный вариант «сделать всё через Tofu» — аутентификация root@pam по
паролю. В realm `pam` это системный пароль root узла Proxmox: его нельзя
ограничить по scope и нельзя отозвать отдельно от смены пароля root.
### Решение: root@pam по паролю (принято и проверено 2026-09-02)
Выбран привилегированный режим. Проверено на пилоте — всё привилегированное
выставляется декларативно, без единого шага Ansible:
dev0: deny-write=0,path=/dev/net/tun,uid=0,gid=0,mode=0660
features: fuse=1,keyctl=1,nesting=1,mknod=0
mp0: data:199/vm-199-disk-1.raw,mp=/opt/pilot-data,size=4G
Повторный `plan` даёт `No changes`.
Следствия для миграции сервисов:
- дошаг `pct set <vmid> --features nesting=1,keyctl=1` по SSH больше не нужен —
при переезде сервиса его можно удалять из соответствующего `pve-*.yml`;
- правка `/etc/pve/lxc/<vmid>.conf` через `lineinfile` (девять контейнеров)
заменяется блоком `device_passthrough`;
- `lifecycle { ignore_changes = [features] }` не нужен: он был обходом для
гибридной схемы, от которой отказались;
- apply одной фазой, без чередования Tofu и Ansible.
Отвергнутая альтернатива — гибрид: Tofu создаёт то, что доступно токену,
Ansible доводит привилегированное по SSH через существующий sudo. Не требует
новых секретов, но оставляет декларативность частичной и apply двухфазным.
## Аутентификация
Способ выбирает Makefile по корневому `.env`:
| Условие | Режим | Что доступно |
|---|---|---|
| `PROXMOX_ROOT_PASSWORD` задан и не `replace-me` | root@pam по паролю | всё, включая features и device passthrough |
| иначе | токен `ansible@pve` | всё кроме features (кроме nesting), device passthrough и bind mount |
Выбранный режим печатается в stderr перед запуском, чтобы из вывода было видно,
какими правами шёл apply. В `pilot.tf` привилегированные поля включаются через
`local.privileged`, поэтому конфигурация валидна в обоих режимах.
## Сеть
Tofu ходит в Proxmox API напрямую по HTTPS и **не умеет ProxyJump** из
`ssh_config`. Вне LAN узлы недоступны, поэтому цели `tofu-*` в Makefile сами
поднимают SSH-туннель через `ru-vps` на время команды и направляют провайдер в
`127.0.0.1`. Отсюда же `insecure = true`: сертификат выписан на узел, а
обращение идёт к localhost.
## State
`tofu/*.tfstate*` в `.gitignore`: состояние содержит фактическую конфигурацию
гостей. `.terraform.lock.hcl` наоборот коммитится — он закрепляет версию
провайдера. Отдельного бэкенда нет; при переходе к боевому использованию
состояние нужно куда-то бэкапить.
+134
View File
@@ -0,0 +1,134 @@
# ============================================================================
# ОБРАЗЕЦ, НЕ ЗАГРУЖАЕТСЯ. Расширение .example выбрано намеренно: сам пилот
# (VMID 199) снесён 2026-09-02, и будь этот файл активным, каждый `tofu-plan`
# предлагал бы создать его заново.
#
# Держим как рабочий пример формы ресурса: по нему описываются реальные
# сервисы при переезде. См. docs/ai/migration-tofu.md, шаг 4.2.
# ============================================================================
#
# Пилот: одноразовый LXC, созданный OpenTofu.
# ============================================================================
#
# ЗАЧЕМ
# Проверить на живом кластере ровно те места, из-за которых переезд на Tofu
# выглядел рискованным, не трогая ни один боевой контейнер:
#
# 1. Проброс /dev/fuse. В pve-*.yml он сделан правкой /etc/pve/lxc/<vmid>.conf
# (lxc.cgroup2.devices.allow + lxc.mount.entry), потому что Proxmox API
# сырые lxc.* ключи не принимает. У провайдера для этого есть
# features.fuse — штатный флаг PVE, а не обход.
# 2. Проброс /dev/net/tun — блок device_passthrough (dev0: в PVE 8.2+).
# 3. Mount point на storage вместо bind mount каталога хоста. Именно bind
# mount у gitea (mp0: /opt/data/gitea) требует прав root@pam и не
# создаётся API-токеном; volume на datastore — создаётся.
#
# Контейнер намеренно не входит в inventory и не автозапускается.
# Снести после проверки: make tofu-destroy CONFIRM=1
#
# VMID 199 и 192.168.1.39 выбраны свободными на 2026-09-02 и лежат вне
# диапазона сервисов, но внутри маршрутов ru-vps (192.168.1.5-40).
locals {
# Привилегированный режим = аутентификация root@pam по паролю. Makefile
# выбирает его, когда в корневом .env задан PROXMOX_ROOT_PASSWORD.
privileged = var.pve_password != ""
}
resource "proxmox_virtual_environment_container" "pilot" {
node_name = "cloud-pc"
vm_id = 199
unprivileged = true
start_on_boot = false
started = true
tags = ["tofu", "pilot"]
initialization {
hostname = "tofu-pilot"
ip_config {
ipv4 {
address = "192.168.1.39/24"
gateway = "192.168.1.1"
}
}
dns {
servers = ["1.1.1.1"]
}
user_account {
keys = [trimspace(file(pathexpand("~/.ssh/id_ed25519_homelab.pub")))]
}
}
operating_system {
template_file_id = "local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst"
type = "debian"
}
cpu {
cores = 1
}
memory {
dedicated = 512
swap = 512
}
disk {
datastore_id = "data"
size = 8
}
# Проверка №1 и №2: то, что в pve-*.yml делается правкой конфига на узле.
# Токену Proxmox разрешает только nesting; keyctl и fuse требуют root@pam.
# null означает «не задавать поле», а не «выключить».
features {
nesting = true
keyctl = local.privileged ? true : null
fuse = local.privileged ? true : null
}
# Проброс устройства Proxmox разрешает только root@pam (403 для токена,
# проверено на пилоте 2026-09-02), поэтому блок появляется лишь в
# привилегированном режиме.
dynamic "device_passthrough" {
for_each = local.privileged ? [1] : []
content {
path = "/dev/net/tun"
}
}
# Проверка №3: volume на datastore, а не bind mount каталога хоста.
mount_point {
volume = "data"
size = "4G"
path = "/opt/pilot-data"
}
network_interface {
name = "eth0"
bridge = "vmbr0"
firewall = true
}
startup {
order = 999
}
# ignore_changes = [features] здесь НЕТ намеренно. Он нужен только в
# гибридной схеме, где features выставляет Ansible через `pct set`: иначе
# Tofu на следующем apply откатывает их и ломает Docker в контейнере
# (проверено на пилоте). В привилегированном режиме features описаны выше
# декларативно, и обход не требуется.
}
output "pilot" {
description = "Что проверять на узле после apply"
value = {
vmid = proxmox_virtual_environment_container.pilot.vm_id
node = proxmox_virtual_environment_container.pilot.node_name
verify = "ssh cloud-pc sudo pct config ${proxmox_virtual_environment_container.pilot.vm_id}"
}
}
+11
View File
@@ -0,0 +1,11 @@
provider "proxmox" {
endpoint = var.pve_endpoint
insecure = var.pve_insecure
# null вместо пустой строки принципиален: провайдер выбирает способ
# аутентификации по тому, какие поля заданы, и пустая строка считалась бы
# заданным значением.
api_token = var.pve_api_token != "" ? var.pve_api_token : null
username = var.pve_username != "" ? var.pve_username : null
password = var.pve_password != "" ? var.pve_password : null
}
+93
View File
@@ -0,0 +1,93 @@
# ============================================================================
# Сервисы, переведённые на OpenTofu provisioning по схеме blue-green.
# Процедура и порядок сервисов: docs/ai/migration-tofu.md.
# ============================================================================
#
# Сервис №1: emergency-bot (OLD vmid 148, mini-pc).
# Эталон снят 2026-09-02: ssh mini-pc sudo pct config 148.
# Данных нет (backup: none), шаг 4.4 (перенос данных) пропускается целиком.
#
# vm_id 151 — новый VMID для blue-green переезда, TMPIP 192.168.1.6 был
# использован только на шаге 4.3 (проверка перед cutover). После шага 4.5.15
# адрес — боевой 192.168.1.32, OLD (148) остановлен и остаётся откатом
# минимум неделю.
resource "proxmox_virtual_environment_container" "emergency_bot" {
node_name = "mini-pc"
vm_id = 151
unprivileged = true
start_on_boot = true
started = true
tags = ["tofu"]
initialization {
hostname = "emergency-bot"
ip_config {
ipv4 {
address = "192.168.1.32/24"
gateway = "192.168.1.1"
}
}
dns {
servers = ["1.1.1.1"]
}
user_account {
keys = [trimspace(file(pathexpand("~/.ssh/id_ed25519_homelab.pub")))]
}
}
operating_system {
template_file_id = "local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst"
type = "debian"
}
cpu {
cores = 1
}
memory {
dedicated = 512
swap = 256
}
disk {
datastore_id = "local-lvm"
size = 4
}
# Эталон: features: nesting=1. Ни keyctl, ни fuse, ни device passthrough
# эталону не нужны, поэтому доступно в обоих режимах аутентификации.
features {
nesting = true
}
# roles/pve_lxc всем контейнерам ставит cmode=shell (pve_lxc_cmode), провайдер
# это отслеживает через блок console.type — без него на следующем apply
# откатит на дефолт Proxmox tty. Обнаружено 2026-09-02 на этом сервисе,
# см. tofu/README.md.
console {
type = "shell"
}
network_interface {
name = "eth0"
bridge = "vmbr0"
firewall = true
}
startup {
order = 70
}
}
output "emergency_bot" {
description = "Что проверять на узле после apply"
value = {
vmid = proxmox_virtual_environment_container.emergency_bot.vm_id
node = proxmox_virtual_environment_container.emergency_bot.node_name
verify = "ssh mini-pc sudo pct config ${proxmox_virtual_environment_container.emergency_bot.vm_id}"
}
}
+127
View File
@@ -0,0 +1,127 @@
# ============================================================================
# Сервис №8 по плану (docs/ai/migration-tofu.md, раздел 5): adguard.
# DNS-фильтр всей LAN. Жёсткое окно простоя только между шагами 4.5.14 и
# 4.5.17 (два контейнера на 192.168.1.28 одновременно невозможны).
#
# OLD vmid 144, узел mini-pc, боевой адрес 192.168.1.28.
# Эталон снят 2026-09-02: ssh mini-pc sudo pct config 144 (дословно совпадает
# с /etc/pve/lxc/144.conf):
#
# arch: amd64
# cmode: shell
# cores: 1
# features: nesting=1,keyctl=1
# hostname: adguard
# memory: 512
# nameserver: 1.1.1.1
# net0: name=eth0,bridge=vmbr0,firewall=1,gw=192.168.1.1,ip=192.168.1.28/24,type=veth
# onboot: 1
# ostype: debian
# rootfs: local-lvm:vm-144-disk-0,size=8G
# startup: order=40
# swap: 512
# unprivileged: 1
# lxc.cgroup2.devices.allow: c 10:229 rwm
# lxc.mount.entry: /dev/fuse dev/fuse none bind,create=file
#
# vm_id 158 — новый VMID для blue-green переезда (назначен, не менять).
# TMPIP 192.168.1.17 используется только на шаге 4.3 (проверка перед cutover).
# После шага 4.5.15 адрес меняется на боевой 192.168.1.28, OLD (144)
# останавливается и остаётся откатом минимум неделю.
#
# --- /dev/fuse: features.fuse, а не device_passthrough ----------------------
# Эталонные lxc.cgroup2.devices.allow + lxc.mount.entry для /dev/fuse — ручной
# обход (lineinfile по /etc/pve/lxc/144.conf в pve-adguard.yml), потому что
# Proxmox API сырые lxc.* ключи не принимает. Заменяется штатным флагом PVE
# features.fuse, проверенным на пилоте VMID 199 и на сервисах 2-7. Следствие:
# pct config нового контейнера покажет features: fuse=1,keyctl=1,nesting=1 —
# текстуально иначе, чем эталон, при том же эффекте. См. tofu/README.md.
#
# --- Данные ----------------------------------------------------------------
# /opt/adguard/{conf,work} (~313 МБ, почти всё — work/: журнал запросов,
# статистика, кэш фильтров). conf/AdGuardHome.yaml несёт весь конфиг, включая
# хэш пароля админа, DNS rewrites, upstream и клиентов. Переносится целиком
# на шаге 4.4; Ansible этим каталогом не управляет.
# ============================================================================
resource "proxmox_virtual_environment_container" "adguard" {
node_name = "mini-pc"
vm_id = 158
unprivileged = true
start_on_boot = true
started = true
tags = ["tofu"]
initialization {
hostname = "adguard"
ip_config {
ipv4 {
address = "192.168.1.28/24"
gateway = "192.168.1.1"
}
}
dns {
servers = ["1.1.1.1"]
}
user_account {
keys = [trimspace(file(pathexpand("~/.ssh/id_ed25519_homelab.pub")))]
}
}
operating_system {
template_file_id = "local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst"
type = "debian"
}
cpu {
cores = 1
}
memory {
dedicated = 512
swap = 512
}
disk {
datastore_id = "local-lvm"
size = 8
}
# Эталон: features: nesting=1,keyctl=1 плюс проброс /dev/fuse через сырые
# lxc.* строки. fuse=1 — штатная замена этого обхода (см. комментарий выше).
features {
nesting = true
keyctl = true
fuse = true
}
# roles/pve_lxc всем контейнерам ставит cmode=shell (pve_lxc_cmode), провайдер
# это отслеживает через блок console.type — без него на следующем apply
# откатит на дефолт Proxmox tty. Обнаружено 2026-09-02 на emergency-bot,
# см. tofu/README.md.
console {
type = "shell"
}
network_interface {
name = "eth0"
bridge = "vmbr0"
firewall = true
}
startup {
order = 40
}
}
output "adguard" {
description = "Что проверять на узле после apply"
value = {
vmid = proxmox_virtual_environment_container.adguard.vm_id
node = proxmox_virtual_environment_container.adguard.node_name
verify = "ssh mini-pc sudo pct config ${proxmox_virtual_environment_container.adguard.vm_id}"
}
}
+104
View File
@@ -0,0 +1,104 @@
# ============================================================================
# Сервис №2: docker-test (OLD vmid 145, cloud-pc).
# Эталон снят 2026-09-02: ssh cloud-pc sudo pct config 145.
# Данных нет (песочница), шаг 4.4 (перенос данных) пропускается целиком.
#
# vm_id 152 — новый VMID blue-green переезда. TMPIP 192.168.1.11 отработал на
# шаге 4.3; cutover выполнен 2026-09-02, адрес боевой — 192.168.1.29.
# OLD (145) остановлен и остаётся откатом минимум неделю, до 2026-09-09.
#
# В отличие от emergency_bot, этот сервис проверяет проброс /dev/fuse на
# реальном сервисе: Docker внутри работает на storage-driver fuse-overlayfs.
# ============================================================================
resource "proxmox_virtual_environment_container" "docker_test" {
node_name = "cloud-pc"
vm_id = 152
unprivileged = true
start_on_boot = true
started = true
tags = ["tofu"]
initialization {
hostname = "docker-test"
ip_config {
ipv4 {
address = "192.168.1.29/24"
gateway = "192.168.1.1"
}
}
dns {
servers = ["1.1.1.1"]
}
user_account {
keys = [trimspace(file(pathexpand("~/.ssh/id_ed25519_homelab.pub")))]
}
}
operating_system {
template_file_id = "local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst"
type = "debian"
}
cpu {
cores = 1
}
memory {
dedicated = 512
swap = 512
}
disk {
datastore_id = "data"
size = 8
}
# Эталон: features: nesting=1,keyctl=1 плюс проброс /dev/fuse, сделанный в
# pve-docker-test.yml правкой /etc/pve/lxc/145.conf (lxc.cgroup2.devices.allow
# + lxc.mount.entry) — Proxmox API сырые lxc.* ключи не принимает.
#
# У провайдера для /dev/fuse есть ШТАТНЫЙ флаг features.fuse, а не
# device_passthrough: последний нужен только для /dev/net/tun (dev0: в PVE
# 8.2+). См. tofu/pilot.tf.example:17-21 и tofu/README.md.
# Поэтому pct config нового контейнера покажет features: fuse=1,keyctl=1,
# nesting=1 — текстуально иначе, чем эталон, но это тот же результат
# штатным механизмом вместо обхода.
#
# Всё три флага требуют root@pam; токену Proxmox разрешает только nesting.
features {
nesting = true
keyctl = true
fuse = true
}
# roles/pve_lxc всем контейнерам ставит cmode=shell (pve_lxc_cmode), провайдер
# это отслеживает через блок console.type — без него на следующем apply
# откатит на дефолт Proxmox tty. Обнаружено 2026-09-02 на emergency-bot,
# см. tofu/README.md.
console {
type = "shell"
}
network_interface {
name = "eth0"
bridge = "vmbr0"
firewall = true
}
startup {
order = 50
}
}
output "docker_test" {
description = "Что проверять на узле после apply"
value = {
vmid = proxmox_virtual_environment_container.docker_test.vm_id
node = proxmox_virtual_environment_container.docker_test.node_name
verify = "ssh cloud-pc sudo pct config ${proxmox_virtual_environment_container.docker_test.vm_id}"
}
}
+146
View File
@@ -0,0 +1,146 @@
# ============================================================================
# Сервис №3 по плану (docs/ai/migration-tofu.md, раздел 5): gitea.
# Главный приз всей миграции — ради него она и затевалась.
#
# OLD vmid 141, узел cloud-pc, боевой адрес 192.168.1.25.
# Эталон снят 2026-09-02: ssh cloud-pc sudo pct config 141 (и совпадающий
# /etc/pve/lxc/141.conf):
#
# arch: amd64
# cmode: shell
# cores: 2
# features: nesting=1,keyctl=1
# hostname: gitea
# memory: 2048
# mp0: /opt/data/gitea,mp=/opt/gitea/data
# nameserver: 1.1.1.1
# net0: name=eth0,bridge=vmbr0,firewall=1,gw=192.168.1.1,ip=192.168.1.25/24,type=veth
# onboot: 1
# ostype: debian
# rootfs: data:141/vm-141-disk-0.raw,size=32G
# startup: order=50
# swap: 1024
# unprivileged: 1
# lxc.cgroup2.devices.allow: c 10:229 rwm
# lxc.mount.entry: /dev/fuse dev/fuse none bind,create=file
#
# vm_id 153 — новый VMID для blue-green переезда. IP ниже — TMPIP
# 192.168.1.12, используется только до шага 4.5.15 (проверка перед cutover);
# затем меняется на боевой 192.168.1.25, а OLD (141) останавливается и
# остаётся откатом минимум неделю.
#
# --- Bind mount -> volume ----------------------------------------------------
# Эталонный mp0 — bind mount каталога НОДЫ (/opt/data/gitea на cloud-pc), а не
# volume на datastore. Provider с root@pam мог бы воспроизвести и bind mount
# буквально, но делать это НЕЛЬЗЯ: если по ошибке одновременно поднимутся
# оба контейнера, два процесса Gitea будут писать в один и тот же каталог
# хоста и повредят SQLite и репозитории. Поэтому mount_point ниже — volume на
# том же datastore, что и rootfs ("data"), с независимым хранилищем. Данные
# переносятся отдельно на шаге 4.4 (rsync/pct push), а не общим bind mount.
# Размер volume — 32G по решению migration-tofu.md (раздел 5, пункт 3):
# фактическое использование на 2026-09-02 — 2.3G (`sudo du -sh
# /opt/data/gitea`), так что 32G — запас с большим множителем на рост
# репозиториев, а не минимально достаточное значение.
#
# --- /dev/fuse: features.fuse, а не device_passthrough ----------------------
# Эталонные строки lxc.cgroup2.devices.allow + lxc.mount.entry для /dev/fuse —
# это ручной обход, потому что Proxmox API не принимает сырые lxc.* ключи
# (pve-gitea.yml добавляет их через lineinfile). У провайдера bpg/proxmox для
# ровно этого эффекта есть штатный флаг PVE features.fuse — это подтверждено
# на пилоте (tofu/README.md, раздел «Решение: root@pam по паролю») и явно
# названо в комментарии tofu/pilot.tf.example: "У провайдера для этого есть
# features.fuse — штатный флаг PVE, а не обход". device_passthrough (dev0:)
# нужен только для сырых character-устройств вида /dev/net/tun; у gitea
# такого устройства нет (в pct config нет строки dev0:), поэтому блока
# device_passthrough в этом ресурсе нет вообще — только features.fuse.
# ============================================================================
resource "proxmox_virtual_environment_container" "gitea" {
node_name = "cloud-pc"
vm_id = 153
unprivileged = true
start_on_boot = true
started = true
tags = ["tofu"]
initialization {
hostname = "gitea"
ip_config {
ipv4 {
address = "192.168.1.25/24"
gateway = "192.168.1.1"
}
}
dns {
servers = ["1.1.1.1"]
}
user_account {
keys = [trimspace(file(pathexpand("~/.ssh/id_ed25519_homelab.pub")))]
}
}
operating_system {
template_file_id = "local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst"
type = "debian"
}
cpu {
cores = 2
}
memory {
dedicated = 2048
swap = 1024
}
disk {
datastore_id = "data"
size = 32
}
# Эталон: features: nesting=1,keyctl=1 плюс ручной обход для /dev/fuse
# (см. комментарий выше). fuse=1 — штатная замена этого обхода.
features {
nesting = true
keyctl = true
fuse = true
}
# roles/pve_lxc всем контейнерам ставит cmode=shell (pve_lxc_cmode), провайдер
# это отслеживает через блок console.type — без него на следующем apply
# откатит на дефолт Proxmox tty. Обнаружено 2026-09-02 на emergency-bot,
# см. tofu/README.md.
console {
type = "shell"
}
# Bind mount ноды заменён на независимый volume — см. комментарий выше.
# Тот же datastore, что и rootfs.
mount_point {
volume = "data"
size = "32G"
path = "/opt/gitea/data"
}
network_interface {
name = "eth0"
bridge = "vmbr0"
firewall = true
}
startup {
order = 50
}
}
output "gitea" {
description = "Что проверять на узле после apply"
value = {
vmid = proxmox_virtual_environment_container.gitea.vm_id
node = proxmox_virtual_environment_container.gitea.node_name
verify = "ssh cloud-pc sudo pct config ${proxmox_virtual_environment_container.gitea.vm_id}"
}
}
+149
View File
@@ -0,0 +1,149 @@
# ============================================================================
# Сервис №7 по плану (docs/ai/migration-tofu.md, раздел 5): grimmory.
# Самый болезненный шаг: MariaDB, Flyway-миграции, контракт OPDS/KOReader.
#
# OLD vmid 149, узел cloud-pc, боевой адрес 192.168.1.34.
# Эталон снят 2026-09-02: ssh cloud-pc sudo pct config 149 (и совпадающий
# дословно /etc/pve/lxc/149.conf):
#
# arch: amd64
# cmode: shell
# cores: 2
# features: nesting=1,keyctl=1
# hostname: grimmory
# memory: 4096
# nameserver: 1.1.1.1
# net0: name=eth0,bridge=vmbr0,firewall=1,gw=192.168.1.1,ip=192.168.1.34/24,type=veth
# onboot: 1
# ostype: debian
# rootfs: data:149/vm-149-disk-0.raw,size=64G
# startup: order=100
# swap: 1024
# unprivileged: 1
# lxc.cgroup2.devices.allow: c 10:229 rwm
# lxc.mount.entry: /dev/fuse dev/fuse none bind,create=file
#
# Ни mpN, ни dev0 в эталоне нет — только /dev/fuse через сырые lxc.* строки.
# Значит правило про bind mount -> mount_point (как у gitea) здесь не
# применяется: mount_point в этом ресурсе не добавлен.
#
# vm_id 157 — новый VMID для blue-green переезда (назначен, не менять).
# TMPIP 192.168.1.16 ниже используется только на шаге 4.3 (проверка перед
# cutover; запись grimmory-new уже есть в hosts.yml/ssh_config — заведена
# координатором). После шага 4.5.15 адрес меняется на боевой 192.168.1.34,
# OLD (149) останавливается и остаётся откатом минимум неделю.
#
# --- /dev/fuse: features.fuse, а не device_passthrough ----------------------
# Эталонные lxc.cgroup2.devices.allow + lxc.mount.entry для /dev/fuse — это
# ручной обход (lineinfile по /etc/pve/lxc/149.conf в pve-grimmory.yml),
# потому что Proxmox API сырые lxc.* ключи не принимает.
#
# Заменяется штатным флагом PVE features.fuse, а НЕ device_passthrough.
# device_passthrough (dev0:) предназначен для сырых character-устройств вида
# /dev/net/tun; для /dev/fuse у PVE есть флаг, и именно он проверен на пилоте
# VMID 199 (`features: fuse=1,keyctl=1,nesting=1`), см. tofu/pilot.tf.example
# строки 17-21 и tofu/README.md. Формулировка «device_passthrough для каждого
# устройства из lxc.mount.entry» в migration-tofu.md п.4.2 была слишком
# широкой и уточнена там же 2026-09-02; так же поправлены svc-docker-test.tf
# и svc-vaultwarden.tf, так что все ресурсы tofu/ теперь единообразны.
#
# Следствие: pct config нового контейнера покажет features: fuse=1,keyctl=1,
# nesting=1 — текстуально иначе, чем эталон, при том же эффекте.
#
# --- MariaDB: только dump/restore, не копирование файлов --------------------
# /opt/grimmory/mariadb — это datadir MariaDB (grimmory-mariadb, БД grimmory).
# Копировать эти файлы между старым и новым контейнером НЕЛЬЗЯ: нужен
# mariadb-dump на старом контейнере (сервис приложения остановлен, MariaDB
# работает) и restore на новом. Рецепт dump уже есть и проверен в рабочем
# offsite-профиле restic (playbooks/offsite-restic-yadisk.yml, задача
# ../tasks/offsite-restic-profile.yml): docker exec -e MYSQL_PWD=...
# grimmory-mariadb mariadb-dump --single-transaction --routines --events
# --databases grimmory. legacy-warning.md и docs/ai/edge-cases.md запрещают
# code-only downgrade после Flyway-миграции — на новом контейнере обязан
# развернуться РОВНО ТОТ ЖЕ pinned образ v3.2.4 (проверено 2026-09-02:
# запущенный в CT 149 образ grimmory/grimmory:v3.2.4 имеет digest
# sha256:dfa7afdfcf25d649fd664497a62385dd00cd9678c37546e182c172e41c8e80cb —
# совпадает с реестром services.yml байт-в-байт, расхождения нет).
# ============================================================================
resource "proxmox_virtual_environment_container" "grimmory" {
node_name = "cloud-pc"
vm_id = 157
unprivileged = true
start_on_boot = true
started = true
tags = ["tofu"]
initialization {
hostname = "grimmory"
ip_config {
ipv4 {
address = "192.168.1.34/24"
gateway = "192.168.1.1"
}
}
dns {
servers = ["1.1.1.1"]
}
user_account {
keys = [trimspace(file(pathexpand("~/.ssh/id_ed25519_homelab.pub")))]
}
}
operating_system {
template_file_id = "local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst"
type = "debian"
}
cpu {
cores = 2
}
memory {
dedicated = 4096
swap = 1024
}
disk {
datastore_id = "data"
size = 64
}
# Эталон: features: nesting=1,keyctl=1 плюс /dev/fuse через сырые lxc.*
# строки. fuse=1 — штатная замена этого обхода, см. комментарий файла выше.
features {
nesting = true
keyctl = true
fuse = true
}
# roles/pve_lxc всем контейнерам ставит cmode=shell (pve_lxc_cmode), провайдер
# это отслеживает через блок console.type — без него на следующем apply
# откатит на дефолт Proxmox tty. Обнаружено 2026-09-02 на emergency-bot,
# см. tofu/README.md.
console {
type = "shell"
}
network_interface {
name = "eth0"
bridge = "vmbr0"
firewall = true
}
startup {
order = 100
}
}
output "grimmory" {
description = "Что проверять на узле после apply"
value = {
vmid = proxmox_virtual_environment_container.grimmory.vm_id
node = proxmox_virtual_environment_container.grimmory.node_name
verify = "ssh cloud-pc sudo pct config ${proxmox_virtual_environment_container.grimmory.vm_id}"
}
}
+92
View File
@@ -0,0 +1,92 @@
# ============================================================================
# Сервис №6: gyro (OLD vmid 150, mini-pc).
# Эталон снят 2026-09-02: ssh mini-pc sudo pct config 150.
#
# vm_id 156 — новый VMID для blue-green переезда, TMPIP 192.168.1.15.
# После шага 4.5.15 адрес — боевой 192.168.1.35, OLD (150) остановлен
# и остаётся откатом минимум неделю.
#
# ОСОБЕННОСТИ:
# - Контейнер намеренно без nesting (features: "" в эталоне).
# - Proxmox-фаервол уже перенесён на VMID 156; Datacenter firewall остаётся выключен.
# - Требует Ansible Vault password (make gyro для конфигурации).
# - Старое предупреждение про host key Gitea неверно: gyro пинит github.com,
# поэтому после cutover менять known_hosts не нужно.
# ============================================================================
resource "proxmox_virtual_environment_container" "gyro" {
node_name = "mini-pc"
vm_id = 156
unprivileged = true
start_on_boot = true
started = true
tags = ["tofu"]
initialization {
hostname = "gyro"
ip_config {
ipv4 {
address = "192.168.1.35/24"
gateway = "192.168.1.1"
}
}
dns {
servers = ["1.1.1.1"]
}
user_account {
keys = [trimspace(file(pathexpand("~/.ssh/id_ed25519_homelab.pub")))]
}
}
operating_system {
template_file_id = "local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst"
type = "debian"
}
cpu {
cores = 1
}
memory {
dedicated = 512
swap = 256
}
disk {
datastore_id = "local-lvm"
size = 2
}
# Эталон: features: (пусто) — nesting отключён НАМЕРЕННО, no keyctl, no fuse.
# Блок features не объявляется, чтобы провайдер не создавал его с дефолтами.
# Это консервативное решение: gyro этот функционал не требует.
# roles/pve_lxc всем контейнерам ставит cmode=shell (pve_lxc_cmode), провайдер
# это отслеживает через блок console.type — без него на следующем apply
# откатит на дефолт Proxmox tty. Обнаружено 2026-09-02 на emergency-bot.
console {
type = "shell"
}
network_interface {
name = "eth0"
bridge = "vmbr0"
firewall = true
}
startup {
order = 80
}
}
output "gyro" {
description = "Что проверять на узле после apply"
value = {
vmid = proxmox_virtual_environment_container.gyro.vm_id
node = proxmox_virtual_environment_container.gyro.node_name
verify = "ssh mini-pc sudo pct config ${proxmox_virtual_environment_container.gyro.vm_id}"
}
}
+139
View File
@@ -0,0 +1,139 @@
# ============================================================================
# Сервис №9 по плану (docs/ai/migration-tofu.md, раздел 5): mihomo.
# Локальный прокси + MetaCubeXD UI. На него завязаны egress hermes-ai
# (bash_config_proxy_* в hosts.yml), внешние пробы Uptime Kuma, emergency-бот
# и уведомления gyro — простой затрагивает всё это разом.
#
# OLD vmid 143, узел mini-pc, боевой адрес 192.168.1.27.
# Эталон снят 2026-09-02: ssh mini-pc sudo pct config 143 (дословно совпадает
# с /etc/pve/lxc/143.conf):
#
# arch: amd64
# cmode: shell
# cores: 1
# features: nesting=1,keyctl=1
# hostname: mihomo
# memory: 512
# nameserver: 1.1.1.1
# net0: name=eth0,bridge=vmbr0,firewall=1,gw=192.168.1.1,ip=192.168.1.27/24,type=veth
# onboot: 1
# ostype: debian
# rootfs: local-lvm:vm-143-disk-0,size=8G
# startup: order=70
# swap: 512
# unprivileged: 1
# lxc.cgroup2.devices.allow: c 10:229 rwm # /dev/fuse
# lxc.mount.entry: /dev/fuse dev/fuse none bind,create=file
# lxc.cgroup2.devices.allow: c 10:200 rwm # /dev/net/tun
# lxc.mount.entry: /dev/net/tun dev/net/tun none bind,create=file
#
# vm_id 159 — новый VMID для blue-green переезда (назначен, не менять).
# TMPIP 192.168.1.18 используется только на шаге 4.3. После шага 4.5.15 адрес
# меняется на боевой 192.168.1.27, OLD (143) останавливается и остаётся
# откатом минимум неделю.
#
# --- Два устройства, ДВА разных механизма -----------------------------------
# /dev/fuse -> штатный флаг features.fuse (Docker внутри на fuse-overlayfs).
# /dev/net/tun -> блок device_passthrough (dev0: в PVE 8.2+). mihomo.service
# запускает контейнер с `--device /dev/net/tun` для TUN-режима.
# Это первый боевой сервис, использующий device_passthrough: на пилоте VMID
# 199 механизм проверен (`dev0: deny-write=0,path=/dev/net/tun,...`, повторный
# plan -> No changes), см. tofu/pilot.tf.example и tofu/README.md. Оба требуют
# root@pam; в обычном токенном режиме Proxmox отвечает 403.
# Следствие: pct config покажет `features: fuse=1,...` и `dev0: path=/dev/net/tun`
# вместо сырых lxc.* строк эталона — тот же эффект штатным механизмом.
#
# --- Данные ----------------------------------------------------------------
# /opt/mihomo (~80 КБ): config/config.yaml (реальный конфиг прокси),
# config/cache.db, config/providers/main.yaml. Переносится целиком на шаге
# 4.4. Задача "Install default mihomo config if missing" в pve-mihomo.yml
# идёт с force: false, поэтому перенесённый конфиг не перетирается.
# ============================================================================
resource "proxmox_virtual_environment_container" "mihomo" {
node_name = "mini-pc"
vm_id = 159
unprivileged = true
start_on_boot = true
started = true
tags = ["tofu"]
initialization {
hostname = "mihomo"
ip_config {
ipv4 {
address = "192.168.1.27/24"
gateway = "192.168.1.1"
}
}
dns {
servers = ["1.1.1.1"]
}
user_account {
keys = [trimspace(file(pathexpand("~/.ssh/id_ed25519_homelab.pub")))]
}
}
operating_system {
template_file_id = "local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst"
type = "debian"
}
cpu {
cores = 1
}
memory {
dedicated = 512
swap = 512
}
disk {
datastore_id = "local-lvm"
size = 8
}
# Эталон: features: nesting=1,keyctl=1 плюс /dev/fuse через сырые lxc.*
# строки. fuse=1 — штатная замена этого обхода (см. комментарий выше).
features {
nesting = true
keyctl = true
fuse = true
}
# /dev/net/tun: в эталоне проброшен сырыми lxc.cgroup2.devices.allow +
# lxc.mount.entry. Штатная декларация — device_passthrough (dev0:).
device_passthrough {
path = "/dev/net/tun"
}
# roles/pve_lxc всем контейнерам ставит cmode=shell (pve_lxc_cmode), провайдер
# это отслеживает через блок console.type — без него на следующем apply
# откатит на дефолт Proxmox tty. Обнаружено 2026-09-02 на emergency-bot,
# см. tofu/README.md.
console {
type = "shell"
}
network_interface {
name = "eth0"
bridge = "vmbr0"
firewall = true
}
startup {
order = 70
}
}
output "mihomo" {
description = "Что проверять на узле после apply"
value = {
vmid = proxmox_virtual_environment_container.mihomo.vm_id
node = proxmox_virtual_environment_container.mihomo.node_name
verify = "ssh mini-pc sudo pct config ${proxmox_virtual_environment_container.mihomo.vm_id}"
}
}
+107
View File
@@ -0,0 +1,107 @@
# ============================================================================
# Сервис №5: monitoring (OLD vmid 146, cloud-pc).
# Эталон снят 2026-09-02: ssh cloud-pc sudo pct config 146 (совпадает с
# cat /etc/pve/lxc/146.conf дословно).
#
# vm_id 155 — новый VMID для blue-green переезда, TMPIP 192.168.1.14
# используется только на шаге 4.3 (проверка перед cutover). После шага 4.5.15
# адрес — боевой 192.168.1.30 (тот же, что и у OLD: в этой схеме переезжает
# VMID, а не IP), OLD (146) остановлен и остаётся откатом минимум неделю.
#
# Активный сервис — Uptime Kuma: SQLite в /opt/uptime-kuma/data/kuma.db,
# UI-состояние (мониторы, уведомления) в Ansible не описано, каталог
# переносится целиком на шаге 4.4. Стек Prometheus + Alertmanager + Grafana
# в этом контейнере ЗАМОРОЖЕН (docs/ai/legacy-warning.md) — его данные лежат
# отдельно в /opt/monitoring (в основном TSDB Prometheus) и в новый контейнер
# намеренно НЕ переносятся; решение о судьбе замороженного стека отдельное,
# не часть этого переезда.
#
# ОСОБЕННОСТЬ features: реестр (services.yml) отмечает, что роль создаёт
# контейнер с nesting=1, а keyctl=1 добавляет отдельной задачей `pct set 146
# --features nesting=1,keyctl=1` уже после создания (playbooks/pve-monitoring.yml).
# Здесь воспроизведён ЭТАЛОН как он есть на живом контейнере — оба флага сразу,
# декларативно, без промежуточного шага: в привилегированном режиме root@pam
# (см. tofu/README.md, «Решение: root@pam по паролю») дошаг `pct set` для этого
# и остальных сервисов больше не нужен, features применяются одной фазой.
# ============================================================================
resource "proxmox_virtual_environment_container" "monitoring" {
node_name = "cloud-pc"
vm_id = 155
unprivileged = true
start_on_boot = true
started = true
tags = ["tofu"]
initialization {
hostname = "monitoring"
ip_config {
ipv4 {
address = "192.168.1.30/24"
gateway = "192.168.1.1"
}
}
dns {
servers = ["1.1.1.1"]
}
user_account {
keys = [trimspace(file(pathexpand("~/.ssh/id_ed25519_homelab.pub")))]
}
}
operating_system {
template_file_id = "local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst"
type = "debian"
}
cpu {
cores = 2
}
memory {
dedicated = 4096
swap = 512
}
disk {
datastore_id = "data"
size = 24
}
# Эталон: features: nesting=1,keyctl=1 — см. ОСОБЕННОСТЬ в комментарии выше.
# Ни fuse, ни device passthrough эталону не нужны (dev0 в pct config нет).
features {
nesting = true
keyctl = true
}
# roles/pve_lxc всем контейнерам ставит cmode=shell (pve_lxc_cmode), провайдер
# это отслеживает через блок console.type — без него на следующем apply
# откатит на дефолт Proxmox tty. Обнаружено 2026-09-02 на emergency-bot,
# см. tofu/README.md.
console {
type = "shell"
}
network_interface {
name = "eth0"
bridge = "vmbr0"
firewall = true
}
startup {
order = 80
}
}
output "monitoring" {
description = "Что проверять на узле после apply"
value = {
vmid = proxmox_virtual_environment_container.monitoring.vm_id
node = proxmox_virtual_environment_container.monitoring.node_name
verify = "ssh cloud-pc sudo pct config ${proxmox_virtual_environment_container.monitoring.vm_id}"
}
}
+141
View File
@@ -0,0 +1,141 @@
# ============================================================================
# Сервис №10 по плану (docs/ai/migration-tofu.md, раздел 5): ovpn-mini.
# Терминирует OpenVPN-туннель ru-vps <-> LAN (gateway-конец, 10.78.0.2).
# Через него идёт ProxyJump ко всем LXC вне LAN и обратный путь reverse-proxy.
# ПЕРЕЕЗД ТОЛЬКО ИЗ ЛОКАЛЬНОЙ СЕТИ: вне LAN прогон оборвёт себе транспорт.
#
# OLD vmid 132, узел mini-pc, боевой адрес 192.168.1.23.
# Эталон снят 2026-09-02: ssh mini-pc sudo pct config 132 (дословно совпадает
# с /etc/pve/lxc/132.conf):
#
# arch: amd64
# cmode: shell
# cores: 1
# features: nesting=1
# hostname: ovpn-mini
# memory: 256
# nameserver: 1.1.1.1
# net0: name=eth0,bridge=vmbr0,firewall=1,gw=192.168.1.1,ip=192.168.1.23/24,type=veth
# onboot: 1
# ostype: debian
# rootfs: local-lvm:vm-132-disk-0,size=8G
# startup: order=30
# swap: 128
# unprivileged: 1
# lxc.cgroup2.devices.allow: c 10:200 rwm # /dev/net/tun
# lxc.mount.entry: /dev/net/tun dev/net/tun none bind,create=file
#
# vm_id 160 — новый VMID для blue-green переезда (назначен, не менять).
# TMPIP 192.168.1.19 используется только на шаге 4.3. После шага 4.5.15 адрес
# меняется на боевой 192.168.1.23, OLD (132) останавливается и остаётся
# откатом минимум неделю.
#
# --- features: только nesting ---------------------------------------------
# Эталон: features: nesting=1 — ни keyctl, ни fuse. Оставлено как есть:
# OpenVPN-шлюзу этот функционал не нужен.
#
# --- /dev/net/tun: device_passthrough ------------------------------------
# В эталоне проброшен сырыми lxc.cgroup2.devices.allow + lxc.mount.entry
# (play "Allow TUN device" в pve-ovpn-mini.yml). Штатная декларация —
# device_passthrough (dev0:), проверена на пилоте VMID 199 и на mihomo.
# Требует root@pam.
#
# --- Конфигурация и данные ----------------------------------------------
# pve-ovpn-mini.yml конфигурационной части НЕ содержит (только создание +
# проброс TUN). Настраивает playbooks/openvpn-vps-mini.yml (роль
# openvpn_gateway, группа vpn_openvpn = ru-vps + ovpn-mini).
# Единственные данные для переноса: /etc/openvpn/homelab/static.key —
# общий статический ключ с ru-vps. Остальное (homelab.conf, up.sh, down.sh,
# systemd-юнит) роль рендерит заново. LAN-адрес ovpn-mini ни в одном шаблоне
# роли не фигурирует (masquerade идёт по `-o eth0`), поэтому туннель не
# зависит от смены адреса — меняется только VMID.
#
# --- roles/pve_lxc -----------------------------------------------------
# До этого переезда ovpn-mini был единственным потребителем roles/pve_lxc.
# После него роль не подключена нигде — кандидат на удаление, см.
# docs/ai/migration-tofu.md раздел 6.
# ============================================================================
resource "proxmox_virtual_environment_container" "ovpn_mini" {
node_name = "mini-pc"
vm_id = 160
unprivileged = true
start_on_boot = true
started = true
tags = ["tofu"]
initialization {
hostname = "ovpn-mini"
ip_config {
ipv4 {
address = "192.168.1.23/24"
gateway = "192.168.1.1"
}
}
dns {
servers = ["1.1.1.1"]
}
user_account {
keys = [trimspace(file(pathexpand("~/.ssh/id_ed25519_homelab.pub")))]
}
}
operating_system {
template_file_id = "local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst"
type = "debian"
}
cpu {
cores = 1
}
memory {
dedicated = 256
swap = 128
}
disk {
datastore_id = "local-lvm"
size = 8
}
# Эталон: features: nesting=1 (без keyctl/fuse).
features {
nesting = true
}
# /dev/net/tun: в эталоне — сырые lxc.* строки. Штатная декларация dev0:.
device_passthrough {
path = "/dev/net/tun"
}
# roles/pve_lxc всем контейнерам ставит cmode=shell (pve_lxc_cmode), провайдер
# это отслеживает через блок console.type — без него на следующем apply
# откатит на дефолт Proxmox tty. Обнаружено 2026-09-02 на emergency-bot,
# см. tofu/README.md.
console {
type = "shell"
}
network_interface {
name = "eth0"
bridge = "vmbr0"
firewall = true
}
startup {
order = 30
}
}
output "ovpn_mini" {
description = "Что проверять на узле после apply"
value = {
vmid = proxmox_virtual_environment_container.ovpn_mini.vm_id
node = proxmox_virtual_environment_container.ovpn_mini.node_name
verify = "ssh mini-pc sudo pct config ${proxmox_virtual_environment_container.ovpn_mini.vm_id}"
}
}
+109
View File
@@ -0,0 +1,109 @@
# ============================================================================
# Сервис №4: vaultwarden (OLD vmid 140, mini-pc).
# Эталон снят 2026-09-02: ssh mini-pc sudo pct config 140 / cat /etc/pve/lxc/140.conf.
# Данные критичны: SQLite /opt/vaultwarden/data/db.sqlite3 (плюс config.json,
# rsa_key.pem, icon_cache/). Переносятся вручную на шаге 4.4, а не этим
# ресурсом — контейнер создаётся пустым, данные переезжают отдельной
# процедурой (см. docs/ai/migration-tofu.md, п.5.4).
#
# vm_id 154. TMPIP 192.168.1.13 отработал на шагах 4.3 и 4.4; cutover выполнен
# 2026-09-02, адрес боевой — 192.168.1.24. Данные перенесены и сверены:
# integrity_check ok, 1 users / 314 ciphers совпали с боевыми.
# OLD (140) остановлен и остаётся откатом минимум неделю, до 2026-09-09
# (docs/ai/migration-tofu.md, инвариант №2).
# ============================================================================
resource "proxmox_virtual_environment_container" "vaultwarden" {
node_name = "mini-pc"
vm_id = 154
unprivileged = true
start_on_boot = true
started = true
tags = ["tofu"]
initialization {
hostname = "vaultwarden"
ip_config {
ipv4 {
address = "192.168.1.24/24"
gateway = "192.168.1.1"
}
}
dns {
servers = ["1.1.1.1"]
}
user_account {
keys = [trimspace(file(pathexpand("~/.ssh/id_ed25519_homelab.pub")))]
}
}
operating_system {
template_file_id = "local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst"
type = "debian"
}
cpu {
cores = 2
}
memory {
dedicated = 1024
swap = 512
}
disk {
datastore_id = "local-lvm"
size = 16
}
# Эталон: features: nesting=1,keyctl=1 плюс /dev/fuse, проброшенный вручную
# двумя строками в /etc/pve/lxc/140.conf (lxc.cgroup2.devices.allow c 10:229
# rwm + lxc.mount.entry) — Proxmox API сырые lxc.* ключи не принимает,
# поэтому pve-vaultwarden.yml добавляет их через lineinfile.
#
# ЗАМЕНА ЭТОГО ОБХОДА — features.fuse, а НЕ device_passthrough.
# device_passthrough (dev0:) нужен только для сырых character-устройств
# вида /dev/net/tun. Для /dev/fuse у PVE есть штатный флаг, и именно он
# проверен на пилоте VMID 199 — результат был
# `features: fuse=1,keyctl=1,nesting=1`, см. tofu/pilot.tf.example:17-21
# и tofu/README.md. Формулировка «device_passthrough для каждого устройства»
# в migration-tofu.md п.4.2 слишком широкая; она уточнена там же.
#
# Следствие: pct config нового контейнера покажет
# `features: fuse=1,keyctl=1,nesting=1` — текстуально иначе, чем эталон,
# но это тот же эффект штатным механизмом вместо обхода.
features {
nesting = true
keyctl = true
fuse = true
}
# roles/pve_lxc всем контейнерам ставит cmode=shell (pve_lxc_cmode), провайдер
# это отслеживает через блок console.type — без него на следующем apply
# откатит на дефолт Proxmox tty (найдено на emergency-bot, см. tofu/README.md).
console {
type = "shell"
}
network_interface {
name = "eth0"
bridge = "vmbr0"
firewall = true
}
startup {
order = 40
}
}
output "vaultwarden" {
description = "Что проверять на узле после apply"
value = {
vmid = proxmox_virtual_environment_container.vaultwarden.vm_id
node = proxmox_virtual_environment_container.vaultwarden.node_name
verify = "ssh mini-pc sudo pct config ${proxmox_virtual_environment_container.vaultwarden.vm_id}"
}
}
+42
View File
@@ -0,0 +1,42 @@
# Значения приходят из корневого .env через Make (цели tofu-* в ansible/Makefile),
# который перекладывает PROXMOX_* в TF_VAR_*. Отдельного файла с секретами нет.
variable "pve_endpoint" {
description = "URL Proxmox API. Цели tofu-* направляют его в локальный конец SSH-туннеля."
type = string
}
# --- Аутентификация ---------------------------------------------------------
# Ровно один из двух способов, выбор делает Makefile:
#
# токен ansible@pve — обычный режим. Не может features кроме nesting,
# device passthrough и bind mount каталога хоста.
# root@pam + пароль — привилегированный режим. Проверка в Proxmox буквальная
# (`$authuser eq 'root@pam'`), поэтому токен, даже
# принадлежащий root, её не проходит — нужен именно пароль.
variable "pve_api_token" {
description = "Токен в формате user@realm!tokenid=secret. Пустая строка — не использовать."
type = string
sensitive = true
default = ""
}
variable "pve_username" {
description = "Пользователь для парольной аутентификации, обычно root@pam. Пустая строка — не использовать."
type = string
default = ""
}
variable "pve_password" {
description = "Пароль root@pam. Пустая строка — не использовать."
type = string
sensitive = true
default = ""
}
variable "pve_insecure" {
description = "Не проверять TLS-сертификат Proxmox"
type = bool
default = true
}
+10
View File
@@ -0,0 +1,10 @@
terraform {
required_version = ">= 1.6"
required_providers {
proxmox = {
source = "bpg/proxmox"
version = "~> 0.84"
}
}
}