--- # Configures backups on a Proxmox node: a storage on the NAS to write archives # to, and a scheduled vzdump job that fills it. # # This is the off-box copy for every Proxmox guest. Guests deliberately keep # all their state on their own rootfs rather than bind-mounting anything in, # because vzdump excludes bind mounts — see src/forgejo/terraform/README.md. # Where an app's data lives outside the container (the shared Postgres), the # app dumps it into its own filesystem on a timer that runs before the window # below, so one archive is one restore point. # # Driven through `pvesm`/`pvesh` rather than modules: as of writing, # `community.proxmox` has no storage or backup-job module. Swap them in if that # changes — the shape here is create-if-absent, update-if-drifted, which is # what a module would do anyway. # # UNVERIFIED — this has not been run against a real node. `pvesm add` and # `pvesh create /cluster/backup` accept slightly different options across PVE # versions; check yours with `pvesm help add`, `pvesh help create # /cluster/backup`, and a `--check` run before trusting it. - name: Check that the storage backend is configured ansible.builtin.assert: that: - pve_backup_storage_type in ['nfs', 'pbs'] - pve_backup_storage_type != 'nfs' or (pve_backup_nfs_server | length > 0 and pve_backup_nfs_export | length > 0) - pve_backup_storage_type != 'pbs' or (pve_backup_pbs_server | length > 0 and pve_backup_pbs_datastore | length > 0) - pve_backup_all or (pve_backup_vmids | length > 0) fail_msg: >- pve_backup is missing required settings for pve_backup_storage_type={{ pve_backup_storage_type }}. Set them in inventory/host_vars/{{ inventory_hostname }}.yml. quiet: true - name: Build the prune-backups option string ansible.builtin.set_fact: pve_backup_prune_string: >- {% for key, value in pve_backup_prune.items() %}{{ key }}={{ value }}{% if not loop.last %},{% endif %}{% endfor %} # --- Storage ------------------------------------------------------------- - name: Read the configured storages ansible.builtin.command: argv: [pvesh, get, /storage, --output-format, json] register: pve_storages changed_when: false check_mode: false - name: Add the backup storage (NFS) ansible.builtin.command: argv: - pvesm - add - nfs - "{{ pve_backup_storage }}" - --server - "{{ pve_backup_nfs_server }}" - --export - "{{ pve_backup_nfs_export }}" - --options - "{{ pve_backup_nfs_options }}" - --content - backup - --prune-backups - "{{ pve_backup_prune_string }}" when: - pve_backup_storage_type == 'nfs' - pve_backup_storage not in (pve_storages.stdout | from_json | map(attribute='storage') | list) - name: Add the backup storage (PBS) # The password goes on a command line, which is visible in the node's process # list for the moment the command runs. `pvesm` has no stdin form, so the # alternative is configuring PBS by hand — acceptable for a one-time create # that only fires when the storage is absent. ansible.builtin.command: argv: - pvesm - add - pbs - "{{ pve_backup_storage }}" - --server - "{{ pve_backup_pbs_server }}" - --datastore - "{{ pve_backup_pbs_datastore }}" - --username - "{{ pve_backup_pbs_username }}" - --password - "{{ pve_backup_pbs_password }}" - --fingerprint - "{{ pve_backup_pbs_fingerprint }}" - --content - backup - --prune-backups - "{{ pve_backup_prune_string }}" when: - pve_backup_storage_type == 'pbs' - pve_backup_storage not in (pve_storages.stdout | from_json | map(attribute='storage') | list) no_log: true # --- Backup job ---------------------------------------------------------- - name: Read the configured backup jobs ansible.builtin.command: argv: [pvesh, get, /cluster/backup, --output-format, json] register: pve_backup_jobs changed_when: false check_mode: false # Proxmox assigns job IDs itself, so there is nothing stable to key on except # a comment we set. Without this the role would add a duplicate job on every # run. - name: Find a job this role already created ansible.builtin.set_fact: pve_backup_existing: >- {{ (pve_backup_jobs.stdout | from_json) | selectattr('comment', 'defined') | selectattr('comment', 'equalto', pve_backup_comment) | list | first | default({}) }} - name: Build the vzdump job arguments ansible.builtin.set_fact: pve_backup_job_args: >- {{ ['--schedule', pve_backup_schedule, '--storage', pve_backup_storage, '--mode', pve_backup_mode, '--compress', pve_backup_compress, '--comment', pve_backup_comment, '--enabled', ('1' if pve_backup_enabled else '0')] + (['--all', '1'] if pve_backup_all else ['--all', '0', '--vmid', pve_backup_vmids | join(',')]) + (['--mailto', pve_backup_mailto] if pve_backup_mailto | length > 0 else []) }} - name: Create the vzdump job ansible.builtin.command: argv: "{{ ['pvesh', 'create', '/cluster/backup'] + pve_backup_job_args }}" when: pve_backup_existing | length == 0 # Compared field by field rather than blindly re-applying, so the role reports # a change only when there is one. The VMID list is compared as the # comma-joined string Proxmox stores, which makes it order-sensitive — reorder # pve_backup_vmids and you get one no-op update. - name: Work out whether the existing job still matches ansible.builtin.set_fact: pve_backup_drifted: >- {{ pve_backup_existing.schedule | default('') != pve_backup_schedule or pve_backup_existing.storage | default('') != pve_backup_storage or pve_backup_existing.mode | default('') != pve_backup_mode or (pve_backup_existing.enabled | default(1) | int == 1) != pve_backup_enabled or (pve_backup_existing.all | default(0) | int == 1) != pve_backup_all or (not pve_backup_all and pve_backup_existing.vmid | default('') != pve_backup_vmids | join(',')) }} when: pve_backup_existing | length > 0 - name: Update the vzdump job if its settings drifted ansible.builtin.command: argv: "{{ ['pvesh', 'set', '/cluster/backup/' ~ pve_backup_existing.id] + pve_backup_job_args }}" when: - pve_backup_existing | length > 0 - pve_backup_drifted | bool