diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..42e4c9c --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,20 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +jobs: + python: + name: Lint Python + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Install black + run: pip install ruff + - name: Check style + run: ruff format --quiet --line-length 120 --diff */*.py + - name: Lint + run: ruff check */*.py diff --git a/pyinfra_borgbackup/__init__.py b/pyinfra_borgbackup/__init__.py index fa79ace..150aa40 100644 --- a/pyinfra_borgbackup/__init__.py +++ b/pyinfra_borgbackup/__init__.py @@ -2,7 +2,7 @@ import random from io import StringIO -from pyinfra.operations import apt, files, server +from pyinfra.operations import apt, files, server, systemd def deploy_borgbackup( @@ -28,7 +28,7 @@ def deploy_borgbackup( secrets = [ f"BORG_PASSPHRASE={passphrase}", - f"DEST1={borg_repo}", + f"BORG_REPO={borg_repo}", "SKIP_CHECK=true" if skip_check else "SKIP_CHECK=false", ] env = "\n".join(secrets) @@ -62,9 +62,7 @@ def deploy_borgbackup( if borg_repo.startswith("hetzner-backup:"): files.put( name="create SSH config", - src=importlib.resources.files(__package__) - .joinpath("dot_ssh", "config") - .open("rb"), + src=importlib.resources.files(__package__).joinpath("dot_ssh", "config").open("rb"), dest="/root/.ssh/config", user="root", group="root", @@ -96,13 +94,36 @@ def deploy_borgbackup( ], ) - files.template( - name="Create cron job for backup script", - src=importlib.resources.files(__package__).joinpath("borgbackup.cron"), - dest="/etc/cron.d/borgbackup", - user="root", - group="root", + files.file( + name="Remove old backup cronjob, replaced by systemd timer", + path="/etc/cron.d/borgbackup", + present=False, + ) + + backup_service_file = files.put( + src=importlib.resources.files(__package__).joinpath("borgbackup.service"), + dest="/etc/systemd/system/borgbackup.service", + mode="644", + ) + systemd.service( + name="Setup borgbackup service", + service="borgbackup.service", + running=False, + enabled=False, + daemon_reload=backup_service_file.changed, + ) + + backup_timer_file = files.template( + src=importlib.resources.files(__package__).joinpath("borgbackup.timer.j2"), + dest="/etc/systemd/system/borgbackup.timer", mode="644", minute=f"{random.randint(0, 59):02d}", hour=f"{random.randint(0, 4):02d}", ) + systemd.service( + name="Setup borgbackup timer", + service="borgbackup.timer", + running=True, + enabled=True, + daemon_reload=backup_timer_file.changed, + ) diff --git a/pyinfra_borgbackup/backup-pre.py b/pyinfra_borgbackup/backup-pre.py index 104f115..f5ef5d0 100644 --- a/pyinfra_borgbackup/backup-pre.py +++ b/pyinfra_borgbackup/backup-pre.py @@ -3,6 +3,12 @@ import argparse import os + +def cmd(command: str) -> int: + """Run a command and return its exit code.""" + return os.waitstatus_to_exitcode(os.system(command)) + + services = { "isolationbot": "isolationbot.service", "root": "docker.service", @@ -19,10 +25,8 @@ for user in services: if user == "root": - returncode = os.system(f"systemctl {args.command} {services[user]}") + returncode = cmd(f"systemctl {args.command} {services[user]}") else: - returncode = os.system( - f"su -l {user} -c 'systemctl --user {args.command} {services[user]}'" - ) + returncode = cmd(f"su -l {user} -c 'systemctl --user {args.command} {services[user]}'") if returncode != 0: print(f"WARNING: Failed to {args.command} {services[user]} as {user} user") diff --git a/pyinfra_borgbackup/backup.sh.j2 b/pyinfra_borgbackup/backup.sh.j2 index 88b9806..d2298b7 100644 --- a/pyinfra_borgbackup/backup.sh.j2 +++ b/pyinfra_borgbackup/backup.sh.j2 @@ -2,12 +2,14 @@ function start_services { # start the services/containers which write data again + rv=$? if [ -f /root/backup-pre.py ]; then python3 /root/backup-pre.py start; fi + exit $(( $rv + $? )) } trap "start_services" EXIT -set -exuo pipefail +set -euo pipefail # setup env DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" @@ -16,70 +18,52 @@ export $(xargs < /root/backup.env) # check if these variables have been set echo "$BORG_PASSPHRASE" > /dev/null -echo "$DEST1" > /dev/null +echo "$BORG_REPO" > /dev/null : "${SKIP_CHECK:='false'}" +set -x + export BORG_RSH='ssh -F /root/.ssh/config -o "StrictHostKeyChecking=no"' -# don't fail just because the pod is not running -set +e +function run_borg () { + set +e # don't fail if borg quits with exit code 1 (warning) + borg $@ + borg_rc=$? + if [[ $borg_rc -gt 1 ]]; then + exit $borg_rc + fi + set -e +} # check if variable exists: https://stackoverflow.com/a/11369388 if [ "$SKIP_CHECK" = "true" ]; then echo "skipping borg check." else - borg check ${DEST1} & - if [ -n "${DEST2-}" ]; then - borg check ${DEST2} & - fi + run_borg check fi -wait # stop services/containers which write data if [ -f /root/backup-pre.py ]; then python3 /root/backup-pre.py stop; fi -borg create --stats --compression lzma ${DEST1}::'backup{now:%Y-%m-%d-%H}' {{ borg_args }} \ +run_borg create --stats --compression lzma ::'backup{now:%Y-%m-%d-%H}' {{ borg_args }} \ --exclude *.cache/ \ --exclude /dev \ --exclude /proc \ --exclude /sys \ --exclude /var/run \ + --exclude /var/log \ --exclude /run \ --exclude /lost+found \ --exclude /mnt \ --exclude /tmp \ --exclude /media \ - --exclude /var/lib/lxcfs && FINISHED=true & -if [ -n "${DEST2-}" ]; then - # If there is a second backup destination, create a backup there. - # Note: not yet supported by pyinfra deploy. - borg create --stats --compression lzma ${DEST2}::'backup{now:%Y-%m-%d-%H}' {{ borg_args }} \ - --exclude *.cache/ \ - --exclude /dev \ - --exclude /proc \ - --exclude /sys \ - --exclude /var/run \ - --exclude /run \ - --exclude /lost+found \ - --exclude /mnt \ - --exclude /tmp \ - --exclude /media \ - --exclude /var/lib/lxcfs && FINISHED=true & -fi -wait + --exclude /var/lib/lxcfs {% if prometheus_file is defined %} -if [ "$FINISHED" = "true" ]; then - echo "borgbackup_last_completed $(date +%s)" > {{ prometheus_file }} -fi +echo "borgbackup_last_completed $(date +%s)" > {{ prometheus_file }} {%- endif %} start_services -set -e -borg prune --keep-daily=7 --keep-weekly=4 ${DEST1} -borg compact ${DEST1} -if [ -n "${DEST2-}" ]; then - borg prune --keep-daily=7 --keep-weekly=4 ${DEST2} - borg compact ${DEST2} -fi +run_borg prune --keep-daily=7 --keep-weekly=4 +run_borg compact diff --git a/pyinfra_borgbackup/borgbackup.cron b/pyinfra_borgbackup/borgbackup.cron deleted file mode 100644 index e10719a..0000000 --- a/pyinfra_borgbackup/borgbackup.cron +++ /dev/null @@ -1,4 +0,0 @@ -SHELL=/bin/bash -PATH=/bin:/sbin:/usr/bin:/usr/sbin:/usr/local/bin:/usr/local/sbin -MAILTO=root -{{ minute }} {{ hour }} * * * root /root/backup.sh diff --git a/pyinfra_borgbackup/borgbackup.service b/pyinfra_borgbackup/borgbackup.service new file mode 100644 index 0000000..c788e0b --- /dev/null +++ b/pyinfra_borgbackup/borgbackup.service @@ -0,0 +1,9 @@ +[Unit] +Description=Run nightly backups +After=network.target + +[Service] +Type=oneshot +ExecStart=/root/backup.sh + + diff --git a/pyinfra_borgbackup/borgbackup.timer.j2 b/pyinfra_borgbackup/borgbackup.timer.j2 new file mode 100644 index 0000000..9619463 --- /dev/null +++ b/pyinfra_borgbackup/borgbackup.timer.j2 @@ -0,0 +1,8 @@ +[Unit] +Description=Run nightly backups + +[Timer] +OnCalendar=*-*-* {{ hour }}:{{ minute }}:00 + +[Install] +WantedBy=timers.target