Borg ansible role (continued)

2024-10-07 - The ansible role I rewrote to manage my borg backups
Tags: ansible backups borg

Introduction

I initially wrote about my borg ansible role in a blog article three and a half years ago. I released a second version two years ago (time flies!) and it still works well, but I am no longer using it.

I put down ansible when I got infatuated with nixos a little more than a year ago. As I am dialing it back on nixos, I am reviewing and changing some of my design choices.

Borg repositories changes

One of the main breaking change is that I no longer want to use one borg repository per host as my old role managed: I want one per job/application so that backups are agnostic from the hosts they are running on.

The main advantages are:

The main drawback is that I lose the ability to automatically clean a borg server’s authorized_keys file when I completely stop using an application or service. Migrating from host to host is properly handled, but complete removal will be manual. I tolerate this because now each job has its own private ssh key, generated on the fly when the job is deployed to a host.

The new role

Tasks

The main.yaml contains:

---
- name: 'Install borg'
  package:
    name:
      - 'borgbackup'
    # This use attribute is a work around for https://github.com/ansible/ansible/issues/82598
    # Invoking the package module without this fails in a delegate_to context
    use: '{{ ansible_facts["pkg_mgr"] }}'

It will be included in a delete_to context when a client configures its server. For the client itself, this tasks file will run normally and be invoked from a meta dependency.

The meat of the role is in the client.yaml:

---
# Inputs:
#   client:
#     name: string
#     jobs: list(job)
#     server: string
# With:
#   job:
#     command_to_pipe: optional(string)
#     exclude: optional(list(string))
#     name: string
#     paths: optional(list(string))
#     post_command: optional(string)
#     pre_command: optional(string)

- name: 'Ensure borg directories exists on server'
  file:
    state: 'directory'
    path: '{{ item }}'
    owner: 'root'
    mode: '0700'
  loop:
    - '/etc/borg'
    - '/root/.cache/borg'
    - '/root/.config/borg'

- name: 'Generate openssh key pair'
  openssh_keypair:
    path: '/etc/borg/{{ client.name }}.key'
    type: 'ed25519'
    owner: 'root'
    mode: '0400'

- name: 'Read the public key'
  ansible.builtin.slurp:
    src: '/etc/borg/{{ client.name }}.key.pub'
  register: 'borg_public_key'

- include_role:
    name: 'borg'
    tasks_from: 'server'
  args:
    apply:
      delegate_to: '{{ client.server }}'
  vars:
    server:
      name: '{{ client.name }}'
      pubkey: '{{ borg_public_key.content | b64decode | trim }}'

- name: 'Deploy the jobs script'
  template:
    src: 'jobs.sh'
    dest: '/etc/borg/{{ client.name }}.sh'
    owner: 'root'
    mode: '0500'

- name: 'Deploy the systemd service and timer'
  template:
    src: '{{ item.src }}'
    dest: '{{ item.dest }}'
    owner: 'root'
    mode: '0444'
  notify: 'systemctl daemon-reload'
  loop:
    - { src: 'jobs.service', dest: '/etc/systemd/system/borg-job-{{ client.name }}.service' }
    - { src: 'jobs.timer', dest: '/etc/systemd/system/borg-job-{{ client.name }}.timer' }

- name: 'Activate job'
  service:
    name: 'borg-job-{{ client.name }}.timer'
    enabled: true
    state: 'started'

The server.yaml contains:

---
# Inputs:
#   server:
#     name: string
#     pubkey: string

- name: 'Run common tasks'
  include_tasks: 'main.yaml'

- name: 'Create borg group on server'
  group:
    name: 'borg'
    system: 'yes'

- name: 'Create borg user on server'
  user:
    name: 'borg'
    group: 'borg'
    shell: '/bin/sh'
    home: '/srv/borg'
    createhome: 'yes'
    system: 'yes'
    password: '*'

- name: 'Ensure borg directories exist on server'
  file:
    state: 'directory'
    path: '{{ item }}'
    owner: 'borg'
    mode: '0700'
  loop:
    - '/srv/borg/.ssh'
    - '/srv/borg/{{ server.name }}'

- name: 'Authorize client public key'
  lineinfile:
    path: '/srv/borg/.ssh/authorized_keys'
    line: '{{ line }}{{ server.pubkey }}'
    search_string: '{{ line }}'
    create: true
    owner: 'borg'
    group: 'borg'
    mode: '0400'
  vars:
    line: 'command="borg serve --restrict-to-path /srv/borg/{{ server.name }}",restrict '

Handlers

I have a single handler:

---
- name: 'systemctl daemon-reload'
  shell:
    cmd: 'systemctl daemon-reload'

Templates

The jobs.sh script contains:

#!/usr/bin/env bash
###############################################################################
#     \_o<     WARNING : This file is being managed by ansible!      >o_/     #
#     ~~~~                                                           ~~~~     #
###############################################################################
set -euo pipefail

archiveSuffix=".failed"

# Run borg init if the repo doesn't exist yet
if ! borg list > /dev/null; then
    borg init --encryption none
fi

{% for job in client.jobs %}
archiveName="{{ ansible_fqdn }}-{{ client.name }}-{{ job.name }}-$(date +%Y-%m-%dT%H:%M:%S)"
{% if job.pre_command is defined %}
{{ job.pre_command }}
{% endif %}
{% if job.command_to_pipe is defined %}
{{ job.command_to_pipe }} \
    | borg create \
           --compression auto,zstd \
           "::${archiveName}${archiveSuffix}" \
           -
{% else %}
borg create \
     {% for exclude in job.exclude|default([]) %} --exclude {{ exclude }}{% endfor %} \
     --compression auto,zstd \
     "::${archiveName}${archiveSuffix}" \
     {{ job.paths | join(" ") }}
{% endif %}
{% if job.post_command is defined %}
{{ job.post_command }}
{% endif %}
borg rename "::${archiveName}${archiveSuffix}" "${archiveName}"
borg prune \
     --keep-daily=14 --keep-monthly=3 --keep-weekly=4 \
     --glob-archives '*-{{ client.name }}-{{ job.name }}-*'
{% endfor %}

borg compact

The jobs.service systemd unit file contains:

###############################################################################
#     \_o<     WARNING : This file is being managed by ansible!      >o_/     #
#     ~~~~                                                           ~~~~     #
###############################################################################

[Unit]
Description=BorgBackup job {{ client.name }}

[Service]
Environment="BORG_REPO=ssh://borg@{{ client.server }}/srv/borg/{{ client.name }}"
Environment="BORG_RSH=ssh -i /etc/borg/{{ client.name }}.key -o StrictHostKeyChecking=accept-new"
CPUSchedulingPolicy=idle
ExecStart=/etc/borg/{{ client.name }}.sh
Group=root
IOSchedulingClass=idle
PrivateTmp=true
ProtectSystem=strict
ReadWritePaths=/root/.cache/borg
ReadWritePaths=/root/.config/borg
User=root

Finally the jobs.timer systemd timer file contains:

###############################################################################
#     \_o<     WARNING : This file is being managed by ansible!      >o_/     #
#     ~~~~                                                           ~~~~     #
###############################################################################

[Unit]
Description=BorgBackup job {{ client.name }} timer

[Timer]
FixedRandomDelay=true
OnCalendar=daily
Persistent=true
RandomizedDelaySec=3600

[Install]
WantedBy=timers.target

Invoking the role

The role can be invoked by:

- include_role:
    name: 'borg'
    tasks_from: 'client'
  vars:
    client:
      jobs:
        - name: 'data'
          paths:
            - '/srv/vaultwarden'
        - name: 'postgres'
          command_to_pipe: "su - postgres -c '/usr/bin/pg_dump -b -c -C -d vaultwarden'"
      name: 'vaultwarden'
      server: '{{ vaultwarden.borg }}'

Conclusion

I am happy with this new design! The immediate consequence is that I am archiving my old role since I do not intend to maintain it anymore.