From 161c40dbbb275c6fc074ad60f4e8b25740b436ed Mon Sep 17 00:00:00 2001 From: Semaphore Date: Fri, 13 Mar 2026 15:02:13 -0700 Subject: [PATCH] feat: hypervisor_backup_config role and playbook --- playbooks/hypervisor_backup_config.yml | 28 ++++ .../defaults/main.yml | 51 ++++++ roles/hypervisor_backup_config/tasks/main.yml | 151 ++++++++++++++++++ 3 files changed, 230 insertions(+) create mode 100644 playbooks/hypervisor_backup_config.yml create mode 100644 roles/hypervisor_backup_config/defaults/main.yml create mode 100644 roles/hypervisor_backup_config/tasks/main.yml diff --git a/playbooks/hypervisor_backup_config.yml b/playbooks/hypervisor_backup_config.yml new file mode 100644 index 0000000..53ccf35 --- /dev/null +++ b/playbooks/hypervisor_backup_config.yml @@ -0,0 +1,28 @@ +--- +# ============================================================================= +# hypervisor_backup_config.yml +# ============================================================================= +# Backs up hypervisor node configs to one or more destinations. +# Runs against all nodes in proxmox_cluster or xcpng_pool groups. +# +# Destinations configured via pve_config_backup_destinations in +# hypervisor_hosts.yml or group_vars. +# +# Usage: +# ansible-playbook playbooks/hypervisor_backup_config.yml \ +# -i inventories/client_local_eng/hypervisor_hosts.yml +# +# Override destination on the fly: +# ansible-playbook playbooks/hypervisor_backup_config.yml \ +# -i inventories/client_local_eng/hypervisor_hosts.yml \ +# -e '{"pve_config_backup_destinations":[{"type":"local"}]}' +# ============================================================================= + +- name: Backup hypervisor node configs + hosts: proxmox_cluster:xcpng_pool + gather_facts: true + serial: 1 + + roles: + - hypervisor_backup_config + diff --git a/roles/hypervisor_backup_config/defaults/main.yml b/roles/hypervisor_backup_config/defaults/main.yml new file mode 100644 index 0000000..00c5757 --- /dev/null +++ b/roles/hypervisor_backup_config/defaults/main.yml @@ -0,0 +1,51 @@ +--- +# ============================================================================= +# hypervisor_backup_config — defaults +# ============================================================================= + +# Backup destinations — list, supports git | local | sftp +# All configured destinations are used in sequence +pve_config_backup_destinations: + - type: git + +# Number of backups to retain for local and sftp destinations +# git retention is managed by git history +pve_config_backup_keep: 10 + +# Date string used in filenames +pve_config_backup_date: "{{ ansible_date_time.date }}" + +# Filename template (no extension — .tar.gz added for local/sftp) +# e.g. proxmox_client_local_eng_pm-node-01_config_2026-03-13 +pve_config_backup_filename: "{{ hypervisor_type }}_{{ client_id | lower | replace('-','_') | replace(' ','_') }}_{{ inventory_hostname }}_config_{{ pve_config_backup_date }}" + +# Files/dirs to back up per hypervisor type +pve_config_backup_paths_proxmox: + - /etc/pve + - /etc/network/interfaces + - /etc/hosts + - /etc/hostname + - /etc/apt/sources.list + - /etc/apt/sources.list.d + +pve_config_backup_paths_xcpng: + - /etc/xensource + - /etc/network/interfaces + - /etc/hosts + - /etc/hostname + +# Git settings +pve_config_git_repo_dir: /opt/ansible-msp-automations +pve_config_git_branch: main +pve_config_git_base_path: "hypervisor_configs/{{ client_id | lower | replace(' ','_') }}/{{ hypervisor_type }}/{{ inventory_hostname }}" +pve_config_git_commit_message: "[{{ client_id }}] {{ inventory_hostname }} pre-upgrade config backup {{ pve_config_backup_date }}" + +# Local settings +pve_config_local_backup_dir: /var/backups + +# SFTP settings +pve_config_sftp_host: "" +pve_config_sftp_user: "" +pve_config_sftp_key: "" +pve_config_sftp_remote_dir: "." + diff --git a/roles/hypervisor_backup_config/tasks/main.yml b/roles/hypervisor_backup_config/tasks/main.yml new file mode 100644 index 0000000..f833f7b --- /dev/null +++ b/roles/hypervisor_backup_config/tasks/main.yml @@ -0,0 +1,151 @@ +--- +# ============================================================================= +# hypervisor_backup_config — main tasks +# ============================================================================= + +- name: Gather date/time facts + ansible.builtin.setup: + gather_subset: + - date_time + when: ansible_date_time is not defined + +# ── Set backup paths based on hypervisor type ───────────────────────────────── +- name: Set backup paths for proxmox + ansible.builtin.set_fact: + pve_config_backup_paths: "{{ pve_config_backup_paths_proxmox }}" + when: hypervisor_type == 'proxmox' + +- name: Set backup paths for xcpng + ansible.builtin.set_fact: + pve_config_backup_paths: "{{ pve_config_backup_paths_xcpng }}" + when: hypervisor_type == 'xcpng' + +- name: Fail if hypervisor_type not supported + ansible.builtin.fail: + msg: "hypervisor_type '{{ hypervisor_type }}' is not supported. Use proxmox or xcpng." + when: hypervisor_type not in ['proxmox', 'xcpng'] + +# ── Git backup ──────────────────────────────────────────────────────────────── +- name: Git backup + when: "'git' in (pve_config_backup_destinations | map(attribute='type') | list)" + block: + - name: Git | Ensure git base path exists on Semaphore host + ansible.builtin.file: + path: "{{ pve_config_git_repo_dir }}/{{ pve_config_git_base_path }}" + state: directory + mode: '0755' + delegate_to: localhost + + - name: Git | Copy config files from node to repo + ansible.builtin.fetch: + src: "{{ item }}" + dest: "{{ pve_config_git_repo_dir }}/{{ pve_config_git_base_path }}/" + flat: false + fail_on_missing: false + loop: "{{ pve_config_backup_paths }}" + ignore_errors: true + + - name: Git | Check if there are changes to commit + ansible.builtin.shell: | + cd {{ pve_config_git_repo_dir }} + git status --porcelain {{ pve_config_git_base_path }}/ + register: git_status + delegate_to: localhost + changed_when: false + + - name: Git | Stage backup files + ansible.builtin.shell: | + cd {{ pve_config_git_repo_dir }} + git add {{ pve_config_git_base_path }}/ + delegate_to: localhost + when: git_status.stdout != "" + + - name: Git | Commit backup + ansible.builtin.shell: | + cd {{ pve_config_git_repo_dir }} + git -c user.name="ansible-msp" -c user.email="ansible@msp.local" \ + commit -m "{{ pve_config_git_commit_message }}" + delegate_to: localhost + when: git_status.stdout != "" + register: git_commit + + - name: Git | Push to remote + ansible.builtin.shell: | + cd {{ pve_config_git_repo_dir }} + git push origin {{ pve_config_git_branch }} + delegate_to: localhost + when: git_status.stdout != "" + + - name: Git | Log result + ansible.builtin.debug: + msg: "{{ 'Config backed up to git: ' + pve_config_git_base_path if git_status.stdout != '' else 'No config changes since last backup — git commit skipped' }}" + +# ── Local backup ────────────────────────────────────────────────────────────── +- name: Local backup + when: "'local' in (pve_config_backup_destinations | map(attribute='type') | list)" + block: + - name: Local | Ensure backup dir exists + ansible.builtin.file: + path: "{{ pve_config_local_backup_dir }}" + state: directory + mode: '0700' + + - name: Local | Create gzipped tarball of config paths + ansible.builtin.shell: | + tar czf {{ pve_config_local_backup_dir }}/{{ pve_config_backup_filename }}.tar.gz \ + --ignore-failed-read \ + {% for path in pve_config_backup_paths %}{{ path }} {% endfor %} + + echo "Created: {{ pve_config_backup_filename }}.tar.gz" + register: local_backup_result + changed_when: true + + - name: Local | Remove old backups beyond keep limit + ansible.builtin.shell: | + ls -1t {{ pve_config_local_backup_dir }}/{{ hypervisor_type }}_{{ client_id | lower | replace('-','_') | replace(' ','_') }}_{{ inventory_hostname }}_config_*.tar.gz 2>/dev/null \ + | tail -n +{{ (pve_config_backup_keep | int) + 1 }} \ + | xargs -r rm -f + echo "Rotation complete" + changed_when: false + + - name: Local | Log result + ansible.builtin.debug: + msg: "Config backed up locally: {{ pve_config_local_backup_dir }}/{{ pve_config_backup_filename }}.tar.gz" + +# ── SFTP backup ─────────────────────────────────────────────────────────────── +- name: SFTP backup + when: "'sftp' in (pve_config_backup_destinations | map(attribute='type') | list)" + block: + - name: SFTP | Validate required vars are set + ansible.builtin.fail: + msg: "sftp destination requires pve_config_sftp_host, pve_config_sftp_user to be set" + when: pve_config_sftp_host == "" or pve_config_sftp_user == "" + + - name: SFTP | Create local temp tarball first + ansible.builtin.shell: | + tar czf /tmp/{{ pve_config_backup_filename }}.tar.gz \ + --ignore-failed-read \ + {% for path in pve_config_backup_paths %}{{ path }} {% endfor %} + changed_when: true + + - name: SFTP | Transfer tarball to remote host + ansible.builtin.shell: | + sftp_opts="-o StrictHostKeyChecking=no -o BatchMode=yes" + {% if pve_config_sftp_key != "" %} + sftp_opts="$sftp_opts -i {{ pve_config_sftp_key }}" + {% endif %} + sftp $sftp_opts {{ pve_config_sftp_user }}@{{ pve_config_sftp_host }} << EOF + cd {{ pve_config_sftp_remote_dir }} + put /tmp/{{ pve_config_backup_filename }}.tar.gz + EOF + changed_when: true + + - name: SFTP | Remove temp tarball + ansible.builtin.file: + path: "/tmp/{{ pve_config_backup_filename }}.tar.gz" + state: absent + + - name: SFTP | Log result + ansible.builtin.debug: + msg: "Config backed up via sftp: {{ pve_config_sftp_host }}:{{ pve_config_sftp_remote_dir }}/{{ pve_config_backup_filename }}.tar.gz" +