Files
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

463 lines
17 KiB
YAML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
- name: Guard Grimmory VMID before API updates
hosts: cloud-pc
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:
- name: Read existing VMID 149 configuration
ansible.builtin.command: pct config 149
register: grimmory_existing_vmid
check_mode: false
changed_when: false
failed_when: false
- name: Refuse to modify a foreign VMID 149
ansible.builtin.assert:
that:
- grimmory_existing_vmid.rc != 0 or grimmory_existing_hostname == 'grimmory'
fail_msg: VMID 149 already exists and is not the Grimmory container.
vars:
grimmory_existing_hostname: >-
{{ grimmory_existing_vmid.stdout_lines
| select('match', '^hostname: ')
| map('regex_replace', '^hostname: ', '')
| first
| default('') }}
- name: Create Grimmory LXC on cloud-pc
hosts: localhost
connection: local
become: 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:
ansible_become: false
ansible_python_interpreter: "{{ ansible_playbook_python }}"
pve_lxc_vmid: 149
pve_lxc_node: cloud-pc
pve_lxc_hostname: grimmory
pve_lxc_ip: 192.168.1.34/24
pve_lxc_gateway: 192.168.1.1
pve_lxc_disk: data:64
pve_lxc_cores: 2
pve_lxc_memory: 4096
pve_lxc_swap: 1024
pve_lxc_startup: order=100
pve_lxc_unprivileged: true
pve_lxc_update: false
pve_lxc_features:
- nesting=1
pve_lxc_ostemplate: local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst
roles:
- role: pve_lxc
- name: Configure Grimmory LXC devices
hosts: cloud-pc
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:
grimmory_vmid: 149
handlers:
- name: restart Grimmory LXC
ansible.builtin.command: "pct reboot {{ grimmory_vmid }}"
changed_when: true
tasks:
- name: Read Grimmory LXC configuration
ansible.builtin.command: "pct config {{ grimmory_vmid }}"
register: grimmory_lxc_config
changed_when: false
- name: Require expected Grimmory LXC properties
ansible.builtin.assert:
that:
- "'unprivileged: 1' in grimmory_lxc_config.stdout"
- "'rootfs: data:' in grimmory_lxc_config.stdout"
- "'onboot: 1' in grimmory_lxc_config.stdout"
- name: Configure Grimmory LXC network
ansible.builtin.command: >-
pct set {{ grimmory_vmid }}
--net0 name=eth0,bridge=vmbr0,firewall=1,gw=192.168.1.1,ip=192.168.1.34/24
vars:
grimmory_net0: >-
{{ grimmory_lxc_config.stdout_lines
| select('match', '^net0: ')
| first
| default('') }}
when: >-
'name=eth0' not in grimmory_net0 or
'bridge=vmbr0' not in grimmory_net0 or
'firewall=1' not in grimmory_net0 or
'gw=192.168.1.1' not in grimmory_net0 or
'ip=192.168.1.34/24' not in grimmory_net0
notify: restart Grimmory LXC
- name: Enable keyctl for Docker in Grimmory LXC
ansible.builtin.command: "pct set {{ grimmory_vmid }} --features nesting=1,keyctl=1"
when: "'nesting=1' not in grimmory_lxc_config.stdout or 'keyctl=1' not in grimmory_lxc_config.stdout"
notify: restart Grimmory LXC
- name: Allow FUSE device in Grimmory LXC config
ansible.builtin.lineinfile:
path: "/etc/pve/lxc/{{ grimmory_vmid }}.conf"
line: "lxc.cgroup2.devices.allow: c 10:229 rwm"
state: present
notify: restart Grimmory LXC
- name: Bind mount FUSE device in Grimmory LXC config
ansible.builtin.lineinfile:
path: "/etc/pve/lxc/{{ grimmory_vmid }}.conf"
line: "lxc.mount.entry: /dev/fuse dev/fuse none bind,create=file"
state: present
notify: restart Grimmory LXC
- name: Apply pending Grimmory LXC configuration
ansible.builtin.meta: flush_handlers
- name: Wait for Grimmory SSH through ru-vps
ansible.builtin.wait_for_connection:
timeout: 180
delegate_to: grimmory
vars:
ansible_become: false
- name: Configure Grimmory runtime
# Таргет переопределяем ради 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
vars:
ansible_become: false
grimmory_root: /opt/grimmory
grimmory_image: grimmory/grimmory:v3.2.4@sha256:dfa7afdfcf25d649fd664497a62385dd00cd9678c37546e182c172e41c8e80cb
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:
- name: Install Grimmory runtime packages
ansible.builtin.apt:
name:
- ca-certificates
- curl
- docker-compose
- docker.io
- fuse-overlayfs
- mariadb-client
- openssl
- prometheus-node-exporter
- ufw
state: present
update_cache: true
- name: Verify FUSE device exists
ansible.builtin.stat:
path: /dev/fuse
register: grimmory_fuse
- name: Require FUSE device
ansible.builtin.assert:
that:
- grimmory_fuse.stat.exists
- grimmory_fuse.stat.ischr
- 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: grimmory_docker_config
- name: Enable Docker service
ansible.builtin.systemd:
name: docker
enabled: true
state: "{{ 'restarted' if grimmory_docker_config.changed else 'started' }}"
- name: Allow SSH from the HomeLab LAN
community.general.ufw:
rule: allow
port: "22"
proto: tcp
src: "{{ homelab_lan_cidr }}"
- name: Allow SSH from the OpenVPN network
community.general.ufw:
rule: allow
port: "22"
proto: tcp
src: "{{ openvpn_network_cidr }}"
- name: Allow Grimmory from the HomeLab LAN
community.general.ufw:
rule: allow
port: "6060"
proto: tcp
src: "{{ homelab_lan_cidr }}"
- name: Allow Grimmory from the OpenVPN network
community.general.ufw:
rule: allow
port: "6060"
proto: tcp
src: "{{ openvpn_network_cidr }}"
- name: Allow Node Exporter from the monitoring LXC
community.general.ufw:
rule: allow
port: "9100"
proto: tcp
src: 192.168.1.30
- name: Enable restrictive Grimmory firewall
community.general.ufw:
state: enabled
policy: deny
direction: incoming
- name: Create Grimmory directories
ansible.builtin.file:
path: "{{ item.path }}"
state: directory
owner: "{{ item.owner }}"
group: "{{ item.group }}"
mode: "{{ item.mode }}"
loop:
- { path: "{{ grimmory_root }}", owner: root, group: root, mode: "0750" }
- { path: "{{ grimmory_root }}/data", owner: "1000", group: "1000", mode: "0750" }
- { path: "{{ grimmory_root }}/books", owner: "1000", group: "1000", mode: "0750" }
- { path: "{{ grimmory_root }}/bookdrop", owner: "1000", group: "1000", mode: "0750" }
- { path: "{{ grimmory_root }}/mariadb", owner: "1000", group: "1000", mode: "0750" }
- { path: "{{ grimmory_root }}/backup-staging", owner: root, group: root, mode: "0700" }
- name: Generate Grimmory secrets once
ansible.builtin.shell: |
set -eu
umask 077
if [ -e {{ grimmory_root }}/.env ]; then
exit 0
fi
db_password=$(openssl rand -hex 32)
root_password=$(openssl rand -hex 32)
cat > {{ grimmory_root }}/.env <<EOF
TZ=Europe/Moscow
APP_USER_ID=1000
APP_GROUP_ID=1000
DB_USER_ID=1000
DB_GROUP_ID=1000
DB_PASSWORD=$db_password
MYSQL_ROOT_PASSWORD=$root_password
EOF
printf created
args:
executable: /bin/sh
register: grimmory_secrets
changed_when: grimmory_secrets.stdout == 'created'
no_log: true
- name: Enforce Grimmory secret file permissions
ansible.builtin.file:
path: "{{ grimmory_root }}/.env"
owner: root
group: root
mode: "0600"
- name: Install Grimmory environment example
ansible.builtin.copy:
dest: "{{ grimmory_root }}/.env.example"
owner: root
group: root
mode: "0644"
content: |
TZ=Europe/Moscow
APP_USER_ID=1000
APP_GROUP_ID=1000
DB_USER_ID=1000
DB_GROUP_ID=1000
DB_PASSWORD=replace-with-random-password
MYSQL_ROOT_PASSWORD=replace-with-separate-random-password
- name: Install Grimmory Compose configuration
ansible.builtin.copy:
dest: "{{ grimmory_root }}/compose.yml"
owner: root
group: root
mode: "0644"
content: |
services:
grimmory:
image: {{ grimmory_image }}
container_name: grimmory
environment:
USER_ID: "${APP_USER_ID}"
GROUP_ID: "${APP_GROUP_ID}"
TZ: "${TZ}"
DATABASE_URL: jdbc:mariadb://mariadb:3306/grimmory
DATABASE_USERNAME: grimmory
DATABASE_PASSWORD: "${DB_PASSWORD}"
DISK_TYPE: LOCAL
ALLOWED_ORIGINS: https://books.ada-dev.ru
depends_on:
mariadb:
condition: service_healthy
ports:
- "{{ grimmory_bind_ip }}:6060:6060"
volumes:
- ./data:/app/data
- ./books:/books
- ./bookdrop:/bookdrop
restart: unless-stopped
mariadb:
image: {{ grimmory_mariadb_image }}
container_name: grimmory-mariadb
environment:
PUID: "${DB_USER_ID}"
PGID: "${DB_GROUP_ID}"
TZ: "${TZ}"
MYSQL_ROOT_PASSWORD: "${MYSQL_ROOT_PASSWORD}"
MYSQL_DATABASE: grimmory
MYSQL_USER: grimmory
MYSQL_PASSWORD: "${DB_PASSWORD}"
volumes:
- ./mariadb:/config
healthcheck:
test: ["CMD", "mariadb-admin", "ping", "-h", "localhost"]
interval: 5s
timeout: 5s
retries: 20
restart: unless-stopped
register: grimmory_compose
- name: Install Docker firewall script
ansible.builtin.copy:
dest: /usr/local/sbin/grimmory-docker-firewall
owner: root
group: root
mode: "0755"
content: |
#!/bin/sh
set -eu
iptables -N GRIMMORY-FILTER 2>/dev/null || true
iptables -F GRIMMORY-FILTER
iptables -A GRIMMORY-FILTER -m conntrack --ctstate ESTABLISHED,RELATED -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 {{ grimmory_bind_ip }} --ctorigdstport 6060 -j ACCEPT
iptables -A GRIMMORY-FILTER -p tcp -m conntrack --ctorigdst {{ grimmory_bind_ip }} --ctorigdstport 6060 -j DROP
iptables -A GRIMMORY-FILTER -j RETURN
iptables -C DOCKER-USER -j GRIMMORY-FILTER 2>/dev/null || iptables -I DOCKER-USER 1 -j GRIMMORY-FILTER
register: grimmory_firewall_script
- name: Install Docker firewall service
ansible.builtin.copy:
dest: /etc/systemd/system/grimmory-docker-firewall.service
owner: root
group: root
mode: "0644"
content: |
[Unit]
Description=Restrict Grimmory Docker published port
After=docker.service
Requires=docker.service
Before=grimmory.service
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/grimmory-docker-firewall
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
register: grimmory_firewall_unit
- name: Install Grimmory systemd unit
ansible.builtin.copy:
dest: /etc/systemd/system/grimmory.service
owner: root
group: root
mode: "0644"
content: |
[Unit]
Description=Grimmory Compose stack
Wants=network-online.target
After=network-online.target docker.service grimmory-docker-firewall.service
Requires=docker.service grimmory-docker-firewall.service
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory={{ grimmory_root }}
ExecStart=/usr/bin/docker compose -f {{ grimmory_root }}/compose.yml up -d --remove-orphans
ExecStop=/usr/bin/docker compose -f {{ grimmory_root }}/compose.yml down
[Install]
WantedBy=multi-user.target
register: grimmory_unit
- name: Reload systemd for Grimmory units
ansible.builtin.systemd:
daemon_reload: true
when: grimmory_firewall_unit.changed or grimmory_unit.changed
- name: Enable and apply Docker firewall
ansible.builtin.systemd:
name: grimmory-docker-firewall
enabled: true
state: "{{ 'restarted' if grimmory_firewall_script.changed or grimmory_firewall_unit.changed else 'started' }}"
- name: Validate Grimmory Compose configuration
ansible.builtin.command: docker compose -f {{ grimmory_root }}/compose.yml config --quiet
args:
chdir: "{{ grimmory_root }}"
changed_when: false
no_log: true
- name: Enable and start Grimmory
ansible.builtin.systemd:
name: grimmory
enabled: true
state: "{{ 'restarted' if grimmory_compose.changed or grimmory_unit.changed else 'started' }}"
- name: Wait for Grimmory health endpoint
ansible.builtin.uri:
url: http://{{ grimmory_bind_ip }}:6060/api/v1/healthcheck
status_code: 200
register: grimmory_health
retries: 120
delay: 5
until: grimmory_health.status == 200
- name: Verify Docker storage driver
ansible.builtin.command: docker info --format '{{ "{{" }}.Driver{{ "}}" }}'
register: grimmory_docker_driver
changed_when: false
failed_when: grimmory_docker_driver.stdout != 'fuse-overlayfs'