372 lines
15 KiB
YAML
372 lines
15 KiB
YAML
---
|
|
# =============================================================================
|
|
# proxmox_migrate_vms.yml
|
|
# Flexible VM migration playbook supporting three modes:
|
|
#
|
|
# drain — move all VMs off a specific node (pre-maintenance)
|
|
# rebalance — redistribute VMs evenly across all online nodes by resources
|
|
# restore — return VMs to their origin nodes using a drain state file
|
|
# targeted — migrate specific VMIDs or tagged VMs to a specified target
|
|
#
|
|
# Usage examples:
|
|
# # Drain a node before maintenance
|
|
# ansible-playbook proxmox_migrate_vms.yml -e "migrate_mode=drain migrate_source_node=pm-node-01"
|
|
#
|
|
# # Rebalance the cluster
|
|
# ansible-playbook proxmox_migrate_vms.yml -e "migrate_mode=rebalance"
|
|
#
|
|
# # Restore VMs to origin after maintenance
|
|
# ansible-playbook proxmox_migrate_vms.yml -e "migrate_mode=restore migrate_source_node=pm-node-01"
|
|
#
|
|
# # Migrate specific VMIDs to a target node
|
|
# ansible-playbook proxmox_migrate_vms.yml -e "migrate_mode=targeted migrate_vmids=[100,101] migrate_target_node=pm-node-02"
|
|
#
|
|
# # Migrate VMs by tag
|
|
# ansible-playbook proxmox_migrate_vms.yml -e "migrate_mode=targeted migrate_tags=[win11] migrate_target_node=pm-node-02"
|
|
# =============================================================================
|
|
|
|
- name: "Proxmox | Migrate VMs"
|
|
hosts: proxmox_cluster
|
|
gather_facts: true
|
|
run_once: true
|
|
|
|
vars:
|
|
# Mode: drain | rebalance | restore | targeted
|
|
migrate_mode: drain
|
|
|
|
# Source node (required for drain and restore modes)
|
|
migrate_source_node: ""
|
|
|
|
# Target node (required for targeted mode, optional for drain)
|
|
migrate_target_node: ""
|
|
|
|
# Targeted mode filters
|
|
migrate_vmids: [] # list of VMIDs to migrate
|
|
migrate_tags: [] # list of tags to match
|
|
|
|
# Rebalance threshold — don't migrate if imbalance is below this % of total memory
|
|
rebalance_threshold_pct: 10
|
|
|
|
# Shared drain role vars
|
|
drain_target_strategy: "{{ 'explicit' if migrate_target_node != '' else 'resources' }}"
|
|
drain_target_node: "{{ migrate_target_node }}"
|
|
drain_state_dir: "/tmp/proxmox_drain_state"
|
|
|
|
# Restore vars
|
|
restore_state_dir: "/tmp/proxmox_drain_state"
|
|
|
|
pre_tasks:
|
|
- name: "Migrate | Validate mode"
|
|
ansible.builtin.fail:
|
|
msg: >-
|
|
Invalid migrate_mode '{{ migrate_mode }}'.
|
|
Must be one of: drain, rebalance, restore, targeted.
|
|
when: migrate_mode not in ['drain', 'rebalance', 'restore', 'targeted']
|
|
|
|
- name: "Migrate | Validate drain — source node required"
|
|
ansible.builtin.fail:
|
|
msg: "migrate_source_node is required for drain mode."
|
|
when:
|
|
- migrate_mode == 'drain'
|
|
- migrate_source_node == ''
|
|
|
|
- name: "Migrate | Validate restore — source node required"
|
|
ansible.builtin.fail:
|
|
msg: "migrate_source_node is required for restore mode."
|
|
when:
|
|
- migrate_mode == 'restore'
|
|
- migrate_source_node == ''
|
|
|
|
- name: "Migrate | Validate targeted — VMIDs or tags required"
|
|
ansible.builtin.fail:
|
|
msg: "migrate_vmids or migrate_tags must be set for targeted mode."
|
|
when:
|
|
- migrate_mode == 'targeted'
|
|
- migrate_vmids | length == 0
|
|
- migrate_tags | length == 0
|
|
|
|
- name: "Migrate | Log operation"
|
|
ansible.builtin.debug:
|
|
msg: >-
|
|
Proxmox VM migration —
|
|
client={{ client_name | default('Unknown') }}
|
|
mode={{ migrate_mode }}
|
|
{% if migrate_source_node != '' %}source={{ migrate_source_node }}{% endif %}
|
|
{% if migrate_target_node != '' %}target={{ migrate_target_node }}{% endif %}
|
|
{% if migrate_vmids | length > 0 %}vmids={{ migrate_vmids }}{% endif %}
|
|
{% if migrate_tags | length > 0 %}tags={{ migrate_tags }}{% endif %}
|
|
|
|
roles:
|
|
- role: proxmox_preflight
|
|
|
|
tasks:
|
|
# ── DRAIN mode ─────────────────────────────────────────────────────────────
|
|
- name: "Migrate | DRAIN mode"
|
|
ansible.builtin.include_role:
|
|
name: proxmox_drain
|
|
vars:
|
|
current_node: "{{ migrate_source_node }}"
|
|
when: migrate_mode == 'drain'
|
|
|
|
# ── RESTORE mode ───────────────────────────────────────────────────────────
|
|
- name: "Migrate | RESTORE mode"
|
|
ansible.builtin.include_role:
|
|
name: proxmox_restore
|
|
vars:
|
|
current_node: "{{ migrate_source_node }}"
|
|
when: migrate_mode == 'restore'
|
|
|
|
# ── REBALANCE mode ─────────────────────────────────────────────────────────
|
|
- name: "Migrate | REBALANCE | Get all node info"
|
|
community.proxmox.proxmox_node_info:
|
|
api_host: "{{ api_host }}"
|
|
api_user: "{{ api_user }}"
|
|
api_token_id: "{{ api_token_id }}"
|
|
api_token_secret: "{{ api_token_secret }}"
|
|
api_port: "{{ api_port | default(8006) }}"
|
|
validate_certs: "{{ validate_certs | default(false) }}"
|
|
register: rebalance_nodes
|
|
delegate_to: localhost
|
|
when: migrate_mode == 'rebalance'
|
|
|
|
- name: "Migrate | REBALANCE | Get all VM info per node"
|
|
community.proxmox.proxmox_vm_info:
|
|
api_host: "{{ api_host }}"
|
|
api_user: "{{ api_user }}"
|
|
api_token_id: "{{ api_token_id }}"
|
|
api_token_secret: "{{ api_token_secret }}"
|
|
api_port: "{{ api_port | default(8006) }}"
|
|
validate_certs: "{{ validate_certs | default(false) }}"
|
|
node: "{{ item.node }}"
|
|
loop: >-
|
|
{{ rebalance_nodes.proxmox_nodes
|
|
| selectattr('status', 'equalto', 'online')
|
|
| list }}
|
|
loop_control:
|
|
label: "{{ item.node }}"
|
|
register: rebalance_vms_per_node
|
|
delegate_to: localhost
|
|
when: migrate_mode == 'rebalance'
|
|
|
|
- name: "Migrate | REBALANCE | Calculate node loads"
|
|
ansible.builtin.set_fact:
|
|
rebalance_node_loads: >-
|
|
{% set loads = [] %}
|
|
{% for result in rebalance_vms_per_node.results %}
|
|
{% set node_name = result.item.node %}
|
|
{% set node_info = rebalance_nodes.proxmox_nodes
|
|
| selectattr('node', 'equalto', node_name)
|
|
| first %}
|
|
{% set vm_mem = result.proxmox_vms
|
|
| map(attribute='mem')
|
|
| map('default', 0)
|
|
| sum %}
|
|
{% set free_mem = node_info.maxmem - node_info.mem %}
|
|
{% set load_pct = (node_info.mem / node_info.maxmem * 100) | round(1) %}
|
|
{% set _ = loads.append({
|
|
'node': node_name,
|
|
'used_mem': node_info.mem,
|
|
'max_mem': node_info.maxmem,
|
|
'free_mem': free_mem,
|
|
'load_pct': load_pct,
|
|
'vm_count': result.proxmox_vms | rejectattr('template', 'equalto', true) | list | length,
|
|
'vms': result.proxmox_vms | rejectattr('template', 'equalto', true) | list
|
|
}) %}
|
|
{% endfor %}
|
|
{{ loads | sort(attribute='load_pct', reverse=true) }}
|
|
delegate_to: localhost
|
|
when: migrate_mode == 'rebalance'
|
|
|
|
- name: "Migrate | REBALANCE | Log current distribution"
|
|
ansible.builtin.debug:
|
|
msg: >-
|
|
Current cluster load:
|
|
{% for n in rebalance_node_loads %}
|
|
{{ n.node }}: {{ n.load_pct }}% memory used, {{ n.vm_count }} VMs
|
|
{% endfor %}
|
|
when: migrate_mode == 'rebalance'
|
|
|
|
- name: "Migrate | REBALANCE | Build migration plan"
|
|
ansible.builtin.set_fact:
|
|
rebalance_migrations: >-
|
|
{% set moves = [] %}
|
|
{% set loads = rebalance_node_loads | list %}
|
|
{% set total_mem = loads | map(attribute='used_mem') | sum %}
|
|
{% set avg_mem = total_mem / loads | length %}
|
|
{% for vm in (loads | map(attribute='vms') | flatten
|
|
| rejectattr('status', 'equalto', 'stopped')
|
|
| list) %}
|
|
{% set src_node = vm.node %}
|
|
{% set src_info = loads | selectattr('node', 'equalto', src_node) | first %}
|
|
{% if src_info.load_pct | float > (avg_mem / src_info.max_mem * 100 + rebalance_threshold_pct) %}
|
|
{% set target = loads
|
|
| rejectattr('node', 'equalto', src_node)
|
|
| sort(attribute='load_pct')
|
|
| first %}
|
|
{% if target.load_pct | float < src_info.load_pct | float - rebalance_threshold_pct %}
|
|
{% set _ = moves.append({
|
|
'vmid': vm.vmid,
|
|
'name': vm.name,
|
|
'type': vm.type,
|
|
'status': vm.status,
|
|
'from': src_node,
|
|
'to': target.node
|
|
}) %}
|
|
{% endif %}
|
|
{% endif %}
|
|
{% endfor %}
|
|
{{ moves }}
|
|
delegate_to: localhost
|
|
when: migrate_mode == 'rebalance'
|
|
|
|
- name: "Migrate | REBALANCE | Log migration plan"
|
|
ansible.builtin.debug:
|
|
msg: >-
|
|
Rebalance plan ({{ rebalance_migrations | length }} migration(s)):
|
|
{% if rebalance_migrations | length == 0 %}
|
|
Cluster is already balanced within {{ rebalance_threshold_pct }}% threshold — no migrations needed.
|
|
{% else %}
|
|
{% for m in rebalance_migrations %}
|
|
{{ m.name }} (VMID {{ m.vmid }}) {{ m.from }} → {{ m.to }}
|
|
{% endfor %}
|
|
{% endif %}
|
|
when: migrate_mode == 'rebalance'
|
|
|
|
- name: "Migrate | REBALANCE | Execute KVM migrations"
|
|
ansible.builtin.command: >
|
|
qm migrate {{ item.vmid }} {{ item.to }}
|
|
{% if item.status == 'running' %}--online{% endif %}
|
|
--with-local-disks 0
|
|
loop: "{{ rebalance_migrations | selectattr('type', 'equalto', 'qemu') | list }}"
|
|
loop_control:
|
|
label: "{{ item.name }} ({{ item.from }} → {{ item.to }})"
|
|
changed_when: true
|
|
delegate_to: "{{ item.from }}"
|
|
when:
|
|
- migrate_mode == 'rebalance'
|
|
- rebalance_migrations | length > 0
|
|
|
|
- name: "Migrate | REBALANCE | Execute LXC migrations"
|
|
ansible.builtin.command: >
|
|
pct migrate {{ item.vmid }} {{ item.to }} --restart --timeout 120
|
|
loop: "{{ rebalance_migrations | selectattr('type', 'equalto', 'lxc') | list }}"
|
|
loop_control:
|
|
label: "{{ item.name | default(item.vmid) }} ({{ item.from }} → {{ item.to }})"
|
|
changed_when: true
|
|
delegate_to: "{{ item.from }}"
|
|
when:
|
|
- migrate_mode == 'rebalance'
|
|
- rebalance_migrations | length > 0
|
|
|
|
- name: "Migrate | REBALANCE | Complete"
|
|
ansible.builtin.debug:
|
|
msg: >-
|
|
✓ Rebalance complete —
|
|
{{ rebalance_migrations | length }} VM(s) redistributed.
|
|
when: migrate_mode == 'rebalance'
|
|
|
|
# ── TARGETED mode ──────────────────────────────────────────────────────────
|
|
- name: "Migrate | TARGETED | Get all VMs"
|
|
community.proxmox.proxmox_vm_info:
|
|
api_host: "{{ api_host }}"
|
|
api_user: "{{ api_user }}"
|
|
api_token_id: "{{ api_token_id }}"
|
|
api_token_secret: "{{ api_token_secret }}"
|
|
api_port: "{{ api_port | default(8006) }}"
|
|
validate_certs: "{{ validate_certs | default(false) }}"
|
|
register: targeted_all_vms
|
|
delegate_to: localhost
|
|
when: migrate_mode == 'targeted'
|
|
|
|
- name: "Migrate | TARGETED | Filter VMs by VMID"
|
|
ansible.builtin.set_fact:
|
|
targeted_vms: >-
|
|
{{ targeted_all_vms.proxmox_vms
|
|
| selectattr('vmid', 'in', migrate_vmids)
|
|
| list }}
|
|
delegate_to: localhost
|
|
when:
|
|
- migrate_mode == 'targeted'
|
|
- migrate_vmids | length > 0
|
|
|
|
- name: "Migrate | TARGETED | Filter VMs by tag"
|
|
ansible.builtin.set_fact:
|
|
targeted_vms: >-
|
|
{{ targeted_all_vms.proxmox_vms
|
|
| selectattr('tags', 'defined')
|
|
| selectattr('tags', 'search', migrate_tags | join('|'))
|
|
| list }}
|
|
delegate_to: localhost
|
|
when:
|
|
- migrate_mode == 'targeted'
|
|
- migrate_tags | length > 0
|
|
- migrate_vmids | length == 0
|
|
|
|
- name: "Migrate | TARGETED | Resolve target node"
|
|
ansible.builtin.set_fact:
|
|
targeted_resolved_target: "{{ migrate_target_node }}"
|
|
when:
|
|
- migrate_mode == 'targeted'
|
|
- migrate_target_node != ''
|
|
|
|
- name: "Migrate | TARGETED | Auto-select target by resources"
|
|
block:
|
|
- name: "Migrate | TARGETED | Get node resources"
|
|
community.proxmox.proxmox_node_info:
|
|
api_host: "{{ api_host }}"
|
|
api_user: "{{ api_user }}"
|
|
api_token_id: "{{ api_token_id }}"
|
|
api_token_secret: "{{ api_token_secret }}"
|
|
api_port: "{{ api_port | default(8006) }}"
|
|
validate_certs: "{{ validate_certs | default(false) }}"
|
|
register: targeted_nodes
|
|
delegate_to: localhost
|
|
|
|
- name: "Migrate | TARGETED | Pick best target"
|
|
ansible.builtin.set_fact:
|
|
targeted_resolved_target: >-
|
|
{{ (targeted_nodes.proxmox_nodes
|
|
| selectattr('status', 'equalto', 'online')
|
|
| sort(attribute='mem')
|
|
| first).node }}
|
|
delegate_to: localhost
|
|
when:
|
|
- migrate_mode == 'targeted'
|
|
- migrate_target_node == ''
|
|
|
|
- name: "Migrate | TARGETED | Log plan"
|
|
ansible.builtin.debug:
|
|
msg: >-
|
|
Targeted migration: {{ targeted_vms | length }} VM(s) → {{ targeted_resolved_target }}
|
|
VMIDs: {{ targeted_vms | map(attribute='vmid') | list }}
|
|
when: migrate_mode == 'targeted'
|
|
|
|
- name: "Migrate | TARGETED | Migrate KVM VMs"
|
|
ansible.builtin.command: >
|
|
qm migrate {{ item.vmid }} {{ targeted_resolved_target }}
|
|
{% if item.status == 'running' %}--online{% endif %}
|
|
--with-local-disks 0
|
|
loop: "{{ targeted_vms | selectattr('type', 'equalto', 'qemu') | list }}"
|
|
loop_control:
|
|
label: "{{ item.name }} (VMID {{ item.vmid }}) → {{ targeted_resolved_target }}"
|
|
changed_when: true
|
|
delegate_to: "{{ item.node }}"
|
|
when: migrate_mode == 'targeted'
|
|
|
|
- name: "Migrate | TARGETED | Migrate LXC containers"
|
|
ansible.builtin.command: >
|
|
pct migrate {{ item.vmid }} {{ targeted_resolved_target }} --restart --timeout 120
|
|
loop: "{{ targeted_vms | selectattr('type', 'equalto', 'lxc') | list }}"
|
|
loop_control:
|
|
label: "{{ item.name | default(item.vmid) }} (VMID {{ item.vmid }}) → {{ targeted_resolved_target }}"
|
|
changed_when: true
|
|
delegate_to: "{{ item.node }}"
|
|
when: migrate_mode == 'targeted'
|
|
|
|
- name: "Migrate | TARGETED | Complete"
|
|
ansible.builtin.debug:
|
|
msg: >-
|
|
✓ Targeted migration complete —
|
|
{{ targeted_vms | length }} VM(s) moved to {{ targeted_resolved_target }}.
|
|
when: migrate_mode == 'targeted'
|