-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathrestomic
More file actions
executable file
·429 lines (369 loc) · 12.6 KB
/
restomic
File metadata and controls
executable file
·429 lines (369 loc) · 12.6 KB
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
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
#!/bin/sh
#
# Script to make atomic backups with restic using zfs snapshots
#
# Ben Spiegel
#
# changelog:
# 2025/04/11: *brs: refactor arg parsing, support datasets with spaces
# 2025/03/24: *brs: use device-id-for-hardlinks flag in default command
# 2023/04/03: *brs: added pre- and post-hooks and exit trap
# 2023/03/18: *brs: added command and args options
# 2023/01/14: *brs: created
#
# Make sure to clean up snapshots and temp files before exiting
trap "clean_up; exit" INT HUP TERM
VERSION='0.4'
# Paths for FreeBSD:
ZFS='/sbin/zfs'
GREP='/usr/bin/grep'
RESTIC='/usr/local/bin/restic'
# Paths for Linux:
# Note: Linux is not currently supported due to use of nullfs
# ZFS='/usr/sbin/zfs'
# GREP='/usr/bin/grep'
# RESTIC='/usr/bin/restic'
# Snapshot namespace is a prefix (script name and PID) and the date
PREFIX="$(basename "$0")_$$_"
DATE=$(date "+%F_%T")
TMPDIR="/tmp/.$PREFIX$DATE"
MNTDIR="/restic"
# Set to 1 to not recreate and remove top-level directories each time
LEAVE_DIRS_IN_PLACE=0
__usage="
Usage: $(basename "$0") [-ahnrv][-c cmd][-o opts][-pq cmd] <dataset or path> [...]
"
__help="
Makes atomic backups with restic by creating a snapshot of each zfs dataset,
nullfs-mounting those snapshots together into a working directory, and running
the backup command on the contents of that directory.
The script backs up one or more zfs datasets you specify (in zfs notation or
as a file path). Datasets must be mounted to be backed up. You can optionally
process child datasets too with -r or process all mounted datasets with -a.
Because it uses nullfs to assemble datasets into the working directory, the
script runs only on FreeBSD.
For example, $(basename "$0") -r zroot, given the following zfs list output:
\$ zfs list -t filesystem -H -o name
zroot
zroot/usr
zroot/usr/home
creates snapshots using a prefix, PID, and date as namespace, such as:
zroot@${PREFIX}${DATE}
zroot/usr@${PREFIX}${DATE}
zroot/usr/home@${PREFIX}${DATE}
Then, for each dataset, it (1) gets the mountpoint as follows:
\$ zfs get -H -o value mountpoint zroot/usr/home
/usr/home
If there is a mountpoint, it (2) mounts the snapshot contents as follows:
# mount -o ro -t nullfs \\
/usr/home/.zfs/snapshot/${PREFIX}${DATE} \\
${MNTDIR}/usr/home
and runs a restic backup command as follows:
# RESTIC_FEATURES=device-id-for-hardlinks restic backup ${MNTDIR}
Finally, it unmounts the nullfs directory tree, removes the working directory
and temp files, and destroys the snapshots that were created.
Options:
-a Back up all datasets
-c COMMAND Specify command to run (by default, \`restic backup\`) with
the device-id-for-hardlinks feature flag set.
-h Print this help text and exit
-n Dry run without creating snapshots or backing up
-o OPTIONS Specify options to pass to the command
-p COMMAND Specify pre-hook command to run before doing anything
-q COMMAND Specify post-hook command to run after the script is done
-r Process children of specified datasets recursively
-v Print the version number and exit
"
# usage()
#
# Print usage information.
#
usage()
{
echo "$__usage";
}
# help()
#
# Print detailed help text.
#
help()
{
echo "$__help";
}
# Process command line options
dry_run=0
zfs_recursive=
all_datasets=0
backup_command="RESTIC_FEATURES=device-id-for-hardlinks $RESTIC backup"
backup_command_opts=
pre_hook=
post_hook=
# TODO: handle long options
# Convert long options (if present) to short options
# for longopt in "$@"; do
# case "$longopt" in
# --command=?*)
# # Assign the string to the equivalent short option and value.
# # Note: ${arg#*=} deletes through =, leaving the value.
# set -- "$@" "-c${longopt#*=}"
# shift
# ;;
# --command-opts=?*)
# set -- "$@" "-o${longopt#*=}"
# shift
# ;;
# esac
# done
while getopts "ac:hno:p:q:rv" option; do
case "$option" in
a) all_datasets=1;;
c) backup_command=${OPTARG};;
h) usage; help; exit 0;;
n) dry_run=1;;
o) backup_command_opts=${OPTARG};;
p) pre_hook=${OPTARG};;
q) post_hook=${OPTARG};;
r) zfs_recursive=1;;
v) echo "$VERSION"; exit 0;;
?) usage; exit 1;;
esac
done
# Done processing options. Shift the index so only target arguments are left
shift $((OPTIND - 1))
use_dataset_args=
# Make sure there is at least one argument or -a is specified
if [ "$#" -lt 1 ] && [ "$all_datasets" -ne 1 ]; then
usage; exit 1
fi
# Unless -a is specified, use specified datasets from the args
if [ "$all_datasets" -ne 1 ]; then
use_dataset_args=1
fi
# Make sure valid datasets are specified.
# NOTE: This line and others below use parameter expansion (braces) with
# alternate values for the the -r option and the specified datasets so that the
# arg is substituted with null if not set. Quoting would instead inclue an
# argument of "" in the zfs list command, which doesn't work.
if ! "$ZFS" list -t filesystem ${zfs_recursive:+"-r"} ${use_dataset_args:+"$@"} > /dev/null; then
exit 1
fi
# output()
#
# Print a line of output, indicating dry_run option if present.
#
output()
{
if [ $dry_run -ne 0 ]; then
printf "(Dry Run) "
fi
printf '%b' "$@"
}
# run_command()
#
# Run or no-op a command depending on the dry_run option.
#
run_command()
{
if [ $dry_run -eq 0 ]; then
"$@"
fi
}
# take_snapshots()
#
# Take a snapshots of each specified dataset.
#
# Loops over zfs datasets from the args (either a string or blank for all),
# adding -r if specified in the options. For each one, generates and runs a
# commmand to take a snapshot in the script's namespace. For example:
#
# # zfs snap zroot/usr/home@restomic_123_2023-01-14_01:00:00
#
take_snapshots()
{
"$ZFS" list -t filesystem ${zfs_recursive:+"-r"} -H -o name ${use_dataset_args:+"$@"} \
| while read -r dataset; do
output "Snapshotting ${dataset}... "
snapshot=$(printf '%s@%s%s' "$dataset" "$PREFIX" "$DATE")
if run_command "$ZFS" snap "$snapshot"; then
printf "done.\n"
fi
done
}
# mount_snapshots()
#
# For each dataset, open the snapshot and mount it into a directory tree.
#
# First, makes sure directories exist for the working backup and a temp file.
# Then, loops over the zfs datasets in the args (if specified) or all datasets
# if the -a option is specified. For each dataset, generates and runs commands
# to do the following:
#
# 1. Look up the dataset's mountpoint, skipping datasets that aren't mounted.
# 2. Mount the backup snapshot contents (/dataset/.zfs/snapshot/<name>) into
# the working directory in the same layout as the mounted filesystem,
# skipping datasets for which no snapshot contents exist.
# 3. Write out the mount destination to the temp file (to unmount later).
#
# For example, for dataset zroot/usr/home:
#
# # zfs get -H -o value mountpoint zroot/usr/home
# # mount -o ro -t nullfs \
# /usr/home/.zfs/snapshot/restomic_123_2022-01-14_01:00:00 \
# /restic/usr/home
# # printf /restic/usr/home >> /tmp/to_unmount
#
mount_snapshots()
{
# Create the destinations for mount points and temp files
run_command mkdir -p "$TMPDIR"
run_command umask 0077 && run_command mkdir -p "$MNTDIR" \
&& run_command umask 0022
# Loop over specified datasets or all datasets
"$ZFS" list -t filesystem ${zfs_recursive:+"-r"} -H -o name ${use_dataset_args:+"$@"} \
| while read -r dataset; do
# Get the mountpoint for this dataset
MOUNTPOINT=$("$ZFS" get -H -o value mountpoint "$dataset")
# If there's no mountpoint (i.e., it's not mounted), skip to the next
# dataset. (We wouldn't know where to put it in the working directory.)
if [ "$MOUNTPOINT" = 'none' ]; then
output "Skipping $dataset because it's not mounted.\n"
continue
fi
# Assemble string for the path to the contents of the backup snapshot
# under <dataset>/.zfs/snapshot/<name>.
SNAPDIR=''
if [ "$MOUNTPOINT" = '/' ]; then
SNAPDIR=$(printf "/.zfs/snapshot/%s%s" \
"$PREFIX" "$DATE")
else
SNAPDIR=$(printf "%s/.zfs/snapshot/%s%s" \
"$MOUNTPOINT" "$PREFIX" "$DATE")
fi
# If the dataset has no files in it (such as a dataset like zroot that
# only contains other datasets), the .zfs/snapshot directory doesn't
# exist. Skip the dataset in that case.
#
# Don't check this on dry run. We didn't create the snapshot, so the
# directory doesn't exist.
if [ $dry_run -eq 0 ] && ! stat "$SNAPDIR" > /dev/null 2>&1 ; then
output "Skipping $dataset. No snapshot directory (dataset empty).\n"
continue
fi
# Assemble string for the path in the working directory to mount this
# snapshot into.
MOUNTDESTINATION=$(printf "%s%s" \
"$MNTDIR" "$MOUNTPOINT")
# Mount the snapshot into that location.
output "Mounting $SNAPDIR into ${MOUNTDESTINATION}... "
run_command mkdir -p "$MOUNTDESTINATION" \
&& run_command mount -o ro -t nullfs "$SNAPDIR" "$MOUNTDESTINATION" \
&& printf "done.\n" \
# Write out what was mounted to a file, but not on dry run
if [ $dry_run -eq 0 ]; then
printf '%s' "$MOUNTDESTINATION " >> "$TMPDIR/to_unmount"
fi
done
}
# run_restic_backup()
#
# Run the specified backup command (by default, path to restic) with the
# specified arguments plus the mountpoint directory as the last argument.
#
run_restic_backup()
{
output "Running backup... \n"
# NOTE: expands command and arguments before feeding them to the shell
# so that command and option values with multiple words work, such as
# `restomic -c "restic backup" -o "-q --json" /home`.
#
# These values for -c and -o work only if we don't quote $backup_command
# and $backup_command_opts and instead let them be expanded here.
run_command $backup_command $backup_command_opts "$MNTDIR" \
&& output "...done.\n"
}
# unmount_snapshots()
#
# Unmount nullfs-mounted snapshots and delete mountpoint directories.
#
# Reads the temp file where mounted snapshots were recorded and runs a
# umount command with the whole list as arguments.
#
# To make sure everything is unmounted recursively even if a mount point is
# listed before its children (such as /usr before /usr/src), the function
# uses forceful unmounts and attempts to run the umount command up to 5 times.
#
# Finally, deletes the working directory unless LEAVE_DIRS_IN_PLACE is set.
#
unmount_snapshots()
{
output "Unmounting all mounted snapshots"
# Don't do anything on a dry run
if [ $dry_run -ne 0 ]; then
printf " done.\n"
return;
fi
TOUNMOUNT=$(cat "$TMPDIR/to_unmount")
umount_count=0
while [ $umount_count -le 5 ]; do
printf "..."
# NOTE: This doesn't work if $TOUNMOUNT is double-quoted (see above).
if umount -f -t nullfs $TOUNMOUNT > /dev/null 2>&1; then
break
fi
umount_count=$((umount_count+1))
done
if [ "$LEAVE_DIRS_IN_PLACE" -ne 1 ]; then
rm -r "$MNTDIR"
fi
printf " done.\n"
}
# destroy_snapshots()
#
# Remove snapshots in the script's namespace for this run's date and time.
#
# Generates and runs a command like the following:
#
# zfs list -H -o name -t snap \
# | grep restomic_123_2023-01-14_01:00:00 \
# | xargs -n1 zfs destroy
#
destroy_snapshots()
{
output "Destroying snapshots... "
run_command "$ZFS" list -H -o name -t snap \
| "$GREP" "$PREFIX""$DATE" \
| xargs -n1 "$ZFS" destroy \
&& printf "done.\n"
}
# remove_temp_files()
#
# Delete temporary files created by the script.
#
remove_temp_files()
{
output "Removing temp files... "
run_command rm -r "$TMPDIR" \
&& printf "done.\n"
}
# clean_up()
#
# Unmount and remove files used during the script.
#
clean_up()
{
# Override trap above; don't try to clean up when exiting during clean_up
trap "exit" INT HUP TERM
output "Cleaning up...\n"
unmount_snapshots;
destroy_snapshots;
remove_temp_files;
output "...done.\n"
}
# Run script functions
# NOTE: The pre- and post-hooks don't work with double quotes.
run_command $(printf '%b' "$pre_hook");
take_snapshots "$@";
mount_snapshots "$@";
run_restic_backup;
clean_up;
run_command $(printf '%b' "$post_hook");