From 19162e2af3cba48f56e69e003e7526db7ba2e27f Mon Sep 17 00:00:00 2001
From: Marty Oehme <contact@martyoeh.me>
Date: Fri, 28 Feb 2025 21:40:02 +0100
Subject: [PATCH] feat(backup): Add restic backup

Restic backup creates a snapper snapshot of the root system which it
then chroots into and starts a restic backup to a (wasabi) S3 bucket to.

Intended to roughly follow this
<https://codeberg.org/silmaril/my-restic-solution> solution to achieve
restic backup of the _newest_ snapshot of my live root system.
---
 roles/backup/defaults/main.yaml       | 10 +++++
 roles/backup/files/snapstic           | 53 +++++++++++++++++++++++++++
 roles/backup/tasks/main.yaml          |  8 +++-
 roles/backup/tasks/restic.yaml        | 52 ++++++++++++++++++++++++++
 roles/backup/templates/restic.conf.j2 |  9 +++++
 roles/backup/vars/main.yaml           |  0
 6 files changed, 131 insertions(+), 1 deletion(-)
 create mode 100755 roles/backup/files/snapstic
 create mode 100644 roles/backup/tasks/restic.yaml
 create mode 100644 roles/backup/templates/restic.conf.j2
 delete mode 100644 roles/backup/vars/main.yaml

diff --git a/roles/backup/defaults/main.yaml b/roles/backup/defaults/main.yaml
index 85242b0..c7c6052 100644
--- a/roles/backup/defaults/main.yaml
+++ b/roles/backup/defaults/main.yaml
@@ -1,2 +1,12 @@
 ---
 # user_name: # required for snapper templates
+
+restic_repository:
+restic_password:
+restic_s3_id:
+restic_s3_key:
+
+restic_daily: 3
+restic_weekly: 2
+restic_monthly: 6
+restic_yearly: 1
diff --git a/roles/backup/files/snapstic b/roles/backup/files/snapstic
new file mode 100755
index 0000000..a09a148
--- /dev/null
+++ b/roles/backup/files/snapstic
@@ -0,0 +1,53 @@
+#!/usr/bin/env bash
+set -euo pipefail
+shopt -s expand_aliases
+
+# Restic snapshot backup script
+msg() { # $1=message string $2=unit
+    printf "[%s] %s\n" "${2:-snapstic}" "$1"
+}
+
+config="${SNAPSTIC_RESTIC_CONFIG:-root}"
+restic_conf="/etc/restic/${config}/restic.conf"
+snapper_snapshot_dir="${SNAPSTIC_SNAPSHOT_DIR:-/.snapshots}"
+# snapper_config="${SNAPSTIC_SNAPPER_CONFIG}" # TODO: call snapper with associated conf if exists?
+
+source "$restic_conf"
+
+trap cleanup 1 2 3 6
+cleanup() {
+    msg "unmount restic configuration"
+    umount "${snap_dir}/.snapshots"
+    umount "${snap_dir}/tmp"
+    exit
+}
+
+msg "create snapper snapshot"
+snap_num=$(snapper create --cleanup-algorithm number --description 'restic' --print-number)
+snap_dir="${snapper_snapshot_dir}/${snap_num}/snapshot"
+
+if [[ "$#" -eq 0 ]]; then
+    msg "no backup options selected"
+fi
+
+msg "mount restic configuration to ephemeral dir"
+mount --bind "/etc/restic/${config}" "${snap_dir}/.snapshots"
+msg "mount restic cache dir"
+mount --bind "/tmp" "${snap_dir}/tmp"
+
+if [[ $* == *"backup"* ]]; then
+    msg "backup in chrooted $snap_dir"
+    chroot "$snap_dir" /bin/bash -c '. /.snapshots/restic.conf && AWS_ACCESS_KEY_ID="$AWS_ACCESS_KEY_ID" AWS_SECRET_ACCESS_KEY="$AWS_SECRET_ACCESS_KEY" RESTIC_REPOSITORY=$RESTIC_REPOSITORY RESTIC_PASSWORD="$RESTIC_PASSWORD" /usr/bin/restic backup --tag "snapstic" --files-from="/.snapshots/include" --exclude-file="/.snapshots/exclude" --exclude "/.snapshots" --exclude-caches --no-cache'
+fi
+
+if [[ $* == *"forget"* ]]; then
+    msg "forget snapshots in $snap_dir"
+    chroot "$snap_dir" /bin/bash -c '. /.snapshots/restic.conf && DAILY=$DAILY WEEKLY=$WEEKLY MONTHLY=$MONTHLY YEARLY=$YEARLY AWS_ACCESS_KEY_ID="$AWS_ACCESS_KEY_ID" AWS_SECRET_ACCESS_KEY="$AWS_SECRET_ACCESS_KEY" RESTIC_REPOSITORY=$RESTIC_REPOSITORY RESTIC_PASSWORD="$RESTIC_PASSWORD" /usr/bin/restic forget --tag "snapstic" --group-by "paths,tags" --keep-daily="${DAILY:-0}" --keep-weekly="${WEEKLY:-0}" --keep-monthly="${MONTHLY:-0}" --keep-yearly="${YEARLY:-0}" --no-cache'
+fi
+
+if [[ $* == *"stats"* ]]; then
+    msg "display repo stats"
+    chroot "$snap_dir" /bin/bash -c '. /.snapshots/restic.conf && AWS_ACCESS_KEY_ID="$AWS_ACCESS_KEY_ID" AWS_SECRET_ACCESS_KEY="$AWS_SECRET_ACCESS_KEY" RESTIC_REPOSITORY=$RESTIC_REPOSITORY RESTIC_PASSWORD="$RESTIC_PASSWORD" /usr/bin/restic stats --no-cache'
+fi
+
+msg "done"
diff --git a/roles/backup/tasks/main.yaml b/roles/backup/tasks/main.yaml
index a7039cf..af75d10 100644
--- a/roles/backup/tasks/main.yaml
+++ b/roles/backup/tasks/main.yaml
@@ -2,4 +2,10 @@
   import_tasks: snapper.yaml
   tags:
     - btrfs
-    - snapshots
+    - snapper
+
+- name: Set up restic backups
+  import_tasks: restic.yaml
+  tags:
+    - btrfs
+    - restic
diff --git a/roles/backup/tasks/restic.yaml b/roles/backup/tasks/restic.yaml
new file mode 100644
index 0000000..20cbad4
--- /dev/null
+++ b/roles/backup/tasks/restic.yaml
@@ -0,0 +1,52 @@
+- name: Install restic
+  community.general.xbps:
+    name:
+      - restic
+    state: present
+  tags: packages
+
+- name: Ensure restic configuration directory exists
+  ansible.builtin.file:
+    dest: "/etc/restic/root"
+    state: directory
+
+- name: Create restic root backup configuration
+  ansible.builtin.template:
+    src: restic.conf.j2
+    dest: "/etc/restic/root/restic.conf"
+    mode: 0600
+    force: true # ensure contents are always exact
+
+- name: Ensure files and exclude files exist # Only change if necessary
+  ansible.builtin.file:
+    dest: "/etc/restic/root/{{ item }}"
+    state: touch
+    modification_time: preserve
+    access_time: preserve
+  loop:
+    - include
+    - exclude
+
+- name: Create include files # TODO: Rename file to include?
+  ansible.builtin.copy:
+    content: "/"
+    dest: "/etc/restic/root/include"
+    force: true # ensure contents are always exact
+
+- name: Install snapstic script
+  ansible.builtin.copy:
+    src: snapstic
+    dest: "/usr/bin/snapstic"
+    mode: 0744
+
+- name: Ensure restic configuration directory exists
+  ansible.builtin.file:
+    dest: "/etc/cron.weekly"
+    state: directory
+
+- name: Add snapstic to weekly cronjobs
+  ansible.builtin.copy:
+    content: "#!/bin/sh\n\nsnapstic backup"
+    dest: "/etc/cron.weekly/snapstic"
+    mode: 0755
+    force: true # ensure contents are always exact
diff --git a/roles/backup/templates/restic.conf.j2 b/roles/backup/templates/restic.conf.j2
new file mode 100644
index 0000000..b337c77
--- /dev/null
+++ b/roles/backup/templates/restic.conf.j2
@@ -0,0 +1,9 @@
+RESTIC_REPOSITORY='{{ restic_repository }}'
+AWS_ACCESS_KEY_ID='{{ restic_s3_id }}'
+AWS_SECRET_ACCESS_KEY='{{ restic_s3_key }}'
+RESTIC_PASSWORD='{{ restic_password }}'
+RESTIC_CACHE_DIR=/var/tmp
+DAILY={{ restic_daily }}
+WEEKLY={{ restic_weekly }}
+MONTHLY={{ restic_monthly }}
+YEARLY={{ restic_yearly }}
diff --git a/roles/backup/vars/main.yaml b/roles/backup/vars/main.yaml
deleted file mode 100644
index e69de29..0000000