commit 420be44f56b466b2ce4534f026ef4eae0161a5d0 Author: nd Date: Fri Mar 12 03:35:14 2021 +0100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bee8a64 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__ diff --git a/README.md b/README.md new file mode 100644 index 0000000..aab4830 --- /dev/null +++ b/README.md @@ -0,0 +1,44 @@ +# Backup client + +## Parameters and defaults + +All configuration is to be placed inside the `backups` dict. + +``` +# backend specific settings +backends: + # restic specific settings + restic: + # url of the restic repository + url: '/var/backup-client/restic' + # repository type musst be 'local' + repo_type: 'local' + +# Mode in which the backup is taken. One of the following: +# +# vm-via-hypervisor: backup a vm via restic on the hypervisor. Saves config on the host +# hypervisor-restic: backup its vms via restic +# standalone-restic: use restic on the target itself to save a backup to a backup location (TODO) +mode: vm-via-hypervisor + +# Allows backups to be skipped +enabled: True + +# How many copies per time intervall should be kept? +# Note that this is ignored in vm-via-hypervisor mode because the vm host settings are used for all vms +retention: + hours: 12 + days: 14 + weeks: 16 + months: 12 + years: 3 + +# keys are strings with glob patterns of files to be excluded. Value musst be true to enable the exclude, false to disable it +# Only supportet in restic based backups +exclude_files: {} + +# Keys are strings with glob patterns of files to be included. Value musst be true to enable the include, false to disable it +# Only supportet in restic based backups +# Ignored in vm-via-hypervisor mode +include_files: {} +``` diff --git a/defaults/main.yml b/defaults/main.yml new file mode 100644 index 0000000..2fdc183 --- /dev/null +++ b/defaults/main.yml @@ -0,0 +1,20 @@ +backups: + backends: + restic: + url: /var/backup-client/restic + repo_type: local + mode: vm-via-hypervisor + enabled: True + retention: + hours: 12 + days: 14 + weeks: 16 + months: 12 + years: 3 + exclude_files: + '/tmp': true + '/var/tmp': true + '/var/cache': true + '/root/.ansible/': true + include_files: + '/': true diff --git a/filter_plugins/filters.py b/filter_plugins/filters.py new file mode 100755 index 0000000..7adcde1 --- /dev/null +++ b/filter_plugins/filters.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +import os + +def filterEnabled(inputdict): + output = [] + for i in inputdict.keys(): + if inputdict.get(i): + output.append(i) + return output + +def vmpath2hostpath(inputfiles, mountpoint): + output = [] + for line in inputfiles: + line = os.path.normpath(line) + if line.startswith('/'): + disk = '*' + output.append(os.path.join(mountpoint, disk, line[1:])) + else: + output.append(line) + return output + +class FilterModule(object): + def filters(self): + return { + 'vmpath2hostpath': vmpath2hostpath, + 'filterEnabled': filterEnabled, + } diff --git a/tasks/main.yml b/tasks/main.yml new file mode 100644 index 0000000..45cd959 --- /dev/null +++ b/tasks/main.yml @@ -0,0 +1,170 @@ +- name: parse config + set_fact: + backup_backend: "{% if backups.mode in ['standalone-restic', 'hypervisor-restic'] %}restic{% else %}False{% endif %}" + backup_executor: "{% if backups.mode in ['vm-via-hypervisor'] %}False{% else %}True{% endif %}" + +- debug: + var: backup_backend + +- name: create config folder + file: + path: /etc/backup-client/ + state: directory + owner: root + group: root + mode: 0700 + +- name: setup hosts that actualy run backup code (not vms for example) + when: backup_executor + block: + - name: create retention file + copy: + dest: /etc/backup-client/retention.env + owner: root + group: root + mode: 0700 + content: | + export BACKUP_RETENTION_HOURS={{ backups.retention.hours }} + export BACKUP_RETENTION_DAYS={{ backups.retention.days }} + export BACKUP_RETENTION_WEEKS={{ backups.retention.weeks }} + export BACKUP_RETENTION_MONTHS={{ backups.retention.months }} + export BACKUP_RETENTION_YEARS={{ backups.retention.years }} + - name: copy backup config + loop: + - name: 'enabled' + flag: '{{ backups.enabled }}' + file: + path: /etc/backup-client/{{ item.name }} + owner: root + group: root + mode: 0600 + state: "{% if item.flag %}touch{% else %}absent{% endif %}" + - name: copy scripts + loop: + - backup-retention + - backup-standalone + - backup-vm + - backup-all-vms + - backup-full + - backup-cronjob + template: + src: "{{ item }}.j2" + dest: "/usr/local/bin/{{ item }}" + owner: root + group: root + mode: 0700 + validate: /bin/bash -n %s + - name: create data folder + file: + path: /var/backup-client/ + state: directory + owner: root + group: root + mode: 0700 + +- name: handle common restic based setup tasks + when: backup_backend == 'restic' + block: + - name: install backend tools (restic) + apt: + pkg: + - restic + - name: copy exclude file + copy: + dest: /etc/backup-client/exclude_files + owner: root + group: root + mode: 0600 + content: "{{ backups.exclude_files|filterEnabled|join('\n') }}" + - name: copy include file + copy: + dest: /etc/backup-client/include_files + owner: root + group: root + mode: 0600 + content: "{{ backups.include_files|filterEnabled|join('\n') }}" + - name: create repo key for restic + command: "dd if=/dev/urandom of=/etc/backup-client/restic.key bs=1k count=16" + args: + creates: "/etc/backup-client/restic.key" + - name: create restic env file + copy: + dest: /etc/backup-client/restic.env + owner: root + group: root + mode: 0700 + content: | + export RESTIC_REPOSITORY="{{ backups.backends.restic.url }}" + export RESTIC_PASSWORD_FILE="/etc/backup-client/restic.key" + - name: create restic repository folder + when: backups.backends.restic.repo_type == 'local' + file: + path: "{{ backups.backends.restic.url }}" + state: directory + owner: root + group: root + mode: 0700 + - name: create repo for restic + when: backups.backends.restic.repo_type == 'local' + shell: 'source /etc/backup-client/restic.env; restic init' + args: + executable: /bin/bash + creates: "{{ backups.backends.restic.url }}/config" + +- name: handle hypervisor mode + when: backups.mode == 'hypervisor-restic' + block: + - name: create vms config folder + file: + path: /etc/backup-client/vms/ + state: directory + owner: root + group: root + mode: 0700 + - name: create vm mount point + file: + path: /var/backup-client/vm-mountpoint/ + state: directory + owner: root + group: root + mode: 0700 + +- name: handle vm-via-hypervisor mode + when: backups.mode == 'vm-via-hypervisor' + block: + - name: create config folder on vm host + delegate_to: "{{ vm['host'] }}" + file: + dest: /etc/backup-client/vms/{{ vm['name'] }} + state: directory + owner: root + group: root + mode: 0700 + - name: copy exclude file to vm host + delegate_to: "{{ vm['host'] }}" + copy: + dest: /etc/backup-client/vms/{{ vm['name'] }}/exclude_files + owner: root + group: root + mode: 0600 + content: "{{ backups.exclude_files|filterEnabled|vmpath2hostpath(mountpoint='/var/backup-client/vm-mountpoint')|join('\n') }}" + - name: copy include file to vm host + delegate_to: "{{ vm['host'] }}" + copy: + dest: /etc/backup-client/vms/{{ vm['name'] }}/include_files + owner: root + group: root + mode: 0600 + content: "{{ backups.include_files|filterEnabled|vmpath2hostpath(mountpoint='/var/backup-client/vm-mountpoint')|join('\n') }}" + - name: copy vm backup config to vm host + delegate_to: "{{ vm['host'] }}" + loop: + - name: 'enabled' + flag: '{{ backups.enabled }}' + file: + path: /etc/backup-client/vms/{{ vm['name'] }}/{{ item.name }} + owner: root + group: root + mode: 0600 + state: "{% if item.flag %}touch{% else %}absent{% endif %}" + diff --git a/templates/backup-all-vms.j2 b/templates/backup-all-vms.j2 new file mode 100755 index 0000000..48b7768 --- /dev/null +++ b/templates/backup-all-vms.j2 @@ -0,0 +1,6 @@ +#!/bin/bash +set -euo pipefail + +for d in `virsh list --all --name`; do + backup-vm "$d" +done diff --git a/templates/backup-cronjob.j2 b/templates/backup-cronjob.j2 new file mode 100755 index 0000000..a95737e --- /dev/null +++ b/templates/backup-cronjob.j2 @@ -0,0 +1,15 @@ +#!/bin/bash +set -euo pipefail + +( + set -euo pipefail + flock -x -w 10 200 || exit 1 + + export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + + echo started backup cronjob + ionice -c 3 -p$$ + nice -n 19 backup-full + echo finished backup cronjob + +) 200>/var/lock/backup-cronjob.lock | logger -t "backup-cronjob" diff --git a/templates/backup-full.j2 b/templates/backup-full.j2 new file mode 100755 index 0000000..ac525d0 --- /dev/null +++ b/templates/backup-full.j2 @@ -0,0 +1,13 @@ +#!/bin/bash +set -euo pipefail + +{% if backup_executor %} + backup-standalone +{% endif %} +{% if backups.mode in ['hypervisor-restic'] %} + backup-all-vms +{% endif %} +{% if backup_executor %} + backup-retention +{% endif %} + diff --git a/templates/backup-retention.j2 b/templates/backup-retention.j2 new file mode 100755 index 0000000..1a76bc4 --- /dev/null +++ b/templates/backup-retention.j2 @@ -0,0 +1,19 @@ +#!/bin/bash +set -euo pipefail + +source /etc/backup-client/retention.env + +{% if backup_backend == 'restic' %} +# restic backend +source /etc/backup-client/restic.env +restic forget \ + --verbose \ + --prune \ + --group-by "host,paths,tags" \ + --keep-hourly ${BACKUP_RETENTION_HOURS} \ + --keep-daily ${BACKUP_RETENTION_DAYS} \ + --keep-weekly ${BACKUP_RETENTION_WEEKS} \ + --keep-monthly ${BACKUP_RETENTION_MONTHS} \ + --keep-yearly ${BACKUP_RETENTION_YEARS} + +{% endif %} diff --git a/templates/backup-standalone.j2 b/templates/backup-standalone.j2 new file mode 100755 index 0000000..fd1360e --- /dev/null +++ b/templates/backup-standalone.j2 @@ -0,0 +1,22 @@ +#!/bin/bash +set -euo pipefail + +test -f "/etc/backup-client/enabled" || { echo "Standalone backup is disabled"; exit 0; } + +{% if backup_backend == 'restic' %} +# restic backend +source /etc/backup-client/restic.env + +restic backup \ + --verbose \ + --exclude-caches \ + --one-file-system \ + --exclude "${RESTIC_REPOSITORY}" \ + --exclude-file "/etc/backup-client/exclude_files" \ + --files-from "/etc/backup-client/include_files" + + +{% endif %} +{% if not backup_backend %} +echo "Noop, backup is handled external" +{% endif %} diff --git a/templates/backup-vm.j2 b/templates/backup-vm.j2 new file mode 100755 index 0000000..e16a6b1 --- /dev/null +++ b/templates/backup-vm.j2 @@ -0,0 +1,95 @@ +#!/bin/bash +set -euo pipefail + +( + set -euo pipefail + flock -x -w 10 200 || exit 1 + + export LVM_SUPPRESS_FD_WARNINGS=1 + DOMAIN=${1:?VM name musst be passed!} + DOMAIN_MOUNTBASE="/var/backup-client/vm-mountpoint" + + function unfreeze_vm { + virsh resume "$DOMAIN" > /dev/null || true + } + + function delete_snapshots { + cleanup_vmmount > /dev/null 2>&1 + for i in $DISKS; do + extract_lvm + SNAPSHOT="/dev/$VG/backup-$DEVICE" + lvremove -f "$SNAPSHOT" > /dev/null 2>&1 || true + done + } + + function cleanup_vmmount { + umount -l $DOMAIN_MOUNTBASE/* || true + rmdir ${DOMAIN_MOUNTBASE:?}/* || true + rm "$DOMAIN_MOUNTBASE/config.xml" || true + } + + function extract_lvm { + DISK=`echo $i | awk -F, '{print $1}'` + DEVICE=`echo $i | awk -F, '{print $2}'` + VG=`lvs --noheadings -o vg_name "$DISK" | tr -d ' '` + LV=`lvs --noheadings -o lv_name "$DISK" | tr -d ' '` + } + + function backup_vm { + # get a list of disks + DISKS=`virsh domblklist "$DOMAIN" --details \ + | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' \ + | grep ^block \ + | grep -v swap \ + | awk '{printf "%s,%s ",$4, $3}'` + echo "backing up $DOMAIN: $DISKS" + + trap "unfreeze_vm || true; delete_snapshots" INT TERM EXIT + + # in case an earlier script crashed we have to clear snapshots + delete_snapshots + + # freez vm + virsh suspend "$DOMAIN" > /dev/null || true + + # create disk snapshots + for i in $DISKS; do + extract_lvm + lvcreate -L16G -s -n "backup-$DEVICE" "$DISK" > /dev/null + done + + # dump vm config + virsh dumpxml "$DOMAIN" > "$DOMAIN_MOUNTBASE/config.xml" + + unfreeze_vm + trap "delete_snapshots" INT TERM EXIT + + # mount disks snapshots + for i in $DISKS; do + extract_lvm + SNAPSHOT="/dev/$VG/backup-$DEVICE" + fsck -y "$SNAPSHOT" > /dev/null 2> /dev/null + + ( + mkdir "$DOMAIN_MOUNTBASE/$DEVICE" + mount -o ro "$SNAPSHOT" "$DOMAIN_MOUNTBASE/$DEVICE" + ) 2> /dev/null + done + +{% if backup_backend == 'restic' %} + # restic backend + source /etc/backup-client/restic.env + restic backup \ + --verbose \ + --host "$DOMAIN" \ + --exclude-file "/etc/backup-client/vms/$DOMAIN/exclude_files" \ + "$DOMAIN_MOUNTBASE" +{% endif %} + + # delete snapshot + delete_snapshots; + trap - INT TERM EXIT + } + + test -f "/etc/backup-client/vms/$DOMAIN/enabled" && backup_vm || echo "Backup for $DOMAIN is disabled" +) 200>/var/lock/backup-vm.lock