diff options
Diffstat (limited to 'push-btrfs')
-rwxr-xr-x | push-btrfs | 113 |
1 files changed, 113 insertions, 0 deletions
diff --git a/push-btrfs b/push-btrfs new file mode 100755 index 0000000..5e9b537 --- /dev/null +++ b/push-btrfs | |||
@@ -0,0 +1,113 @@ | |||
1 | #!/bin/bash | ||
2 | MAX_AGE_SECONDS=60 | ||
3 | CONFIG_DIR=/etc/btrfs-backup/remotes | ||
4 | |||
5 | die() | ||
6 | { | ||
7 | printf 'Error: %s\n' "$*" >&2 | ||
8 | exit 1 | ||
9 | } | ||
10 | |||
11 | read_config() | ||
12 | { | ||
13 | eval config="($(jq -r '. | to_entries | .[] | "[\"" + .key + "\"]=" + (.value | @sh)'))" | ||
14 | } | ||
15 | |||
16 | check_dependencies() | ||
17 | { | ||
18 | for c in flock jq btrfs pv realpath stat date mv ln rm | ||
19 | do | ||
20 | command -v $c >/dev/null | ||
21 | done | ||
22 | } | ||
23 | |||
24 | check_user_is_root() | ||
25 | { | ||
26 | [ "$UID" = 0 ] | ||
27 | } | ||
28 | |||
29 | is_subvolume() | ||
30 | { | ||
31 | btrfs subvolume show -- "$1" >/dev/null 2>&1 | ||
32 | } | ||
33 | |||
34 | get_age() | ||
35 | { | ||
36 | now=$(date +%s) | ||
37 | then=$(stat -L -c %Y "$1") | ||
38 | echo $((now - then)) | ||
39 | } | ||
40 | |||
41 | btrfs_receive() | ||
42 | { | ||
43 | ssh -- "${1%%:*}" btrfs receive -- "${1#*:}" | ||
44 | } | ||
45 | |||
46 | set -e | ||
47 | check_dependencies | ||
48 | check_user_is_root | ||
49 | |||
50 | if [ $# = 1 ] | ||
51 | then | ||
52 | case "$1" in | ||
53 | '' ) die 'config file name is blank' ;; | ||
54 | */*) die 'config file name must not contain "/"' ;; | ||
55 | *.json) ;; | ||
56 | *) die "config file name must end in '.json'" ;; | ||
57 | esac | ||
58 | config_file=$CONFIG_DIR/$1 | ||
59 | config_file_temp=$CONFIG_DIR/.$1~tmp | ||
60 | else | ||
61 | echo "Usage: $0 <config.json>" >&2 | ||
62 | exit 1 | ||
63 | fi | ||
64 | |||
65 | [ -r "$config_file" ] || die "config file does not exist: $config_file" | ||
66 | exec 3<>"$config_file" | ||
67 | flock -n 3 | ||
68 | declare -A config | ||
69 | read_config <&3 | ||
70 | |||
71 | src=${config[source]} | ||
72 | dst=${config[destination]} | ||
73 | remote_head=${config[head]} | ||
74 | |||
75 | case "$dst" in | ||
76 | *:/*) ;; | ||
77 | /*) dst=localhost:$dst ;; | ||
78 | *) die "Invalid destination: $dst" ;; | ||
79 | esac | ||
80 | |||
81 | is_subvolume "$src" | ||
82 | |||
83 | if [ "$(get_age "$remote_head")" -le "$MAX_AGE_SECONDS" ] | ||
84 | then | ||
85 | echo "Up-to-date." >&2 | ||
86 | exit | ||
87 | else | ||
88 | new_snapshot=${src%/}/.snapshot~$(date -Ins) | ||
89 | btrfs subvolume snapshot -r -- "$src" "$new_snapshot" | ||
90 | fi | ||
91 | |||
92 | btrfs send ${remote_head:+ -p "$remote_head"} -- "$new_snapshot" | pv | btrfs_receive "$dst" | ||
93 | jq --arg h "$new_snapshot" '. | .head=$h' <"$config_file" >"$config_file_temp" | ||
94 | mv -T -- "$config_file_temp" "$config_file" | ||
95 | |||
96 | if [ "$remote_head" ] | ||
97 | then | ||
98 | btrfs subvolume delete "$remote_head" | ||
99 | fi | ||
100 | |||
101 | ### OK, basic idea: | ||
102 | # | ||
103 | ## the system has a list of source subvolumes | ||
104 | ## each source subvolume has a snapshot period | ||
105 | ## each source subvolume has a list of subscribers | ||
106 | ## each source subvolume has an ordered list of its snapshots | ||
107 | ## each subscriber has a last-received snapshot (if any) | ||
108 | |||
109 | # when a subvolume's last snapshot is older than the configured period, a new snapshot is created | ||
110 | # when a subvolume has subscribers whose last snapshot is out-of-date, a snapshot is pushed (using the common ancestor) | ||
111 | # the subscriber's last snapshot is then updated | ||
112 | # if there are no subscriber's holding open a snapshot which is not the latest snapshot, then it is deleted | ||
113 | |||