blob: 54198cc561ed357b4ed86fea73e949a0ce0302e9 (
plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
|
---
title: 'Borg ansible role (continued)'
description: 'The ansible role I rewrote to manage my borg backups'
date: '2024-10-07'
tags:
- ansible
- backups
- borg
---
## Introduction
I initially wrote about my borg ansible role in [a blog article three and a half years ago]({{< ref "borg-ansible-role.md" >}}). 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:
- one private ssh key per job
- no more data expiration when a job stops running on a job for a time
- easier monitoring of job run: now checking if a repository has new data is enough, before I had to check the number of jobs that wrote to it in a specific time frame.
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:
``` yaml
---
- 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:
``` 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:
``` yaml
---
# 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:
``` yaml
---
- name: 'systemctl daemon-reload'
shell:
cmd: 'systemctl daemon-reload'
```
### Templates
The `jobs.sh` script contains:
``` shell
#!/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:
``` ini
###############################################################################
# \_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:
``` ini
###############################################################################
# \_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:
``` yaml
- 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.
|