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
206 lines
7.3 KiB
YAML
206 lines
7.3 KiB
YAML
---
|
|
- name: Guard Gyro VMID before API updates
|
|
hosts: mini-pc
|
|
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:
|
|
- name: Read existing VMID 150 configuration
|
|
ansible.builtin.command: pct config 150
|
|
register: gyro_existing_vmid
|
|
check_mode: false
|
|
changed_when: false
|
|
failed_when: false
|
|
|
|
- name: Refuse to modify a foreign VMID 150
|
|
ansible.builtin.assert:
|
|
that:
|
|
- gyro_existing_vmid.rc != 0 or gyro_existing_hostname == 'gyro'
|
|
fail_msg: VMID 150 already exists and is not the Gyro container.
|
|
vars:
|
|
gyro_existing_hostname: >-
|
|
{{ gyro_existing_vmid.stdout_lines
|
|
| select('match', '^hostname: ')
|
|
| map('regex_replace', '^hostname: ', '')
|
|
| first
|
|
| default('') }}
|
|
|
|
- name: Create Gyro LXC on mini-pc
|
|
hosts: localhost
|
|
connection: local
|
|
become: false
|
|
gather_facts: false
|
|
vars:
|
|
pve_gyro_legacy_provisioning_enabled: "{{ homelab_services['gyro'].provisioner != 'tofu' }}"
|
|
ansible_become: false
|
|
ansible_python_interpreter: "{{ ansible_playbook_python }}"
|
|
pve_lxc_vmid: 150
|
|
pve_lxc_node: mini-pc
|
|
pve_lxc_hostname: gyro
|
|
pve_lxc_ip: 192.168.1.35/24
|
|
pve_lxc_gateway: 192.168.1.1
|
|
pve_lxc_disk: local-lvm:2
|
|
pve_lxc_cores: 1
|
|
pve_lxc_memory: 512
|
|
pve_lxc_swap: 256
|
|
pve_lxc_startup: order=80
|
|
pve_lxc_unprivileged: true
|
|
pve_lxc_update: false
|
|
pve_lxc_features: []
|
|
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:
|
|
- role: pve_lxc
|
|
|
|
- name: Configure Gyro LXC isolation
|
|
hosts: mini-pc
|
|
gather_facts: false
|
|
vars:
|
|
pve_gyro_legacy_provisioning_enabled: "{{ homelab_services['gyro'].provisioner != 'tofu' }}"
|
|
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:
|
|
- name: Read Gyro LXC configuration
|
|
ansible.builtin.command: "pct config {{ gyro_vmid }}"
|
|
register: gyro_lxc_config
|
|
changed_when: false
|
|
|
|
- name: Require expected Gyro LXC properties
|
|
ansible.builtin.assert:
|
|
that:
|
|
- "'hostname: gyro' in gyro_lxc_config.stdout"
|
|
- "'unprivileged: 1' in gyro_lxc_config.stdout"
|
|
- "'rootfs: local-lvm:' in gyro_lxc_config.stdout"
|
|
- "'onboot: 1' in gyro_lxc_config.stdout"
|
|
|
|
- name: Check for an existing Gyro firewall file
|
|
ansible.builtin.stat:
|
|
path: "/etc/pve/firewall/{{ gyro_vmid }}.fw"
|
|
register: gyro_firewall_file
|
|
|
|
- name: Preserve the existing Gyro firewall file
|
|
ansible.builtin.slurp:
|
|
src: "/etc/pve/firewall/{{ gyro_vmid }}.fw"
|
|
register: gyro_previous_firewall
|
|
when: gyro_firewall_file.stat.exists
|
|
|
|
- name: Apply and verify Gyro firewall
|
|
block:
|
|
- name: Render Proxmox firewall for Gyro
|
|
ansible.builtin.copy:
|
|
dest: "/tmp/gyro-{{ gyro_vmid }}.fw"
|
|
owner: root
|
|
group: root
|
|
mode: "0640"
|
|
content: |
|
|
[OPTIONS]
|
|
enable: 1
|
|
policy_in: DROP
|
|
policy_out: ACCEPT
|
|
|
|
[RULES]
|
|
IN ACCEPT -source {{ homelab_lan_cidr }} -p tcp -dport 22 -log nolog
|
|
IN ACCEPT -source {{ openvpn_network_cidr }} -p tcp -dport 22 -log nolog
|
|
OUT ACCEPT -dest 192.168.1.27 -p tcp -dport 7890 -log nolog
|
|
OUT DROP -dest {{ homelab_lan_cidr }} -log nolog
|
|
register: gyro_rendered_firewall
|
|
changed_when: false
|
|
|
|
- name: Compare rendered and active Gyro firewall
|
|
ansible.builtin.command: >-
|
|
cmp -s /tmp/gyro-{{ gyro_vmid }}.fw /etc/pve/firewall/{{ gyro_vmid }}.fw
|
|
register: gyro_firewall_comparison
|
|
changed_when: false
|
|
failed_when: gyro_firewall_comparison.rc not in [0, 1]
|
|
when: gyro_firewall_file.stat.exists
|
|
|
|
- name: Install Proxmox firewall for Gyro
|
|
ansible.builtin.command: >-
|
|
cp /tmp/gyro-{{ gyro_vmid }}.fw /etc/pve/firewall/{{ gyro_vmid }}.fw
|
|
when: not gyro_firewall_file.stat.exists or gyro_firewall_comparison.rc != 0
|
|
changed_when: true
|
|
|
|
- name: Validate Proxmox firewall configuration
|
|
ansible.builtin.command: pve-firewall compile
|
|
changed_when: false
|
|
|
|
- name: Read cluster firewall options
|
|
ansible.builtin.command: pvesh get /cluster/firewall/options --output-format json
|
|
register: gyro_cluster_firewall_options
|
|
changed_when: false
|
|
|
|
- name: Report staged Proxmox firewall state
|
|
ansible.builtin.debug:
|
|
msg: >-
|
|
{{ 'Gyro Proxmox firewall is active.'
|
|
if (gyro_cluster_firewall_options.stdout | from_json).enable | default(0) | int == 1
|
|
else 'Gyro Proxmox firewall is staged but inactive because the cluster firewall is disabled; UFW remains the enforced isolation layer.' }}
|
|
|
|
- name: Verify SSH remains reachable through the firewall
|
|
ansible.builtin.wait_for_connection:
|
|
timeout: 180
|
|
delegate_to: gyro
|
|
vars:
|
|
ansible_become: false
|
|
rescue:
|
|
- name: Restore the previous Gyro firewall file
|
|
ansible.builtin.copy:
|
|
dest: "/tmp/gyro-{{ gyro_vmid }}-previous.fw"
|
|
content: "{{ gyro_previous_firewall.content | b64decode }}"
|
|
owner: root
|
|
group: root
|
|
mode: "0640"
|
|
when: gyro_firewall_file.stat.exists
|
|
|
|
- name: Reinstall the previous Gyro firewall file
|
|
ansible.builtin.command: >-
|
|
cp /tmp/gyro-{{ gyro_vmid }}-previous.fw /etc/pve/firewall/{{ gyro_vmid }}.fw
|
|
when: gyro_firewall_file.stat.exists
|
|
changed_when: true
|
|
|
|
- name: Remove the failed new Gyro firewall file
|
|
ansible.builtin.file:
|
|
path: "/etc/pve/firewall/{{ gyro_vmid }}.fw"
|
|
state: absent
|
|
when: not gyro_firewall_file.stat.exists
|
|
|
|
- name: Recompile restored Proxmox firewall configuration
|
|
ansible.builtin.command: pve-firewall compile
|
|
changed_when: false
|
|
|
|
- name: Remove temporary Gyro firewall files after rollback
|
|
ansible.builtin.file:
|
|
path: "{{ item }}"
|
|
state: absent
|
|
loop:
|
|
- "/tmp/gyro-{{ gyro_vmid }}.fw"
|
|
- "/tmp/gyro-{{ gyro_vmid }}-previous.fw"
|
|
|
|
- name: Stop after rolling back the Gyro firewall
|
|
ansible.builtin.fail:
|
|
msg: Gyro firewall validation or SSH reachability failed; the previous firewall state was restored.
|
|
|
|
- name: Remove temporary Gyro firewall file
|
|
ansible.builtin.file:
|
|
path: "/tmp/gyro-{{ gyro_vmid }}.fw"
|
|
state: absent
|
|
changed_when: false
|
|
|
|
- name: Wait for Gyro SSH
|
|
hosts: gyro
|
|
gather_facts: false
|
|
tasks:
|
|
- name: Wait for Gyro to accept SSH connections
|
|
ansible.builtin.wait_for_connection:
|
|
timeout: 180
|