#!/bin/bash MIN_AGE_SECONDS=60 CONFIG_DIR=/etc/btrfs/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 egrep date mv ln rm do command -v "$c" >/dev/null || die "missing dependency: $c" done } check_user_is_root() { [ "$UID" = 0 ] } is_subvolume() { btrfs subvolume show -- "$1" >/dev/null 2>&1 } is_readonly_subvolume() { btrfs subvolume show -- "$1" | egrep -q '^ Flags:.*\breadonly\b' } get_age() { now=$(date +%s) then=$(date +%s -d "${1##*.snapshot~}") echo $((now - then)) } btrfs_receive() { ssh_cmdline=(ssh -- "${1%%:*}") "${ssh_cmdline[@]}" bash -c $(printf %q 'set -x; ([ -d "$0" ] || mkdir -p "$0") && btrfs receive -- "$0"') $(printf %q "${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 "/"' ;; .* ) die 'config file name must not begin "."' ;; *.json ) ;; * ) die 'config file name must end ".json"' ;; esac config_file=$CONFIG_DIR/$1 config_file_temp=$CONFIG_DIR/.$1~tmp else echo "Usage: $0 " >&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 [ "$remote_head" ] then is_readonly_subvolume "$remote_head" AGE=$(get_age "$remote_head") if [ "$AGE" -le "$MIN_AGE_SECONDS" ] then echo "Up-to-date." >&2 exit fi fi new_snapshot=${src%/}/.snapshot~$(date -Ins) btrfs subvolume snapshot -r -- "$src" "$new_snapshot" if ! btrfs send ${remote_head:+ -p "$remote_head"} -- "$new_snapshot" | btrfs_receive "$dst" then btrfs subvolume delete "$new_snapshot" exit 1 fi 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