--- - name: Converged Proxmox & Optional Linstor Deployment hosts: proxmox become: true gather_facts: true any_errors_fatal: true vars: # Dynamically detect if we are in Single-Node or Cluster mode is_single_node: "{{ groups['proxmox'] | length == 1 }}" # standalone_mode can be set manually in group_vars to bypass cluster/linstor tasks # even with multiple nodes in inventory (e.g. independent nodes for the same customer) _skip_cluster: "{{ (is_single_node | bool) or (standalone_mode | default(false) | bool) }}" # Corosync clustering IPs corosync_ip: "{{ priv_ip | default(ansible_host) }}" master_corosync_ip: "{{ hostvars[groups['proxmox'][0]]['priv_ip'] | default(hostvars[groups['proxmox'][0]]['ansible_host']) }}" is_master: "{{ inventory_hostname == groups['proxmox'][0] }}" tasks: # ========================================== # PHASE 0: P2P PRIVATE NETWORK # ========================================== - name: Configure P2P Private Network Bridge ansible.builtin.include_role: name: p2p_network when: - not _skip_cluster | bool - priv_nic_1 is defined - priv_nic_2 is defined - priv_ip is defined # ========================================== # PHASE 1: REPOSITORIES & LICENSING (PVE 9 / Trixie) # ========================================== - name: Disable IPv6 system-wide ansible.posix.sysctl: name: "{{ item }}" value: "1" state: present sysctl_file: /etc/sysctl.d/99-disable-ipv6.conf reload: true loop: - net.ipv6.conf.all.disable_ipv6 - net.ipv6.conf.default.disable_ipv6 - net.ipv6.conf.lo.disable_ipv6 when: disable_ipv6 | default(false) | bool - name: Apply Proxmox Enterprise Key ansible.builtin.command: pvesubscription set -k {{ pve_enterprise_key }} when: pve_enterprise_key | default('') | length > 0 register: pve_key_result changed_when: "'successfully' in pve_key_result.stdout" - name: Ensure Legacy .list Files are Removed (Prevent APT Duplicates) ansible.builtin.file: path: "{{ item }}" state: absent loop: - /etc/apt/sources.list.d/pve-enterprise.list - /etc/apt/sources.list.d/pve-no-subscription.list - /etc/apt/sources.list.d/ceph.list - name: Manage Proxmox Enterprise Repository (DEB822) ansible.builtin.deb822_repository: name: pve-enterprise types: deb uris: https://enterprise.proxmox.com/debian/pve suites: trixie components: pve-enterprise signed_by: /usr/share/keyrings/proxmox-archive-keyring.gpg state: "{{ 'present' if pve_enterprise_key | default('') | length > 0 else 'absent' }}" - name: Manage Proxmox No-Subscription Repository (DEB822) ansible.builtin.deb822_repository: name: pve-no-subscription types: deb uris: http://download.proxmox.com/debian/pve suites: trixie components: pve-no-subscription signed_by: /usr/share/keyrings/proxmox-archive-keyring.gpg state: "{{ 'absent' if pve_enterprise_key | default('') | length > 0 else 'present' }}" - name: Remove Ceph Enterprise Repository (Unused in Linstor setups) ansible.builtin.file: path: /etc/apt/sources.list.d/ceph.sources state: absent when: pve_enterprise_key | default('') | length == 0 - name: Add Linstor Repositories ansible.builtin.include_role: name: linstor_repo when: - deploy_linstor | bool - not _skip_cluster | bool # ========================================== # PHASE 2: UPDATES & NAG REMOVAL # ========================================== # NOTE: async/poll used here to prevent SSH timeout during long dist-upgrade. # The task is submitted asynchronously (up to 1h) and polled every 30s. - name: Update APT cache and run dist-upgrade ansible.builtin.apt: update_cache: true upgrade: dist autoremove: true lock_timeout: 300 # Wait up to 5 min for dpkg lock from other apt processes dpkg_options: 'force-confnew,force-confdef' # Non-interactive config file handling environment: DEBIAN_FRONTEND: noninteractive # Prevent debconf from blocking on prompts async: 3600 # Allow up to 1 hour for the full upgrade poll: 30 # Check progress every 30 seconds - name: Remove Proxmox Subscription Nag (JS Patch) ansible.builtin.replace: path: /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js regexp: "(Ext.Msg.show\\(\\{\\s+title: gettext\\('No valid subscription'\\),)" replace: "void({ //\\1" notify: Restart pveproxy - name: Install Linstor Packages ansible.builtin.include_role: name: linstor_install when: - deploy_linstor | bool - not _skip_cluster | bool - name: Check if a reboot is required by APT ansible.builtin.stat: path: /var/run/reboot-required register: reboot_required_file - name: Reboot the node gracefully ansible.builtin.reboot: msg: "Rebooting node to apply new kernel/system updates" reboot_timeout: 600 when: reboot_required_file.stat.exists # Explicit barrier: ensure ALL nodes are back online before proceeding to Phase 3. # Without this, any_errors_fatal would abort if one node reboots and another doesn't, # causing them to arrive at Phase 3 at different times. - name: Wait for all nodes to be reachable after (optional) reboot ansible.builtin.wait_for_connection: timeout: 300 sleep: 5 - name: Sync barrier — wait for all nodes before clustering phase ansible.builtin.meta: clear_host_errors # ========================================== # PHASE 3: PROXMOX CLUSTERING # ========================================== - name: PROXMOX CLUSTERING (Skipped in Single Node / Standalone Mode) when: not _skip_cluster | bool block: - name: Ensure SSH keypair exists for root ansible.builtin.user: name: root generate_ssh_key: true ssh_key_bits: 4096 - name: Fetch public SSH keys from all nodes ansible.builtin.slurp: src: /root/.ssh/id_rsa.pub register: ssh_pub_keys - name: Distribute SSH keys to build full-mesh trust ansible.posix.authorized_key: user: root key: "{{ hostvars[item]['ssh_pub_keys']['content'] | b64decode }}" state: present loop: "{{ ansible_play_hosts }}" - name: Compute SSH wildcard host patterns for cluster subnets ansible.builtin.set_fact: _corosync_subnet: "{{ master_corosync_ip | regex_replace('\\.[0-9]+$', '.*') }}" _mgmt_subnet: >- {{ hostvars[groups['proxmox'][0]]['ansible_host'] | regex_replace('\\.[0-9]+$', '.*') }} - name: Automatically accept host keys for cluster subnets ansible.builtin.blockinfile: path: /root/.ssh/config create: true mode: '0600' block: | Host {{ _corosync_subnet }} {{ _mgmt_subnet }} StrictHostKeyChecking no UserKnownHostsFile=/dev/null - name: Check current Proxmox cluster status ansible.builtin.command: pvecm status register: pvecm_status ignore_errors: true changed_when: false - name: Initialize the Proxmox Cluster (Master Node) ansible.builtin.command: pvecm create {{ pve_cluster_name }} --link0 {{ corosync_ip }} changed_when: true when: - pvecm_status.rc != 0 - is_master | bool - name: Wait for Corosync to stabilize ansible.builtin.pause: seconds: 10 when: - pvecm_status.rc != 0 - is_master | bool - name: Join the Proxmox Cluster (Worker Nodes — one at a time) ansible.builtin.command: > pvecm add {{ master_corosync_ip }} --link0 {{ corosync_ip }} --use_ssh yes register: pvecm_join changed_when: true failed_when: pvecm_join.rc != 0 or 'TASK ERROR' in pvecm_join.stdout or 'TASK ERROR' in pvecm_join.stderr when: - pvecm_status.rc != 0 - not is_master | bool throttle: 1 # Prevent race condition: pvecm add does not support parallel joins async: 120 poll: 10 # ========================================== # PHASE 4: LINSTOR CONFIGURATION # ========================================== - name: LINSTOR CONFIGURATION (Skipped if deploy_linstor=false or Single Node / Standalone) when: - deploy_linstor | bool - not _skip_cluster | bool block: - name: Configure Linstor Cluster ansible.builtin.include_role: name: linstor_cluster - name: Configure Linstor Storage ansible.builtin.include_role: name: linstor_storage - name: Configure Linstor HA ansible.builtin.include_role: name: linstor_ha - name: Configure Proxmox Storage Plugin for Linstor ansible.builtin.include_role: name: proxmox_storage handlers: - name: Restart pveproxy ansible.builtin.service: name: pveproxy state: restarted