summaryrefslogtreecommitdiff
path: root/go.sh
blob: 5e9b537a0147c79912f9ddf2624b7caf7c174c6b (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
#!/bin/bash
MAX_AGE_SECONDS=60
CONFIG_DIR=/etc/btrfs-backup/remotes

die()
{
        printf 'Error: %s\n' "$*" >&2
        exit 1
}

read_config()
{
        eval config="($(jq -r '. | to_entries | .[] | "[\"" + .key + "\"]=" + (.value | @sh)'))"
}

check_dependencies()
{
        for c in flock jq btrfs pv realpath stat date mv ln rm
        do
                command -v $c >/dev/null
        done
}

check_user_is_root()
{
        [ "$UID" = 0 ]
}

is_subvolume()
{
        btrfs subvolume show -- "$1" >/dev/null 2>&1
}

get_age()
{
        now=$(date +%s)
        then=$(stat -L -c %Y "$1")
        echo $((now - then))
}

btrfs_receive()
{
        ssh -- "${1%%:*}" btrfs receive -- "${1#*:}"
}

set -e
check_dependencies
check_user_is_root

if [ $# = 1 ]
then
        case "$1" in
                '' ) die 'config file name is blank' ;;
                */*) die 'config file name must not contain "/"' ;;
                *.json) ;;
                *) die "config file name must end in '.json'" ;;
        esac
        config_file=$CONFIG_DIR/$1
        config_file_temp=$CONFIG_DIR/.$1~tmp
else
        echo "Usage: $0 <config.json>" >&2
        exit 1
fi

[ -r "$config_file" ] || die "config file does not exist: $config_file"
exec 3<>"$config_file"
flock -n 3
declare -A config
read_config <&3

src=${config[source]}
dst=${config[destination]}
remote_head=${config[head]}

case "$dst" in
        *:/*) ;;
        /*) dst=localhost:$dst ;;
        *) die "Invalid destination: $dst" ;;
esac

is_subvolume "$src"

if [ "$(get_age "$remote_head")" -le "$MAX_AGE_SECONDS" ]
then
        echo "Up-to-date." >&2
        exit
else
        new_snapshot=${src%/}/.snapshot~$(date -Ins)
        btrfs subvolume snapshot -r -- "$src" "$new_snapshot"
fi

btrfs send ${remote_head:+ -p "$remote_head"} -- "$new_snapshot" | pv | btrfs_receive "$dst"
jq --arg h "$new_snapshot" '. | .head=$h' <"$config_file" >"$config_file_temp"
mv -T -- "$config_file_temp" "$config_file"

if [ "$remote_head" ]
then
        btrfs subvolume delete "$remote_head"
fi

### OK, basic idea:
#
## the system has a list of source subvolumes
## each source subvolume has a snapshot period
## each source subvolume has a list of subscribers
## each source subvolume has an ordered list of its snapshots
## each subscriber has a last-received snapshot (if any)

# when a subvolume's last snapshot is older than the configured period, a new snapshot is created
# when a subvolume has subscribers whose last snapshot is out-of-date, a snapshot is pushed (using the common ancestor)
# the subscriber's last snapshot is then updated
# if there are no subscriber's holding open a snapshot which is not the latest snapshot, then it is deleted