From 9725d3ea7cdce6c02c65a4e41ca4cf607868b757 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Wed, 26 Aug 2026 22:10:16 +0300 Subject: [PATCH] Add service registry, shared roles and unified reverse proxy Collect the facts about all 14 services -- VMID, node, address, ports, domain, pinned images, resources, backup and monitoring participation -- into group_vars/all/services.yml. Values are taken from the existing playbooks; gaps are marked null rather than invented. Replace reverse-proxy-{gitea,vaultwarden,grimmory}.yml with a single playbook iterating over registry entries that declare a domain. It keeps every check the three had, preserves grimmory's richer Caddy block byte-for-byte, and restarts Caddy once when any site changed instead of up to three times. Verified with --check --diff against ru-vps: ok=6 changed=0, so it reproduces the current Caddyfile exactly. Add two roles factoring out the skeleton duplicated across the pve-* playbooks: lxc_docker_host (packages, /dev/fuse assertion, fuse-overlayfs storage driver, UFW baseline) and compose_service (compose file, systemd unit, config validation, health check). They are not wired into any playbook yet -- migrating a live service is a separate, per-service step; compose_service/README.md shows the Gitea example and spells out what actually changes on the host. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GTocXkGUUazHdKKd3r9k71 --- ansible/inventory/group_vars/all/services.yml | 605 ++++++++++++++++++ ansible/playbooks/reverse-proxy-gitea.yml | 65 -- ansible/playbooks/reverse-proxy-grimmory.yml | 88 --- .../playbooks/reverse-proxy-vaultwarden.yml | 65 -- ansible/playbooks/reverse-proxy.yml | 182 ++++++ ansible/roles/README.md | 47 ++ ansible/roles/compose_service/README.md | 133 ++++ .../roles/compose_service/defaults/main.yml | 70 ++ .../roles/compose_service/handlers/main.yml | 7 + ansible/roles/compose_service/meta/main.yml | 4 + ansible/roles/compose_service/tasks/main.yml | 173 +++++ ansible/roles/lxc_docker_host/README.md | 83 +++ .../roles/lxc_docker_host/defaults/main.yml | 82 +++ ansible/roles/lxc_docker_host/meta/main.yml | 4 + ansible/roles/lxc_docker_host/tasks/main.yml | 113 ++++ 15 files changed, 1503 insertions(+), 218 deletions(-) create mode 100644 ansible/inventory/group_vars/all/services.yml delete mode 100644 ansible/playbooks/reverse-proxy-gitea.yml delete mode 100644 ansible/playbooks/reverse-proxy-grimmory.yml delete mode 100644 ansible/playbooks/reverse-proxy-vaultwarden.yml create mode 100644 ansible/playbooks/reverse-proxy.yml create mode 100644 ansible/roles/README.md create mode 100644 ansible/roles/compose_service/README.md create mode 100644 ansible/roles/compose_service/defaults/main.yml create mode 100644 ansible/roles/compose_service/handlers/main.yml create mode 100644 ansible/roles/compose_service/meta/main.yml create mode 100644 ansible/roles/compose_service/tasks/main.yml create mode 100644 ansible/roles/lxc_docker_host/README.md create mode 100644 ansible/roles/lxc_docker_host/defaults/main.yml create mode 100644 ansible/roles/lxc_docker_host/meta/main.yml create mode 100644 ansible/roles/lxc_docker_host/tasks/main.yml diff --git a/ansible/inventory/group_vars/all/services.yml b/ansible/inventory/group_vars/all/services.yml new file mode 100644 index 0000000..3e62ab9 --- /dev/null +++ b/ansible/inventory/group_vars/all/services.yml @@ -0,0 +1,605 @@ +--- +# ============================================================================ +# homelab_services — декларативный реестр сервисов HomeLab +# ============================================================================ +# +# ЧТО ЭТО +# Единственное место, где собраны факты о каждом сервисе домашней лаборатории: +# VMID, узел Proxmox, адрес, порты, публичный домен, закреплённые образы +# с digest, ресурсы LXC, схема резервного копирования, мониторинг и порядок +# автозапуска. +# +# ВСЕ значения взяты из существующего кода (ansible/playbooks/*.yml, +# ansible/roles/*, ansible/inventory/hosts.yml). Ничего не выдумано. +# Там, где факта в коде нет, стоит комментарий "# нет в коде", а не догадка. +# +# КТО ЭТО ПОТРЕБЛЯЕТ (на текущем этапе) +# * ansible/playbooks/reverse-proxy.yml — итерируется по сервисам, +# у которых задан блок `proxy`, и собирает Caddyfile на ru-vps. +# * Человек — как справочник вместо чтения семи playbook'ов по 200-400 строк. +# +# Существующие pve-*.yml пока НЕ читают этот реестр: их перевод на +# homelab_services + роли lxc_docker_host/compose_service — отдельный этап. +# До тех пор реестр и pve-*.yml нужно править согласованно. +# +# ЧТО ПРАВИТЬ ПРИ ДОБАВЛЕНИИ НОВОГО СЕРВИСА +# 1. Добавить запись в homelab_services ниже (все обязательные поля). +# 2. Если сервис публикуется наружу — заполнить блок `proxy` +# (domain, upstream, caddy_marker, caddy_container_check, health). +# Больше ничего для reverse-proxy делать не нужно. +# 3. Хост в ansible/inventory/hosts.yml (+ группы monitoring_exporters и др.). +# 4. Задание бэкапа в ansible/playbooks/pve-backup-jobs.yml +# и VMID в roles/backup_audit/defaults/main.yml. +# 5. Плейбук развёртывания (pve-.yml). +# +# СОГЛАШЕНИЯ ПО ПОЛЯМ +# vmid int, уникальный в кластере +# node cloud-pc | mini-pc — узел Proxmox, где живёт контейнер +# ip адрес в LAN без маски +# hostname имя LXC и имя хоста в inventory +# role короткое человеческое описание +# provisioner чем создаётся контейнер: +# pct_ssh — прямой `pct create` по SSH на узле PVE +# pve_lxc — роль roles/pve_lxc через Proxmox API +# unmanaged — создан вручную, в репозитории нет плейбука +# lxc ресурсы и параметры контейнера (as-created) +# ports опубликованные порты сервиса +# images закреплённые образы с digest (пустой список — образ не +# используется или собирается локально) +# backup pbs / restic / none — фактическая схема из pve-backup-jobs.yml +# и offsite-restic-yadisk.yml +# monitoring node_exporter — состоит ли хост в группе monitoring_exporters; +# blackbox — пробится ли снаружи из roles/monitoring_blackbox +# proxy присутствует только у сервисов с публичным доменом +# ============================================================================ + +# Хост, на котором стоит Caddy и терминируется публичный HTTPS. +homelab_reverse_proxy_host: ru-vps +homelab_reverse_proxy_dir: /opt/services/ru-vps/caddy +homelab_reverse_proxy_caddyfile: /opt/services/ru-vps/caddy/Caddyfile +# ВНИМАНИЕ: сам контейнер Caddy на ru-vps ansible'ом НЕ управляется — +# в репозитории нет плейбука его установки. Управляется только Caddyfile. +homelab_reverse_proxy_container: caddy + +# Адрес центрального хоста мониторинга (источник scrape для node-exporter). +homelab_monitoring_host_ip: 192.168.1.30 + +homelab_services: + + # -------------------------------------------------------------------------- + pbs: + vmid: 120 + node: cloud-pc + ip: 192.168.1.20 + hostname: pbs + role: Proxmox Backup Server + provisioner: unmanaged # плейбука создания в репозитории нет + lxc: + cores: null # нет в коде + memory: null # нет в коде + swap: null # нет в коде + disk: null # нет в коде + startup: null # нет в коде + features: null # нет в коде + unprivileged: null # нет в коде + ports: + - {name: pbs-api, port: 8007, proto: tcp} # стандартный порт PBS + images: [] + backup: + kind: pbs-local + # pve-backup-jobs.yml: job homelab-local-weekly-pbs, хранилище "backup" + job: homelab-local-weekly-pbs + schedule: "Sun 03:30" + storage: backup + prune: keep-last=2 + monitoring: + node_exporter: true + blackbox: false + backup_audit_vmid: false # 120 отсутствует в backup_audit_pbs_vmids + + # -------------------------------------------------------------------------- + ovpn-mini: + vmid: 132 + node: mini-pc + ip: 192.168.1.23 + hostname: ovpn-mini + role: OpenVPN-шлюз в LAN (клиент ru-vps) + provisioner: pve_lxc # playbooks/pve-ovpn-mini.yml + lxc: + cores: 1 # из roles/pve_lxc/defaults + memory: 256 # из roles/pve_lxc/defaults + swap: 128 # из roles/pve_lxc/defaults + disk: local-lvm:8 + startup: order=30 # из roles/pve_lxc/defaults + features: "nesting=1" + unprivileged: true + devices: ["/dev/net/tun"] + ports: + - {name: openvpn, port: 8443, proto: tcp, note: "туннель к ru-vps 10.78.0.1"} + images: [] + backup: + kind: pbs + job: homelab-pbs-daily-mini + schedule: "02:40" + storage: pbs + monitoring: + node_exporter: true + blackbox: false + backup_audit_vmid: true + + # -------------------------------------------------------------------------- + vaultwarden: + vmid: 140 + node: mini-pc + ip: 192.168.1.24 + hostname: vaultwarden + role: Менеджер паролей Vaultwarden + provisioner: pct_ssh # playbooks/pve-vaultwarden.yml + lxc: + cores: 2 + memory: 1024 + swap: 512 + disk: local-lvm:16 + startup: order=40 + features: "nesting=1,keyctl=1" + unprivileged: true + ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst + devices: ["/dev/fuse"] + data_dir: /opt/vaultwarden/data + ports: + - {name: http, port: 80, proto: tcp} + images: + - vaultwarden/server:1.37.1@sha256:e9efdf001bf0d68c21f2cbfb8e1d9b5961a7ca9c85e0a7e58bf51a13b997d744 + runtime: docker-run-systemd # /etc/systemd/system/vaultwarden.service + backup: + kind: pbs+restic + job: homelab-pbs-daily-mini + schedule: "02:40" + storage: pbs + restic: + profile: vaultwarden + repository: rclone:yadisk:System/Backups/HomeLab/restic/vaultwarden + source_path: /opt/vaultwarden/data + schedule: "*-*-* 04:45:00" + sqlite_db: /opt/vaultwarden/data/db.sqlite3 + monitoring: + node_exporter: true + blackbox: true + backup_audit_vmid: true + proxy: + domain: pass.ada-dev.ru + upstream: 192.168.1.24:80 + caddy_marker: "# {mark} ANSIBLE MANAGED VAULTWARDEN SITE" + # caddy_body не задан -> используется простой `reverse_proxy ` + caddy_container_check: "reverse_proxy 192.168.1.24:80" + # Устаревшая секция от миграции с 10.122.62.95. Удалить это поле + # можно после подтверждённого прогона reverse-proxy.yml на ru-vps. + caddy_legacy_regexp: '(?ms)^pass\.ada-dev\.ru \{\n\s*reverse_proxy 10\.122\.62\.95:10380\n\}\n+' + health: + path: / + status_code: [200] + follow_redirects: none # соответствует `curl -fsS` без -L в старом плейбуке + + # -------------------------------------------------------------------------- + gitea: + vmid: 141 + node: cloud-pc + ip: 192.168.1.25 + hostname: gitea + role: Git-хостинг Gitea + provisioner: pct_ssh # playbooks/pve-gitea.yml + lxc: + cores: 2 + memory: 2048 + swap: 1024 + disk: data:32 + startup: order=50 + features: "nesting=1,keyctl=1" + unprivileged: true + ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst + devices: ["/dev/fuse"] + mounts: + # mp0 на cloud-pc; каталог на хосте принадлежит uid/gid 101000 + - {host_path: /opt/data/gitea, container_path: /opt/gitea/data, host_uid: 101000, host_gid: 101000} + data_dir: /opt/gitea/data + ports: + - {name: http, port: 3000, proto: tcp} + - {name: ssh, port: 2222, proto: tcp, container_port: 22} + images: + - gitea/gitea:1.27.1@sha256:b64126cf5c3f4e5f0f231b510bb13715f6cb8e508188b44de90bdb9a04f3055d + runtime: docker-run-systemd # /etc/systemd/system/gitea.service + backup: + kind: pbs+restic + job: homelab-pbs-daily-cloud + schedule: "02:10" + storage: pbs + restic: + profile: gitea + # restic-профиль выполняется на cloud-pc, а не внутри LXC + run_on: cloud-pc + repository: rclone:yadisk:System/Backups/HomeLab/restic/gitea + source_path: /opt/data/gitea + schedule: "*-*-* 04:15:00" + sqlite_db: /opt/data/gitea/gitea/gitea.db + monitoring: + node_exporter: true + blackbox: true + backup_audit_vmid: true + proxy: + domain: git.ada-dev.ru + upstream: 192.168.1.25:3000 + caddy_marker: "# {mark} ANSIBLE MANAGED GITEA SITE" + caddy_container_check: "reverse_proxy 192.168.1.25:3000" + # Устаревшая секция от миграции с 10.122.62.51. Удалить это поле + # можно после подтверждённого прогона reverse-proxy.yml на ru-vps. + caddy_legacy_regexp: '(?ms)^git\.ada-dev\.ru \{\n\s*reverse_proxy 10\.122\.62\.51:3002\n\}\n+' + health: + path: / + status_code: [200] + 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: + vmid: 143 + node: mini-pc + ip: 192.168.1.27 + hostname: mihomo + role: Локальный прокси Mihomo + веб-интерфейс MetaCubeXD + provisioner: pct_ssh # playbooks/pve-mihomo.yml + lxc: + cores: 1 + memory: 512 + swap: 512 + disk: local-lvm:8 + startup: order=70 + features: "nesting=1,keyctl=1" + unprivileged: true + ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst + devices: ["/dev/fuse", "/dev/net/tun"] + data_dir: /opt/mihomo + ports: + - {name: mixed, port: 7890, proto: tcp} + - {name: socks, port: 7891, proto: tcp} + - {name: controller, port: 9090, proto: tcp} + - {name: ui, port: 8080, proto: tcp, container_port: 80} + images: + - metacubex/mihomo:v1.19.29@sha256:5e7bcc5e7a866afcc8b007ef827c9ba773f2f34b6d7311b6d39ed1751f37cfd5 + - ghcr.io/metacubex/metacubexd:v1.270.6@sha256:156d55be885d4ba6254d840bd781b715c20c00afee6e6c24c76be4cfe5eb89d4 + runtime: docker-run-systemd # mihomo.service + mihomo-ui.service + backup: + kind: pbs + job: homelab-pbs-daily-mini + schedule: "02:40" + storage: pbs + monitoring: + node_exporter: true + blackbox: false + backup_audit_vmid: true + + # -------------------------------------------------------------------------- + adguard: + vmid: 144 + node: mini-pc + ip: 192.168.1.28 + hostname: adguard + role: AdGuard Home — DNS с фильтрацией + provisioner: pct_ssh # playbooks/pve-adguard.yml + lxc: + cores: 1 + memory: 512 + swap: 512 + disk: local-lvm:8 + startup: order=40 + features: "nesting=1,keyctl=1" + unprivileged: true + ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst + devices: ["/dev/fuse"] + data_dir: /opt/adguard + ports: + - {name: dns-tcp, port: 53, proto: tcp} + - {name: dns-udp, port: 53, proto: udp} + - {name: http, port: 80, proto: tcp} + - {name: setup, port: 3000, proto: tcp} + images: + - adguard/adguardhome:v0.107.78@sha256:2c127294fa5f96151d9d3a433fb9d66c17e4d18cf698c2b04372a80e26fdd26f + runtime: docker-run-systemd # /etc/systemd/system/adguard.service + backup: + kind: pbs + job: homelab-pbs-daily-mini + schedule: "02:40" + storage: pbs + monitoring: + node_exporter: true + blackbox: false + backup_audit_vmid: true + + # -------------------------------------------------------------------------- + docker-test: + vmid: 145 + node: cloud-pc + ip: 192.168.1.29 + hostname: docker-test + role: Песочница для проверки Docker в непривилегированном LXC + provisioner: pct_ssh # playbooks/pve-docker-test.yml + lxc: + cores: 1 + memory: 512 + swap: 512 + disk: data:8 + startup: order=50 + features: "nesting=1,keyctl=1" + unprivileged: true + ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst + devices: ["/dev/fuse"] + ports: [] + images: [] # используется только hello-world для smoke-теста + backup: + kind: pbs + job: homelab-pbs-daily-cloud + schedule: "02:10" + storage: pbs + monitoring: + node_exporter: false # хоста нет в группе monitoring_exporters + blackbox: false + backup_audit_vmid: true + + # -------------------------------------------------------------------------- + monitoring: + vmid: 146 + node: cloud-pc + ip: 192.168.1.30 + hostname: monitoring + role: Prometheus + Alertmanager + Grafana + blackbox + pve-exporter + Uptime Kuma + provisioner: pve_lxc # playbooks/pve-monitoring.yml + lxc: + cores: 2 + memory: 4096 + swap: 512 + disk: data:24 + startup: order=80 + # создаётся с nesting=1, keyctl=1 добавляется отдельной задачей `pct set` + features: "nesting=1,keyctl=1" + unprivileged: true + ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst + data_dir: /opt/monitoring + ports: + - {name: grafana, port: 3000, proto: tcp, bind: 192.168.1.30} + - {name: pushgateway, port: 9091, proto: tcp, bind: 192.168.1.30} + - {name: uptime-kuma, port: 3001, proto: tcp, bind: 192.168.1.30} + images: + # без digest — так закреплено в roles/monitoring_server/templates/compose.yml.j2 + - prom/prometheus:v3.2.1 + - prom/alertmanager:v0.28.0 + - grafana/grafana:11.5.1 + - prom/blackbox-exporter:v0.25.0 + - prompve/prometheus-pve-exporter:3.5.5 + - prom/pushgateway:v1.11.0 + - louislam/uptime-kuma:1.23.16@sha256:431fee3be822b04861cf0e35daf4beef6b7cb37391c5f26c3ad6e12ce280fe18 + runtime: docker-compose-systemd + backup: + kind: pbs + job: homelab-pbs-daily-cloud + schedule: "02:10" + storage: pbs + monitoring: + node_exporter: true + blackbox: false + backup_audit_vmid: true + + # -------------------------------------------------------------------------- + hermes-ai: + vmid: 147 + node: cloud-pc + ip: 192.168.1.31 + hostname: hermes-ai + role: Хост под приложение Hermes + прозрачный TUN-прокси через mihomo + provisioner: pct_ssh # playbooks/pve-hermes-ai.yml + lxc: + cores: 2 + memory: 4096 + swap: 512 + disk: data:24 + startup: order=90 + features: "nesting=1,keyctl=1" + unprivileged: true + ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst + devices: ["/dev/fuse", "/dev/net/tun"] + data_dir: /srv/hermes-ai + ports: [] # публикация портов запрещена: Docker обходит UFW + images: + - metacubex/mihomo@sha256:e6acd921addecfd59a8e2d38203f88356d635b54de6c0673db0e015139989312 + runtime: docker-run-systemd # hermes-ai-tun-proxy.service + backup: + kind: pbs + job: homelab-pbs-daily-cloud + schedule: "02:10" + storage: pbs + monitoring: + node_exporter: false # хоста нет в группе monitoring_exporters + blackbox: false + backup_audit_vmid: true + + # -------------------------------------------------------------------------- + emergency-bot: + vmid: 148 + node: mini-pc + ip: 192.168.1.32 + hostname: emergency-bot + role: Telegram-бот аварийного доступа + provisioner: pve_lxc # playbooks/pve-emergency-bot.yml + lxc: + cores: 1 + memory: 512 + swap: 256 + disk: local-lvm:4 + startup: order=70 + features: "nesting=1" # из roles/pve_lxc/defaults + unprivileged: true + ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst + ports: [] + images: [] # роль emergency_bot образы не закрепляет + backup: + kind: none # VMID 148 отсутствует в pve-backup-jobs.yml — бэкапа нет + monitoring: + node_exporter: false # хоста нет в группе monitoring_exporters + blackbox: false + backup_audit_vmid: false + + # -------------------------------------------------------------------------- + grimmory: + vmid: 149 + node: cloud-pc + ip: 192.168.1.34 + hostname: grimmory + role: Библиотека книг Grimmory (+ MariaDB), OPDS/KOReader + provisioner: pve_lxc # playbooks/pve-grimmory.yml + lxc: + cores: 2 + memory: 4096 + swap: 1024 + disk: data:64 + startup: order=100 + # создаётся с nesting=1, keyctl=1 добавляется отдельной задачей `pct set` + features: "nesting=1,keyctl=1" + unprivileged: true + ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst + devices: ["/dev/fuse"] + data_dir: /opt/grimmory + ports: + - {name: http, port: 6060, proto: tcp, bind: 192.168.1.34} + images: + - grimmory/grimmory:v3.2.4@sha256:dfa7afdfcf25d649fd664497a62385dd00cd9678c37546e182c172e41c8e80cb + - lscr.io/linuxserver/mariadb:11.4.8@sha256:91de7f701bc7fc3a424b81beafca7a7c6c4c5b7c8be6afd2ae148698695c0b0c + runtime: docker-compose-systemd # grimmory.service + grimmory-docker-firewall.service + backup: + kind: pbs+restic + job: homelab-pbs-daily-cloud + schedule: "02:10" + storage: pbs + restic: + profile: grimmory + repository: rclone:yadisk:System/Backups/HomeLab/restic/grimmory + source_path: /opt/grimmory + schedule: "*-*-* 05:15:00" + mariadb_container: grimmory-mariadb + mariadb_database: grimmory + monitoring: + node_exporter: true + blackbox: true + backup_audit_vmid: true + proxy: + domain: books.ada-dev.ru + upstream: 192.168.1.34:6060 + caddy_marker: "# {mark} ANSIBLE MANAGED GRIMMORY SITE" + # Тело секции Caddy целиком: OPDS/KOReader требуют отключить сжатие + # и принудительно выставить Accept, иначе читалки не понимают ответ. + # Апстрим в теле обязан совпадать с полем `upstream` выше — + # reverse-proxy.yml это проверяет assert'ом. + caddy_body: | + @grimmory_opds_atom path /api/v1/opds /api/v1/opds/libraries /api/v1/opds/shelves /api/v1/opds/magic-shelves /api/v1/opds/authors /api/v1/opds/series /api/v1/opds/catalog /api/v1/opds/recent /api/v1/opds/surprise + handle @grimmory_opds_atom { + reverse_proxy 192.168.1.34:6060 { + header_up Accept "application/atom+xml" + header_up Accept-Encoding identity + transport http { + compression off + } + } + } + + @grimmory_opds_search path /api/v1/opds/search.opds + handle @grimmory_opds_search { + reverse_proxy 192.168.1.34:6060 { + header_up Accept "application/opensearchdescription+xml" + header_up Accept-Encoding identity + transport http { + compression off + } + } + } + + @grimmory_device_api path /api/koreader /api/koreader/* /api/v1/opds /api/v1/opds/* + handle @grimmory_device_api { + reverse_proxy 192.168.1.34:6060 { + header_up Accept-Encoding identity + transport http { + compression off + } + } + } + + handle { + reverse_proxy 192.168.1.34:6060 + } + # Проверка бинд-маунта внутри контейнера caddy: у grimmory ищем маркер + # маршрутизации, а не строку reverse_proxy — она встречается многократно. + caddy_container_check: "@grimmory_device_api path /api/koreader" + # caddy_legacy_regexp отсутствует: в старом reverse-proxy-grimmory.yml + # секции удаления legacy-конфига не было. + health: + path: /api/v1/healthcheck + status_code: [200] + follow_redirects: safe # поведение ansible.builtin.uri по умолчанию + + # -------------------------------------------------------------------------- + gyro: + vmid: 150 + node: mini-pc + ip: 192.168.1.35 + hostname: gyro + role: Изолированный контейнер под задачу gyro (доступ в сеть только через mihomo) + provisioner: pve_lxc # playbooks/pve-gyro.yml + lxc: + cores: 1 + memory: 512 + swap: 256 + disk: local-lvm:2 + startup: order=80 + features: "" # pve_lxc_features: [] — nesting отключён намеренно + unprivileged: true + ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst + firewall: proxmox # /etc/pve/firewall/150.fw, policy_in DROP + ports: [] + images: [] # роль gyro образы не закрепляет + backup: + kind: pbs + job: homelab-pbs-daily-mini + schedule: "02:40" + storage: pbs + monitoring: + node_exporter: false # хоста нет в группе monitoring_exporters + blackbox: false + backup_audit_vmid: true diff --git a/ansible/playbooks/reverse-proxy-gitea.yml b/ansible/playbooks/reverse-proxy-gitea.yml deleted file mode 100644 index 59eb559..0000000 --- a/ansible/playbooks/reverse-proxy-gitea.yml +++ /dev/null @@ -1,65 +0,0 @@ -- name: Configure Gitea reverse proxy on ru-vps - hosts: ru-vps - gather_facts: false - vars: - caddy_dir: /opt/services/ru-vps/caddy - caddyfile_path: /opt/services/ru-vps/caddy/Caddyfile - gitea_domain: git.ada-dev.ru - gitea_upstream: 192.168.1.25:3000 - - tasks: - - name: Ensure Caddy service directory exists - ansible.builtin.file: - path: "{{ caddy_dir }}" - state: directory - owner: root - group: root - mode: "0755" - - - name: Configure Gitea Caddy site - ansible.builtin.blockinfile: - path: "{{ caddyfile_path }}" - create: true - owner: root - group: root - mode: "0644" - marker: "# {mark} ANSIBLE MANAGED GITEA SITE" - block: | - {{ gitea_domain }} { - reverse_proxy {{ gitea_upstream }} - } - register: gitea_caddy_site - - - name: Remove legacy unmanaged Gitea Caddy site - ansible.builtin.replace: - path: "{{ caddyfile_path }}" - regexp: '(?ms)^git\.ada-dev\.ru \{\n\s*reverse_proxy 10\.122\.62\.51:3002\n\}\n+' - replace: '' - register: gitea_legacy_site - - - name: Validate host Caddy config - ansible.builtin.command: caddy validate --config {{ caddyfile_path }} - changed_when: false - - - name: Check Caddy container mounted config - ansible.builtin.command: docker exec caddy grep -F 'reverse_proxy {{ gitea_upstream }}' /etc/caddy/Caddyfile - register: gitea_container_caddyfile - changed_when: false - failed_when: false - - - name: Restart Caddy when config changed or bind mount is stale - ansible.builtin.command: docker restart caddy - when: >- - gitea_caddy_site.changed or - gitea_legacy_site.changed or - gitea_container_caddyfile.rc != 0 - - - name: Validate Caddy container config after restart - ansible.builtin.command: docker exec caddy caddy validate --config /etc/caddy/Caddyfile - changed_when: false - - - name: Check Gitea upstream from ru-vps - ansible.builtin.command: curl -fsS -o /dev/null -w '%{http_code}' http://{{ gitea_upstream }}/ - register: gitea_upstream_http - changed_when: false - failed_when: gitea_upstream_http.stdout != '200' diff --git a/ansible/playbooks/reverse-proxy-grimmory.yml b/ansible/playbooks/reverse-proxy-grimmory.yml deleted file mode 100644 index 71684c9..0000000 --- a/ansible/playbooks/reverse-proxy-grimmory.yml +++ /dev/null @@ -1,88 +0,0 @@ ---- -- name: Configure Grimmory reverse proxy on ru-vps - hosts: ru-vps - gather_facts: false - vars: - caddy_dir: /opt/services/ru-vps/caddy - caddyfile_path: /opt/services/ru-vps/caddy/Caddyfile - grimmory_domain: books.ada-dev.ru - grimmory_upstream: 192.168.1.34:6060 - tasks: - - name: Ensure Caddy service directory exists - ansible.builtin.file: - path: "{{ caddy_dir }}" - state: directory - owner: root - group: root - mode: "0755" - - - name: Configure Grimmory Caddy site - ansible.builtin.blockinfile: - path: "{{ caddyfile_path }}" - create: true - owner: root - group: root - mode: "0644" - marker: "# {mark} ANSIBLE MANAGED GRIMMORY SITE" - block: | - {{ grimmory_domain }} { - @grimmory_opds_atom path /api/v1/opds /api/v1/opds/libraries /api/v1/opds/shelves /api/v1/opds/magic-shelves /api/v1/opds/authors /api/v1/opds/series /api/v1/opds/catalog /api/v1/opds/recent /api/v1/opds/surprise - handle @grimmory_opds_atom { - reverse_proxy {{ grimmory_upstream }} { - header_up Accept "application/atom+xml" - header_up Accept-Encoding identity - transport http { - compression off - } - } - } - - @grimmory_opds_search path /api/v1/opds/search.opds - handle @grimmory_opds_search { - reverse_proxy {{ grimmory_upstream }} { - header_up Accept "application/opensearchdescription+xml" - header_up Accept-Encoding identity - transport http { - compression off - } - } - } - - @grimmory_device_api path /api/koreader /api/koreader/* /api/v1/opds /api/v1/opds/* - handle @grimmory_device_api { - reverse_proxy {{ grimmory_upstream }} { - header_up Accept-Encoding identity - transport http { - compression off - } - } - } - - handle { - reverse_proxy {{ grimmory_upstream }} - } - } - register: grimmory_caddy_site - - - name: Validate host Caddy config - ansible.builtin.command: caddy validate --config {{ caddyfile_path }} - changed_when: false - - - name: Check Caddy container mounted config - ansible.builtin.command: docker exec caddy grep -F '@grimmory_device_api path /api/koreader' /etc/caddy/Caddyfile - register: grimmory_container_caddyfile - changed_when: false - failed_when: false - - - name: Restart Caddy when config changed or bind mount is stale - ansible.builtin.command: docker restart caddy - when: grimmory_caddy_site.changed or grimmory_container_caddyfile.rc != 0 - - - name: Validate Caddy container config after restart - ansible.builtin.command: docker exec caddy caddy validate --config /etc/caddy/Caddyfile - changed_when: false - - - name: Check Grimmory upstream from ru-vps - ansible.builtin.uri: - url: http://{{ grimmory_upstream }}/api/v1/healthcheck - status_code: 200 diff --git a/ansible/playbooks/reverse-proxy-vaultwarden.yml b/ansible/playbooks/reverse-proxy-vaultwarden.yml deleted file mode 100644 index 726d822..0000000 --- a/ansible/playbooks/reverse-proxy-vaultwarden.yml +++ /dev/null @@ -1,65 +0,0 @@ -- name: Configure Vaultwarden reverse proxy on ru-vps - hosts: ru-vps - gather_facts: false - vars: - caddy_dir: /opt/services/ru-vps/caddy - caddyfile_path: /opt/services/ru-vps/caddy/Caddyfile - vaultwarden_domain: pass.ada-dev.ru - vaultwarden_upstream: 192.168.1.24:80 - - tasks: - - name: Ensure Caddy service directory exists - ansible.builtin.file: - path: "{{ caddy_dir }}" - state: directory - owner: root - group: root - mode: "0755" - - - name: Configure Vaultwarden Caddy site - ansible.builtin.blockinfile: - path: "{{ caddyfile_path }}" - create: true - owner: root - group: root - mode: "0644" - marker: "# {mark} ANSIBLE MANAGED VAULTWARDEN SITE" - block: | - {{ vaultwarden_domain }} { - reverse_proxy {{ vaultwarden_upstream }} - } - register: vaultwarden_caddy_site - - - name: Remove legacy unmanaged Vaultwarden Caddy site - ansible.builtin.replace: - path: "{{ caddyfile_path }}" - regexp: '(?ms)^pass\.ada-dev\.ru \{\n\s*reverse_proxy 10\.122\.62\.95:10380\n\}\n+' - replace: '' - register: vaultwarden_legacy_site - - - name: Validate host Caddy config - ansible.builtin.command: caddy validate --config {{ caddyfile_path }} - changed_when: false - - - name: Check Caddy container mounted config - ansible.builtin.command: docker exec caddy grep -F 'reverse_proxy {{ vaultwarden_upstream }}' /etc/caddy/Caddyfile - register: vaultwarden_container_caddyfile - changed_when: false - failed_when: false - - - name: Restart Caddy when config changed or bind mount is stale - ansible.builtin.command: docker restart caddy - when: >- - vaultwarden_caddy_site.changed or - vaultwarden_legacy_site.changed or - vaultwarden_container_caddyfile.rc != 0 - - - name: Validate Caddy container config after restart - ansible.builtin.command: docker exec caddy caddy validate --config /etc/caddy/Caddyfile - changed_when: false - - - name: Check Vaultwarden upstream from ru-vps - ansible.builtin.command: curl -fsS -o /dev/null -w '%{http_code}' http://{{ vaultwarden_upstream }}/ - register: vaultwarden_upstream_http - changed_when: false - failed_when: vaultwarden_upstream_http.stdout != '200' diff --git a/ansible/playbooks/reverse-proxy.yml b/ansible/playbooks/reverse-proxy.yml new file mode 100644 index 0000000..018415e --- /dev/null +++ b/ansible/playbooks/reverse-proxy.yml @@ -0,0 +1,182 @@ +--- +# ============================================================================ +# Единый плейбук публикации сервисов через Caddy на ru-vps. +# +# Заменяет reverse-proxy-gitea.yml, reverse-proxy-vaultwarden.yml и +# reverse-proxy-grimmory.yml. Источник данных — реестр homelab_services +# (ansible/inventory/group_vars/all/services.yml): обрабатываются все сервисы, +# у которых определён блок `proxy`. +# +# Отличие от трёх старых плейбуков: контейнер Caddy перезапускается ОДИН раз +# в конце, если изменился хотя бы один сайт (раньше — по разу на плейбук). +# +# Запуск: +# ansible-playbook playbooks/reverse-proxy.yml +# ansible-playbook playbooks/reverse-proxy.yml -e 'reverse_proxy_only=[gitea]' +# ============================================================================ +- name: Configure public reverse proxy sites on ru-vps + hosts: ru-vps + gather_facts: false + vars: + caddy_dir: "{{ homelab_reverse_proxy_dir | default('/opt/services/ru-vps/caddy') }}" + caddyfile_path: "{{ homelab_reverse_proxy_caddyfile | default('/opt/services/ru-vps/caddy/Caddyfile') }}" + caddy_container: "{{ homelab_reverse_proxy_container | default('caddy') }}" + caddyfile_container_path: /etc/caddy/Caddyfile + # Ограничить прогон подмножеством сервисов: -e 'reverse_proxy_only=[gitea]' + reverse_proxy_only: [] + reverse_proxy_services: >- + {{ homelab_services | dict2items + | selectattr('value.proxy', 'defined') + | selectattr('key', 'in', reverse_proxy_only) | list + if reverse_proxy_only | length > 0 + else homelab_services | dict2items + | selectattr('value.proxy', 'defined') | list }} + + tasks: + - name: Require at least one service with a published domain + ansible.builtin.assert: + that: + - reverse_proxy_services | length > 0 + fail_msg: >- + В homelab_services нет ни одного сервиса с блоком `proxy` + (или reverse_proxy_only отфильтровал всё). + + # Циклы по именам, а не по записям целиком: иначе assert печатает весь + # словарь сервиса в вывод при каждом прогоне. + - name: Require every proxied service to declare domain and upstream + ansible.builtin.assert: + that: + - svc.domain | default('') | length > 0 + - svc.upstream | default('') | length > 0 + - svc.caddy_marker | default('') is search('{mark}') + fail_msg: >- + Сервис {{ item }}: в proxy обязаны быть domain, upstream и + caddy_marker с плейсхолдером {mark}. + quiet: true + loop: "{{ reverse_proxy_services | map(attribute='key') | list }}" + vars: + svc: "{{ homelab_services[item].proxy }}" + + - name: Require a custom Caddy body to point at the declared upstream + ansible.builtin.assert: + that: + - svc.upstream in svc.caddy_body + fail_msg: >- + Сервис {{ item }}: caddy_body не содержит апстрим + {{ svc.upstream }} — реестр рассинхронизирован. + quiet: true + loop: >- + {{ reverse_proxy_services + | selectattr('value.proxy.caddy_body', 'defined') + | map(attribute='key') | list }} + vars: + svc: "{{ homelab_services[item].proxy }}" + + - name: Ensure Caddy service directory exists + ansible.builtin.file: + path: "{{ caddy_dir }}" + state: directory + owner: root + group: root + mode: "0755" + + - name: Configure Caddy sites + ansible.builtin.blockinfile: + path: "{{ caddyfile_path }}" + create: true + owner: root + group: root + mode: "0644" + marker: "{{ item.value.proxy.caddy_marker }}" + block: | + {{ item.value.proxy.domain }} { + {% if item.value.proxy.caddy_body is defined %} + {{ item.value.proxy.caddy_body | trim | indent(2, first=True) }} + {% else %} + reverse_proxy {{ item.value.proxy.upstream }} + {% endif %} + } + loop: "{{ reverse_proxy_services }}" + loop_control: + label: "{{ item.key }} ({{ item.value.proxy.domain }})" + register: caddy_sites + + # ------------------------------------------------------------------ + # МИГРАЦИОННАЯ СЕКЦИЯ. Удаляет неуправляемые секции Caddyfile, оставшиеся + # от прежней инсталляции на 10.122.62.0/24. Держится только ради миграции: + # после подтверждённого прогона на ru-vps эту задачу и поля + # proxy.caddy_legacy_regexp в services.yml можно удалить. + # ------------------------------------------------------------------ + - name: Remove legacy unmanaged Caddy sites + ansible.builtin.replace: + path: "{{ caddyfile_path }}" + regexp: "{{ item.value.proxy.caddy_legacy_regexp }}" + replace: '' + loop: "{{ reverse_proxy_services | selectattr('value.proxy.caddy_legacy_regexp', 'defined') | list }}" + loop_control: + label: "{{ item.key }}" + register: caddy_legacy + + - name: Validate host Caddy config + ansible.builtin.command: + argv: + - caddy + - validate + - --config + - "{{ caddyfile_path }}" + changed_when: false + + - name: Check Caddy container mounted config + ansible.builtin.command: + argv: + - docker + - exec + - "{{ caddy_container }}" + - grep + - -F + - >- + {{ item.value.proxy.caddy_container_check + | default('reverse_proxy ' ~ item.value.proxy.upstream) }} + - "{{ caddyfile_container_path }}" + loop: "{{ reverse_proxy_services }}" + loop_control: + label: "{{ item.key }}" + register: caddy_container_checks + changed_when: false + failed_when: false + + - name: Restart Caddy once when any site changed or a bind mount is stale + ansible.builtin.command: + argv: + - docker + - restart + - "{{ caddy_container }}" + when: >- + caddy_sites.changed or + caddy_legacy.changed or + (caddy_container_checks.results + | selectattr('rc', 'defined') + | rejectattr('rc', 'equalto', 0) + | list | length > 0) + + - name: Validate Caddy container config after restart + ansible.builtin.command: + argv: + - docker + - exec + - "{{ caddy_container }}" + - caddy + - validate + - --config + - "{{ caddyfile_container_path }}" + changed_when: false + + - name: Check service upstreams from ru-vps + ansible.builtin.uri: + url: "http://{{ item.value.proxy.upstream }}{{ item.value.proxy.health.path | default('/') }}" + status_code: "{{ item.value.proxy.health.status_code | default([200]) }}" + follow_redirects: "{{ item.value.proxy.health.follow_redirects | default('none') }}" + return_content: false + loop: "{{ reverse_proxy_services }}" + loop_control: + label: "{{ item.key }} -> {{ item.value.proxy.upstream }}" diff --git a/ansible/roles/README.md b/ansible/roles/README.md new file mode 100644 index 0000000..625ba80 --- /dev/null +++ b/ansible/roles/README.md @@ -0,0 +1,47 @@ +# Роли Ansible + +| Роль | Назначение | +|---|---| +| `backup_audit` | Аудит бэкапов PBS и restic, метрики в node-exporter | +| `bash_config` | Единый bash-конфиг для shell-хостов | +| `compose_service` | **(новая)** Docker Compose стек под systemd oneshot-юнитом | +| `emergency_access` | Аварийный доступ | +| `emergency_bot` | Telegram-бот аварийного доступа | +| `gyro` | Сервис gyro в изолированном LXC | +| `lxc_docker_host` | **(новая)** Подготовка непривилегированного LXC под Docker | +| `monitoring_blackbox` | Внешние HTTP-пробы с ru-vps | +| `monitoring_exporter` | node-exporter / smartctl-exporter | +| `monitoring_server` | Prometheus + Alertmanager + Grafana | +| `openvpn_gateway` | OpenVPN сервер/клиент | +| `pve_lxc` | Создание LXC через Proxmox API | +| `uptime_kuma` | Uptime Kuma | +| `base`, `docker`, `ufw` | Пустые каталоги, оставшиеся от ранней структуры | + +## Новые роли + +### `lxc_docker_host` + +Пакеты, проверка `/dev/fuse`, `daemon.json` со `storage-driver: +fuse-overlayfs`, запуск docker, базовые правила UFW (SSH из LAN и OpenVPN, +node-exporter 9100 с 192.168.1.30, `deny incoming`), проверка эффективного +драйвера хранилища. Вынесено из восьми `pve-*.yml` — около 350 строк +копипасты. Подробности и пример: [`lxc_docker_host/README.md`](lxc_docker_host/README.md). + +### `compose_service` + +`compose.yml` + `.env` (генерация секретов один раз, `no_log`, `0600`) + +systemd-юнит `Type=oneshot` с `docker compose up -d --remove-orphans`, +`daemon-reload` через handler, валидация `docker compose config --quiet`, +рестарт только при изменениях, health-check по URL с `retries`/`until`. +Подробности и пример плейбука Gitea на новых ролях: +[`compose_service/README.md`](compose_service/README.md). + +**Статус:** роли созданы и проверены синтаксически, но пока не подключены ни +к одному живому сервису. Перевод `pve-*.yml` на них — отдельный этап. + +## Источник данных + +Факты о сервисах (vmid, узел, адрес, порты, домен, образы с digest, ресурсы, +бэкап, мониторинг, порядок автозапуска) собраны в реестре +`ansible/inventory/group_vars/all/services.yml` (`homelab_services`). +Его уже потребляет `playbooks/reverse-proxy.yml`. diff --git a/ansible/roles/compose_service/README.md b/ansible/roles/compose_service/README.md new file mode 100644 index 0000000..37bdce0 --- /dev/null +++ b/ansible/roles/compose_service/README.md @@ -0,0 +1,133 @@ +# compose_service + +Раскладывает Docker Compose стек и systemd-юнит, который им управляет. +Обобщение того, что делает `playbooks/pve-grimmory.yml`; такой же по форме +код продублирован в `roles/monitoring_server` и `roles/uptime_kuma` +(`grep -rn "up -d --remove-orphans"` — четыре копии одного юнита). + +## Что делает + +1. Создаёт корневой каталог сервиса и, при необходимости, каталоги данных + с нужными owner/group/mode. +2. Генерирует `.env` **один раз** под `umask 077`: статические пары из + `compose_service_env_static` и случайные секреты + (`openssl rand -hex`) для имён из `compose_service_env_generated`. + Задача целиком под `no_log: true`; права форсируются в `0600`. + Повторный прогон существующий `.env` не перетирает — иначе поменялся бы + пароль работающей БД. +3. Кладёт `compose.yml` из inline-строки или из Jinja-шаблона. +4. Ставит systemd-юнит `Type=oneshot`, `RemainAfterExit=yes`, + `ExecStart=docker compose up -d --remove-orphans`, + `ExecStop=docker compose down`. +5. `daemon-reload` через handler + немедленный `meta: flush_handlers` + (перечитать юнит надо ДО `systemctl start`, а не в конце play). +6. Валидирует конфигурацию: `docker compose config --quiet`, `no_log: true` + — при ошибке вывод подставляет значения из `.env`. +7. Стартует сервис, перезапуская его только если изменился `compose.yml`, + юнит или сработал внешний триггер (`compose_service_restart_triggers`). +8. Ждёт health-endpoint через `uri` с `retries`/`until`. + +## Переменные + +Полный список — в `defaults/main.yml`. Обязательные: `compose_service_name` +и ровно один из `compose_service_compose_content` / +`compose_service_compose_template` (проверяется `assert` в начале роли). + +| Переменная | По умолчанию | Назначение | +|---|---|---| +| `compose_service_name` | — | имя сервиса и systemd-юнита | +| `compose_service_root` | `/opt/` | корень стека | +| `compose_service_compose_content` | `""` | inline `compose.yml` | +| `compose_service_compose_template` | `""` | путь к Jinja-шаблону | +| `compose_service_directories` | `[]` | каталоги данных | +| `compose_service_env_static` | `{}` | пары для `.env` | +| `compose_service_env_generated` | `[]` | имена случайных секретов | +| `compose_service_after` / `_requires` | `[]` | доп. юниты в `After=`/`Requires=` | +| `compose_service_restart_triggers` | `[]` | внешние причины рестарта | +| `compose_service_health_url` | `""` | URL health-check (пусто — пропустить) | + +## Пример использования — как выглядел бы Gitea на новых ролях + +`playbooks/pve-gitea.yml` сейчас 231 строка. На ролях `pve_lxc` + +`lxc_docker_host` + `compose_service` содержательная часть сводится +примерно к такому (сам стек Gitea пока НЕ мигрирован — это следующий этап): + +```yaml +--- +- name: Create the Gitea LXC on cloud-pc + hosts: localhost + connection: local + gather_facts: false + vars: + gitea: "{{ homelab_services.gitea }}" + roles: + - role: pve_lxc + pve_lxc_vmid: "{{ gitea.vmid }}" + pve_lxc_node: "{{ gitea.node }}" + pve_lxc_hostname: "{{ gitea.hostname }}" + pve_lxc_ip: "{{ gitea.ip }}/24" + pve_lxc_disk: "{{ gitea.lxc.disk }}" + pve_lxc_cores: "{{ gitea.lxc.cores }}" + pve_lxc_memory: "{{ gitea.lxc.memory }}" + pve_lxc_swap: "{{ gitea.lxc.swap }}" + pve_lxc_startup: "{{ gitea.lxc.startup }}" + pve_lxc_ostemplate: "{{ gitea.lxc.ostemplate }}" + +- name: Configure the Gitea service + hosts: gitea + gather_facts: true + vars: + gitea: "{{ homelab_services.gitea }}" + roles: + - role: lxc_docker_host + lxc_docker_host_extra_packages: [sqlite3, rsync] + lxc_docker_host_ufw_service_rules: + - port: "3000" + sources: ["{{ homelab_lan_cidr }}", "{{ openvpn_network_cidr }}"] + - port: "2222" + sources: ["{{ homelab_lan_cidr }}", "{{ openvpn_network_cidr }}"] + + - role: compose_service + compose_service_name: gitea + compose_service_root: /opt/gitea + compose_service_description: Gitea Compose stack + compose_service_directories: + - {path: /opt/gitea/data, owner: "1000", group: "1000", mode: "0750"} + compose_service_env_static: + USER_UID: "1000" + USER_GID: "1000" + compose_service_compose_content: | + services: + gitea: + image: {{ gitea.images[0] }} + container_name: gitea + environment: + USER_UID: "${USER_UID}" + USER_GID: "${USER_GID}" + ports: + - "3000:3000" + - "2222:22" + volumes: + - /opt/gitea/data:/data + restart: unless-stopped + compose_service_health_url: http://127.0.0.1:3000/api/healthz + compose_service_health_retries: 24 + compose_service_health_delay: 5 +``` + +Около 30 строк `vars` вместо 231 строки процедурного кода, и все факты +(vmid, узел, адрес, ресурсы, digest образа) берутся из реестра +`homelab_services`, а не дублируются в плейбуке. + +### Что при такой миграции меняется на живом хосте + +Это не чистый рефакторинг, поэтому мигрировать нужно осознанно: + +* `docker run` в `ExecStart=` заменяется на compose-стек — контейнер + пересоздаётся, юнит `gitea.service` меняет тип на `oneshot`. +* появляется `/opt/gitea/.env`, которого раньше не было; +* `--pull never` и явный `docker pull` по digest заменяются на `image:` + в compose — политику закрепления образов надо перенести отдельно. + +Поэтому перевод существующих сервисов вынесен в отдельный этап и делается +по одному сервису, с бэкапом и `--check --diff` перед реальным прогоном. diff --git a/ansible/roles/compose_service/defaults/main.yml b/ansible/roles/compose_service/defaults/main.yml new file mode 100644 index 0000000..2aa1eca --- /dev/null +++ b/ansible/roles/compose_service/defaults/main.yml @@ -0,0 +1,70 @@ +--- +# ============================================================================ +# roles/compose_service — раскладка Docker Compose стека + systemd-юнита. +# Обобщает то, что делает pve-grimmory.yml (compose.yml, .env с секретами, +# oneshot-юнит, валидация, health-check). +# ============================================================================ + +# --- Обязательное ---------------------------------------------------------- +# Имя сервиса. Оно же имя systemd-юнита (.service) и имя каталога +# по умолчанию. Роль падает с понятным сообщением, если не задано. +compose_service_name: "" + +# Содержимое compose.yml. Ровно один из двух способов: +# compose_service_compose_content — готовая строка (можно собрать в vars); +# compose_service_compose_template — путь к Jinja-шаблону в вызывающей роли +# или в playbooks/templates. +compose_service_compose_content: "" +compose_service_compose_template: "" + +# --- Раскладка на диске ---------------------------------------------------- +compose_service_root: "/opt/{{ compose_service_name }}" +compose_service_root_mode: "0750" +compose_service_owner: root +compose_service_group: root +compose_service_compose_file: compose.yml +compose_service_compose_mode: "0644" + +# Дополнительные каталоги данных. Формат: +# - {path: /opt/grimmory/data, owner: "1000", group: "1000", mode: "0750"} +compose_service_directories: [] + +# --- Файл окружения -------------------------------------------------------- +compose_service_env_path: "{{ compose_service_root }}/.env" +compose_service_env_mode: "0600" + +# Пары ключ-значение, записываемые в .env как есть. +compose_service_env_static: {} +# Имена переменных, значения которых генерируются `openssl rand -hex` ОДИН РАЗ. +# Файл .env создаётся только если его ещё нет: повторный прогон не перетирает +# уже используемые пароли. Чтобы поменять секрет — удалите .env вручную. +compose_service_env_generated: [] +compose_service_env_secret_bytes: 32 + +# Необязательный .env.example для документирования формата (без секретов!). +compose_service_env_example: "" +compose_service_env_example_mode: "0644" + +# --- systemd --------------------------------------------------------------- +compose_service_description: "{{ compose_service_name }} Compose stack" +# Дополнительные юниты в After= / Requires= (docker.service уже включён). +compose_service_after: [] +compose_service_requires: [] +compose_service_unit_path: "/etc/systemd/system/{{ compose_service_name }}.service" +compose_service_docker_binary: /usr/bin/docker +compose_service_enabled: true +# Дополнительные условия рестарта: список булевых значений от вызывающего +# (например, результат pull образа). +compose_service_restart_triggers: [] + +# --- Валидация и health-check --------------------------------------------- +# `docker compose config --quiet` перед стартом. Выполняется с no_log, +# потому что вывод при ошибке может содержать значения из .env. +compose_service_validate: true + +# URL health-check после старта. Пустая строка — проверка пропускается. +compose_service_health_url: "" +compose_service_health_status: [200] +compose_service_health_retries: 24 +compose_service_health_delay: 5 +compose_service_health_follow_redirects: safe diff --git a/ansible/roles/compose_service/handlers/main.yml b/ansible/roles/compose_service/handlers/main.yml new file mode 100644 index 0000000..71f042f --- /dev/null +++ b/ansible/roles/compose_service/handlers/main.yml @@ -0,0 +1,7 @@ +--- +# Юнит должен быть перечитан ДО задачи enable/start, поэтому вызывающая роль +# сразу после установки юнита делает `meta: flush_handlers`. +- name: Reload systemd for compose services + ansible.builtin.systemd: + daemon_reload: true + listen: compose_service_daemon_reload diff --git a/ansible/roles/compose_service/meta/main.yml b/ansible/roles/compose_service/meta/main.yml new file mode 100644 index 0000000..4cf5f73 --- /dev/null +++ b/ansible/roles/compose_service/meta/main.yml @@ -0,0 +1,4 @@ +--- +# Зависимостей у роли нет: подготовка хоста и раскладка стека независимы +# и подключаются в нужном порядке из плейбука. +dependencies: [] diff --git a/ansible/roles/compose_service/tasks/main.yml b/ansible/roles/compose_service/tasks/main.yml new file mode 100644 index 0000000..4149765 --- /dev/null +++ b/ansible/roles/compose_service/tasks/main.yml @@ -0,0 +1,173 @@ +--- +- name: Validate compose_service parameters + ansible.builtin.assert: + that: + - compose_service_name | length > 0 + - (compose_service_compose_content | length > 0) + != (compose_service_compose_template | length > 0) + fail_msg: >- + Задайте compose_service_name и ровно один из + compose_service_compose_content / compose_service_compose_template. + +- name: Ensure the service root directory exists + ansible.builtin.file: + path: "{{ compose_service_root }}" + state: directory + owner: "{{ compose_service_owner }}" + group: "{{ compose_service_group }}" + mode: "{{ compose_service_root_mode }}" + +- name: Ensure the service data directories exist + ansible.builtin.file: + path: "{{ item.path }}" + state: directory + owner: "{{ item.owner | default(compose_service_owner) }}" + group: "{{ item.group | default(compose_service_group) }}" + mode: "{{ item.mode | default('0750') }}" + loop: "{{ compose_service_directories }}" + loop_control: + label: "{{ item.path }}" + +# --- Файл окружения -------------------------------------------------------- +# Создаётся один раз под umask 077. Секреты никогда не попадают в вывод: +# задача целиком под no_log. +- name: Generate the environment file once + ansible.builtin.shell: | + set -eu + umask 077 + if [ -e {{ compose_service_env_path | quote }} ]; then + exit 0 + fi + : > {{ compose_service_env_path | quote }} + {% for key, value in compose_service_env_static.items() %} + printf '%s\n' {{ (key ~ '=' ~ value) | quote }} >> {{ compose_service_env_path | quote }} + {% endfor %} + {% for key in compose_service_env_generated %} + printf '%s=%s\n' {{ key | quote }} "$(openssl rand -hex {{ compose_service_env_secret_bytes }})" >> {{ compose_service_env_path | quote }} + {% endfor %} + printf created + args: + executable: /bin/sh + register: compose_service_env_result + changed_when: compose_service_env_result.stdout == 'created' + no_log: true + when: >- + compose_service_env_static | length > 0 or + compose_service_env_generated | length > 0 + +- name: Enforce the environment file permissions + ansible.builtin.file: + path: "{{ compose_service_env_path }}" + owner: "{{ compose_service_owner }}" + group: "{{ compose_service_group }}" + mode: "{{ compose_service_env_mode }}" + when: >- + compose_service_env_static | length > 0 or + compose_service_env_generated | length > 0 + +- name: Install the environment example + ansible.builtin.copy: + dest: "{{ compose_service_env_path }}.example" + owner: "{{ compose_service_owner }}" + group: "{{ compose_service_group }}" + mode: "{{ compose_service_env_example_mode }}" + content: "{{ compose_service_env_example }}" + when: compose_service_env_example | length > 0 + +# --- compose.yml ----------------------------------------------------------- +- name: Install the Compose configuration from inline content + ansible.builtin.copy: + dest: "{{ compose_service_root }}/{{ compose_service_compose_file }}" + owner: "{{ compose_service_owner }}" + group: "{{ compose_service_group }}" + mode: "{{ compose_service_compose_mode }}" + content: "{{ compose_service_compose_content }}" + when: compose_service_compose_content | length > 0 + register: compose_service_compose_inline + +- name: Install the Compose configuration from a template + ansible.builtin.template: + src: "{{ compose_service_compose_template }}" + dest: "{{ compose_service_root }}/{{ compose_service_compose_file }}" + owner: "{{ compose_service_owner }}" + group: "{{ compose_service_group }}" + mode: "{{ compose_service_compose_mode }}" + when: compose_service_compose_template | length > 0 + register: compose_service_compose_templated + +- name: Record whether the Compose configuration changed + ansible.builtin.set_fact: + compose_service_compose_changed: >- + {{ (compose_service_compose_inline.changed | default(false)) or + (compose_service_compose_templated.changed | default(false)) }} + +# --- systemd --------------------------------------------------------------- +- name: Install the systemd unit + ansible.builtin.copy: + dest: "{{ compose_service_unit_path }}" + owner: root + group: root + mode: "0644" + content: | + [Unit] + Description={{ compose_service_description }} + Wants=network-online.target + After=network-online.target docker.service{{ (' ' ~ compose_service_after | join(' ')) if compose_service_after else '' }} + Requires=docker.service{{ (' ' ~ compose_service_requires | join(' ')) if compose_service_requires else '' }} + + [Service] + Type=oneshot + RemainAfterExit=yes + WorkingDirectory={{ compose_service_root }} + ExecStart={{ compose_service_docker_binary }} compose -f {{ compose_service_root }}/{{ compose_service_compose_file }} up -d --remove-orphans + ExecStop={{ compose_service_docker_binary }} compose -f {{ compose_service_root }}/{{ compose_service_compose_file }} down + + [Install] + WantedBy=multi-user.target + register: compose_service_unit + notify: compose_service_daemon_reload + +- name: Apply the pending systemd daemon reload + ansible.builtin.meta: flush_handlers + +# --- Валидация ------------------------------------------------------------- +# no_log: сообщение об ошибке `docker compose config` подставляет значения +# переменных из .env. +- name: Validate the Compose configuration + ansible.builtin.command: + argv: + - "{{ compose_service_docker_binary }}" + - compose + - -f + - "{{ compose_service_root }}/{{ compose_service_compose_file }}" + - config + - --quiet + args: + chdir: "{{ compose_service_root }}" + changed_when: false + no_log: true + when: compose_service_validate + +# --- Запуск ---------------------------------------------------------------- +- name: Enable and start the service + ansible.builtin.systemd: + name: "{{ compose_service_name }}" + enabled: "{{ compose_service_enabled }}" + state: "{{ 'restarted' if compose_service_needs_restart else 'started' }}" + vars: + compose_service_needs_restart: >- + {{ (compose_service_compose_changed | bool) or + (compose_service_unit.changed | default(false)) or + (compose_service_restart_triggers | select | list | length > 0) }} + +- name: Wait for the service health endpoint + ansible.builtin.uri: + url: "{{ compose_service_health_url }}" + status_code: "{{ compose_service_health_status }}" + follow_redirects: "{{ compose_service_health_follow_redirects }}" + return_content: false + register: compose_service_health + retries: "{{ compose_service_health_retries }}" + delay: "{{ compose_service_health_delay }}" + until: compose_service_health.status in (compose_service_health_status | map('int') | list) + when: compose_service_health_url | length > 0 diff --git a/ansible/roles/lxc_docker_host/README.md b/ansible/roles/lxc_docker_host/README.md new file mode 100644 index 0000000..72fb62a --- /dev/null +++ b/ansible/roles/lxc_docker_host/README.md @@ -0,0 +1,83 @@ +# lxc_docker_host + +Приводит непривилегированный Proxmox LXC в состояние «здесь можно запускать +Docker»: пакеты, проверка `/dev/fuse`, `storage-driver: fuse-overlayfs`, +запуск демона и базовый UFW. + +Роль вынесена из повторяющихся блоков `playbooks/pve-*.yml` +(gitea, vaultwarden, mihomo, adguard, memoir-bot, docker-test, grimmory, +hermes-ai) — суммарно около 350 строк копипасты. + +## Что делает + +1. Ставит `ca-certificates`, `curl`, `docker.io`, `fuse-overlayfs` + (+ `lxc_docker_host_extra_packages`, `ufw` при управлении фаерволом). +2. `stat` + `assert` на `/dev/fuse`: без символьного устройства + fuse-overlayfs не работает, и Docker молча деградирует до `vfs`. + Сообщение об ошибке подсказывает, какие строки добавить в + `/etc/pve/lxc/.conf` на узле PVE. +3. Пишет `/etc/docker/daemon.json` со `storage-driver: fuse-overlayfs`. +4. `systemd: docker` — `enabled: true`, `restarted` при смене daemon.json, + иначе `started`. +5. UFW: SSH из `homelab_lan_cidr` и `openvpn_network_cidr`, node-exporter + 9100/tcp с хоста мониторинга (192.168.1.30), произвольные порты сервиса + из `lxc_docker_host_ufw_service_rules`, затем `policy deny incoming`. + Разрешающие правила ставятся ДО включения политики — иначе прогон + обрывает собственную SSH-сессию. +6. Проверяет `docker info --format '{{.Driver}}'` и падает при расхождении. + +## Переменные + +Полный список с комментариями — в `defaults/main.yml`. Ключевые: + +| Переменная | По умолчанию | Назначение | +|---|---|---| +| `lxc_docker_host_extra_packages` | `[]` | доп. пакеты сервиса | +| `lxc_docker_host_require_fuse` | `true` | проверять `/dev/fuse` | +| `lxc_docker_host_storage_driver` | `fuse-overlayfs` | драйвер хранилища | +| `lxc_docker_host_manage_ufw` | `true` | трогать ли UFW вообще | +| `lxc_docker_host_ssh_sources` | LAN + OpenVPN | откуда разрешён SSH | +| `lxc_docker_host_allow_node_exporter` | `true` | 9100 с хоста мониторинга | +| `lxc_docker_host_ufw_service_rules` | `[]` | порты сервиса | +| `lxc_docker_host_ufw_enable` | `true` | включать `deny incoming` | +| `lxc_docker_host_verify_storage_driver` | `true` | финальная проверка | + +## Пример: как выглядела бы подготовка хоста Grimmory + +```yaml +- name: Prepare the Grimmory Docker host + hosts: grimmory + gather_facts: true + vars: + ansible_become: false + roles: + - role: lxc_docker_host + lxc_docker_host_extra_packages: + - mariadb-client + - openssl + lxc_docker_host_install_node_exporter: true + lxc_docker_host_ufw_service_rules: + - port: "{{ homelab_services.grimmory.ports[0].port }}" + proto: tcp + comment: Grimmory HTTP + sources: + - "{{ homelab_lan_cidr }}" + - "{{ openvpn_network_cidr }}" +``` + +Эти 12 строк заменяют 86 строк из `playbooks/pve-grimmory.yml`. + +## Чего роль НЕ делает + +* Не создаёт LXC и не правит `/etc/pve/lxc/.conf` — это роль `pve_lxc` + и соответствующий `pve-*.yml`. Роль только проверяет результат. +* Не публикует порты Docker в обход UFW. Помните: `-p` в Docker обходит UFW, + поэтому для публикуемых портов нужны правила в цепочке `DOCKER-USER` + (см. `grimmory-docker-firewall` в `playbooks/pve-grimmory.yml`). +* Не ставит node-exporter по умолчанию — этим занимается `monitoring_exporter`. + +## Handlers + +Их нет намеренно. Docker перезапускается прямо в задаче через +`state: restarted if changed else started`: handler отработал бы в конце +play, уже после того как compose-стек стартовал на старом драйвере хранилища. diff --git a/ansible/roles/lxc_docker_host/defaults/main.yml b/ansible/roles/lxc_docker_host/defaults/main.yml new file mode 100644 index 0000000..7ddd559 --- /dev/null +++ b/ansible/roles/lxc_docker_host/defaults/main.yml @@ -0,0 +1,82 @@ +--- +# ============================================================================ +# roles/lxc_docker_host — подготовка непривилегированного LXC под Docker. +# Все переменные документированы; значения по умолчанию соответствуют тому, +# что фактически делают playbooks/pve-*.yml. +# ============================================================================ + +# --- Пакеты ---------------------------------------------------------------- +# Базовый набор, общий для всех Docker-хостов HomeLab. +lxc_docker_host_packages: + - ca-certificates + - curl + - docker.io + - fuse-overlayfs + +# Дополнительные пакеты конкретного сервиса. +# Примеры из существующих плейбуков: +# gitea: [sqlite3, rsync] +# adguard: [dnsutils] +# mihomo: [git] +# memoir-bot: [git, openssh-client, rsync] +# grimmory: [mariadb-client, openssl] +lxc_docker_host_extra_packages: [] + +# Ставить пакет docker-compose (в pve-grimmory.yml и pve-hermes-ai.yml он есть). +# Юниты используют плагин `docker compose` из docker.io, поэтому по умолчанию +# отдельный пакет не нужен. +lxc_docker_host_install_compose_package: false + +# Ставить prometheus-node-exporter локально (так делает pve-grimmory.yml). +# Обычно экспортёром управляет роль monitoring_exporter. +lxc_docker_host_install_node_exporter: false + +# Обновлять кеш apt перед установкой. +lxc_docker_host_update_cache: true + +# --- FUSE ------------------------------------------------------------------ +# Проверять наличие символьного устройства /dev/fuse и падать, если его нет. +# Без него fuse-overlayfs не заработает, а Docker молча деградирует. +lxc_docker_host_require_fuse: true +lxc_docker_host_fuse_device: /dev/fuse + +# --- Docker daemon --------------------------------------------------------- +lxc_docker_host_storage_driver: fuse-overlayfs +# Итоговое содержимое /etc/docker/daemon.json. Расширяемо: можно передать +# дополнительные ключи, storage-driver подставляется отсюда. +lxc_docker_host_daemon_config: + storage-driver: "{{ lxc_docker_host_storage_driver }}" +lxc_docker_host_daemon_config_path: /etc/docker/daemon.json +# Проверить `docker info --format {{.Driver}}` в конце и упасть при расхождении. +lxc_docker_host_verify_storage_driver: true + +# --- UFW ------------------------------------------------------------------- +# Управлять ли фаерволом вообще. false — роль не трогает ufw. +lxc_docker_host_manage_ufw: true +# Ставить пакет ufw, если управление включено. +lxc_docker_host_install_ufw: true + +# Источники, которым разрешён SSH. +lxc_docker_host_ssh_port: 22 +lxc_docker_host_ssh_sources: + - "{{ homelab_lan_cidr }}" + - "{{ openvpn_network_cidr }}" + +# Node exporter: скрейп разрешён только с хоста мониторинга. +lxc_docker_host_allow_node_exporter: true +lxc_docker_host_node_exporter_port: 9100 +lxc_docker_host_monitoring_host: "{{ homelab_monitoring_host_ip | default('192.168.1.30') }}" + +# Дополнительные порты сервиса. Формат: +# - port: "6060" +# proto: tcp # необязательно, по умолчанию tcp +# comment: "Grimmory" # необязательно +# sources: +# - "{{ homelab_lan_cidr }}" +# - "{{ openvpn_network_cidr }}" +lxc_docker_host_ufw_service_rules: [] + +# Включить ufw с политикой deny incoming. Правила выше применяются ДО включения, +# чтобы не потерять SSH. +lxc_docker_host_ufw_enable: true +lxc_docker_host_ufw_policy: deny diff --git a/ansible/roles/lxc_docker_host/meta/main.yml b/ansible/roles/lxc_docker_host/meta/main.yml new file mode 100644 index 0000000..4cf5f73 --- /dev/null +++ b/ansible/roles/lxc_docker_host/meta/main.yml @@ -0,0 +1,4 @@ +--- +# Зависимостей у роли нет: подготовка хоста и раскладка стека независимы +# и подключаются в нужном порядке из плейбука. +dependencies: [] diff --git a/ansible/roles/lxc_docker_host/tasks/main.yml b/ansible/roles/lxc_docker_host/tasks/main.yml new file mode 100644 index 0000000..c973a34 --- /dev/null +++ b/ansible/roles/lxc_docker_host/tasks/main.yml @@ -0,0 +1,113 @@ +--- +- name: Install Docker runtime packages + ansible.builtin.apt: + name: "{{ lxc_docker_host_all_packages }}" + state: present + update_cache: "{{ lxc_docker_host_update_cache }}" + vars: + lxc_docker_host_all_packages: >- + {{ (lxc_docker_host_packages + + lxc_docker_host_extra_packages + + (['docker-compose'] if lxc_docker_host_install_compose_package else []) + + (['prometheus-node-exporter'] if lxc_docker_host_install_node_exporter else []) + + (['ufw'] if (lxc_docker_host_manage_ufw and lxc_docker_host_install_ufw) else [])) + | unique | sort }} + +- name: Check the FUSE device + ansible.builtin.stat: + path: "{{ lxc_docker_host_fuse_device }}" + register: lxc_docker_host_fuse + when: lxc_docker_host_require_fuse + +- name: Require the FUSE device + ansible.builtin.assert: + that: + - lxc_docker_host_fuse.stat.exists + - lxc_docker_host_fuse.stat.ischr + fail_msg: >- + {{ lxc_docker_host_fuse_device }} отсутствует или не является символьным + устройством. Добавьте в /etc/pve/lxc/.conf на узле PVE: + "lxc.cgroup2.devices.allow: c 10:229 rwm" и + "lxc.mount.entry: /dev/fuse dev/fuse none bind,create=file", + затем перезапустите контейнер. + when: lxc_docker_host_require_fuse + +- name: Ensure the Docker config directory exists + ansible.builtin.file: + path: "{{ lxc_docker_host_daemon_config_path | dirname }}" + state: directory + owner: root + group: root + mode: "0755" + +- name: Configure the Docker storage driver for the unprivileged LXC + ansible.builtin.copy: + dest: "{{ lxc_docker_host_daemon_config_path }}" + owner: root + group: root + mode: "0644" + content: "{{ lxc_docker_host_daemon_config | to_nice_json(indent=2) }}\n" + register: lxc_docker_host_daemon_config_result + +- name: Ensure the Docker service is enabled and running + ansible.builtin.systemd: + name: docker + enabled: true + state: "{{ 'restarted' if lxc_docker_host_daemon_config_result.changed else 'started' }}" + +# --- UFW ------------------------------------------------------------------- +# Порядок важен: сначала разрешающие правила, только потом включение политики +# deny incoming — иначе SSH обрывается прямо во время прогона. + +- name: Allow SSH from the trusted networks + community.general.ufw: + rule: allow + port: "{{ lxc_docker_host_ssh_port | string }}" + proto: tcp + src: "{{ item }}" + loop: "{{ lxc_docker_host_ssh_sources }}" + when: lxc_docker_host_manage_ufw + +- name: Allow the declared service ports + community.general.ufw: + rule: allow + port: "{{ item.0.port | string }}" + proto: "{{ item.0.proto | default('tcp') }}" + src: "{{ item.1 }}" + comment: "{{ item.0.comment | default(omit) }}" + loop: "{{ lxc_docker_host_ufw_service_rules | subelements('sources') }}" + loop_control: + label: "{{ item.0.port }}/{{ item.0.proto | default('tcp') }} from {{ item.1 }}" + when: lxc_docker_host_manage_ufw + +- name: Allow Node Exporter from the monitoring host + community.general.ufw: + rule: allow + port: "{{ lxc_docker_host_node_exporter_port | string }}" + proto: tcp + src: "{{ lxc_docker_host_monitoring_host }}" + when: + - lxc_docker_host_manage_ufw + - lxc_docker_host_allow_node_exporter + +- name: Enable the restrictive firewall + community.general.ufw: + state: enabled + policy: "{{ lxc_docker_host_ufw_policy }}" + direction: incoming + when: + - lxc_docker_host_manage_ufw + - lxc_docker_host_ufw_enable + +# --- Verification ---------------------------------------------------------- +- name: Verify the effective Docker storage driver + ansible.builtin.command: + argv: + - docker + - info + - --format + - "{% raw %}{{.Driver}}{% endraw %}" + register: lxc_docker_host_driver + changed_when: false + failed_when: lxc_docker_host_driver.stdout != lxc_docker_host_storage_driver + when: lxc_docker_host_verify_storage_driver