Compare commits

..

No commits in common. "master" and "v0.2.0" have entirely different histories.

4 changed files with 93 additions and 274 deletions

324
btv
View file

@ -8,14 +8,9 @@ import datetime
import json import json
import os import os
import shlex import shlex
import shutil
import sys import sys
import time
import socket
import urllib.request
CONFIG = "/etc/btv/config.ini" CONFIG = "/etc/btv/config.ini"
LOCKFILE = "/run/lock/btv/serialization.lock"
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Global # Global
@ -38,20 +33,6 @@ def chdir(new_dir):
finally: finally:
os.chdir(previous_dir) os.chdir(previous_dir)
@contextmanager
def lockfile(path):
d = os.path.dirname(path)
if not os.path.exists(d):
os.makedirs(d)
f = open(path, "w")
try:
yield
finally:
os.remove(path)
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Functions # Functions
@ -191,17 +172,15 @@ def serialize(snap, outdir, key, snap_from=None):
Snap and snap_from are Snapshot objects. Snap and snap_from are Snapshot objects.
A lockfile is kept for the duration of the process.
Returns 0 on success. Returns 0 on success.
""" """
## prepare directories ## prepare directories
## ##
if snap_from: if snap_from:
name = "%s to %s" %(snap_from.name, snap.name) name = "%s diff from %s" %(snap.name, snap_from.name)
else: else:
name = snap.name name = "%s full" %(snap.name)
directory = os.path.join(outdir, name) directory = os.path.join(outdir, name)
os.makedirs(directory) os.makedirs(directory)
@ -217,67 +196,52 @@ def serialize(snap, outdir, key, snap_from=None):
return 1 return 1
## serialization (most expensive) ## serialize each subvolume or subvolume pair
## ##
with lockfile(LOCKFILE): for subvolume in snap.subvolumes:
if snap_from:
btrfs_send = 'btrfs send -p "%s" "%s"' %(
os.path.join(snap_from.path, subvolume),
os.path.join(snap.path, subvolume)
)
else:
btrfs_send = 'btrfs send "%s"' %(
os.path.join(snap.path, subvolume)
)
for subvolume in snap.subvolumes: error = os.system('%s | zstd | openssl enc -e -aes-256-cbc -pbkdf2 -salt -pass "file:%s" > "%s.btrfs.zst.aes"' %(
if snap_from: btrfs_send,
btrfs_send = 'btrfs send -p "%s" "%s"' %( cfg.get("crypto", "keyfile"),
os.path.join(snap_from.path, subvolume), os.path.join(directory, subvolume)
os.path.join(snap.path, subvolume) ))
)
else:
btrfs_send = 'btrfs send "%s"' %(
os.path.join(snap.path, subvolume)
)
error = os.system('%s | zstd | openssl enc -e -aes-256-cbc -pbkdf2 -salt -pass "file:%s" | hash-pipe sha512 "%s.btrfs.zst.aes" "%s/manifest.sha512" > "%s.btrfs.zst.aes"' %( if error:
btrfs_send, print(" !! failed to serialize %s" %(subvolume))
cfg.get("crypto", "keyfile"), return error
subvolume,
directory,
os.path.join(directory, subvolume)
))
if error: ## calculate checksums and add a self-check executable
print(" !! failed to serialize %s" %(subvolume)) ## FIXME calculate this on the fly, re-reading is expensive
return error previous_wd = os.getcwd()
os.chdir(directory)
os.system('sha512sum "%s" > manifest.sha512' %(
'" "'.join("%s.btrfs.zst.aes" %(s) for s in snap.subvolumes)
))
## final touches with open("check-integrity.sh", "w") as f:
##
## add self-check and unpack executables
with open(os.path.join(directory, "check-integrity.sh"), "w") as f:
f.write("#! /bin/sh\n\nsha512sum --check manifest.sha512\n") f.write("#! /bin/sh\n\nsha512sum --check manifest.sha512\n")
os.chmod(f.name, 0o500)
unpack_path = os.path.join(directory, "unpack.sh")
shutil.copy("/usr/share/btv/unpack.sh", unpack_path)
os.chmod(unpack_path, 0o500)
## fix permissions and ownership of created objects ## fix permissions and ownership of created objects
os.chmod("check-integrity.sh", 0o555)
outdir_stat = os.stat(outdir) outdir_stat = os.stat(outdir)
os.chown(directory, outdir_stat.st_uid, outdir_stat.st_gid) os.chown(directory, outdir_stat.st_uid, outdir_stat.st_gid)
os.chmod(directory, 0o700)
for file in os.listdir(directory): for file in os.listdir(directory):
path = os.path.join(directory, file) path = os.path.join(directory, file)
os.chown(path, outdir_stat.st_uid, outdir_stat.st_gid) os.chown(path, outdir_stat.st_uid, outdir_stat.st_gid)
if path.endswith(".aes") or path.endswith(".sha512"):
os.chmod(path, 0o400)
return 0 return 0
def ping(url):
try:
urllib.request.urlopen(url, timeout=10)
except socket.error as e:
print("Ping failed: %s" %(e))
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Verbs # Verbs
@ -297,37 +261,30 @@ def do_create(args):
## determine the rank of the new snapshot ## determine the rank of the new snapshot
## ##
if "--rank2" in args: snaps_since_rank_1 = 1
snapshot.rank = 2 snaps_since_rank_2 = 1
else:
snaps_since_rank_1 = 1
snaps_since_rank_2 = 1
for snap in list_snapshots(): for snap in list_snapshots():
if snap.rank == 1: if snap.rank == 1:
snaps_since_rank_1 = 1 snaps_since_rank_1 = 1
else: else:
snaps_since_rank_1 += 1 snaps_since_rank_1 += 1
if snap.rank == 2: if snap.rank == 2:
snaps_since_rank_1 = 1 snaps_since_rank_1 = 1
snaps_since_rank_2 = 1 snaps_since_rank_2 = 1
else: else:
snaps_since_rank_2 += 1 snaps_since_rank_2 += 1
## promote the snapshot ## promote the snapshot
if snaps_since_rank_2 >= cfg.getint("snap", "rank_2_interval"): if snaps_since_rank_2 >= cfg.getint("snap", "rank_2_interval"):
if "--process" in args: if "--process" in args:
snapshot.rank = 2 snapshot.rank = 2
else: else:
print("!!! Rank 2 snapshot is due, please enable --process") print("!!! Rank 2 snapshot is due, please enable --process")
snapshot.rank = 1
elif snaps_since_rank_1 >= cfg.getint("snap", "rank_1_interval"):
snapshot.rank = 1 snapshot.rank = 1
elif snaps_since_rank_1 >= cfg.getint("snap", "rank_1_interval"):
ping_url = cfg.get("monitoring", "rank2_start_url") snapshot.rank = 1
if snapshot.rank == 2 and ping_url:
ping(ping_url)
## create the snapshot itself ## create the snapshot itself
## ##
@ -350,6 +307,7 @@ def do_create(args):
## do optional processing ## do optional processing
## ##
if snapshot.rank == 2: if snapshot.rank == 2:
## snapshot serialization ## snapshot serialization
# if there's a previous Rank 2 snapshot, compute a diff against it # if there's a previous Rank 2 snapshot, compute a diff against it
# if not or if the process fails, demote this snap to Rank 1 # if not or if the process fails, demote this snap to Rank 1
@ -374,18 +332,14 @@ def do_create(args):
print("!!! no previous Rank 2 snapshot, please create one using btv stream") print("!!! no previous Rank 2 snapshot, please create one using btv stream")
snapshot.rank = 1 snapshot.rank = 1
## garbage collection
do_gc()
## save all snapshot metadata ## save all snapshot metadata
snapshot.dump() snapshot.dump()
print(">>> Snapshot created: rank %d %s" %(snapshot.rank, snapshot.name)) print(">>> Snapshot created: rank %d %s" %(snapshot.rank, snapshot.name))
## garbage collection
do_gc()
ping_url = cfg.get("monitoring", "rank2_end_url")
if snapshot.rank == 2 and ping_url:
ping(ping_url)
def do_list(args): def do_list(args):
""" """
Print a list of existing snapshots. Print a list of existing snapshots.
@ -426,151 +380,59 @@ def do_drop(args):
def do_stream(args): def do_stream(args):
""" """
args are either Stream the snapshot args[0] into dir args[1].
"diff" SNAPSHOT_FROM SNAPSHOT_TO OUTPUT_DIR The affected snapshot is then promoted to Rank 2.
or
"full" SNAPSHOT OUTPUT_DIR
SNAPSHOT_FROM must be Rank 2.
Streams the full or diff snapshot into OUTPUT_DIR.
The SNAPSHOT or SNAPSHOT_TO is then promoted to Rank 2.
""" """
## args interpretation and validation snapshot = get_snapshot_by_name(args[0])
##
if not args:
raise UsageError("no args")
if args[0] == "diff": if not snapshot:
if len(args) != 4: print("!!! %s is not a snapshot" %(args[0]))
raise UsageError("'stream diff' requires exactly 3 arguments")
snap_from_name, snap_name, output_dir = args[1:]
snap_from = get_snapshot_by_name(snap_from_name)
if not snap_from:
print("!!! %s is not a snapshot" %(snap_from_name))
sys.exit(2)
if snap_from.rank != 2:
print("!!! source snapshot must be Rank 2, %s is %d" %(snap_from.name, snap_from.rank))
print(' > Hint: btv stream full %s "%s"' %(snap_from.name, output_dir))
sys.exit(3)
elif args[0] == "full":
if len(args) != 3:
raise UsageError("'stream full' requires exactly 2 arguments")
snap_name, output_dir = args[1:]
snap_from = None
else:
raise UsageError("'stream' type is either 'full' or 'diff'")
snap = get_snapshot_by_name(snap_name)
if not snap:
print("!!! %s is not a snapshot" %(snap_name))
sys.exit(2) sys.exit(2)
if not os.path.isdir(output_dir): directory = args[1]
print("!!! %s is not a directory" %(output_dir))
if not os.path.isdir(directory):
print("!!! %s is not a directory" %(directory))
sys.exit(2) sys.exit(2)
if snap_from and snap_from.name >= snap.name: print(">>> Serializing %s into %s" %(snapshot.name, directory))
print("!!! source snapshot is younger than target snapshot")
sys.exit(3)
## serialization work
##
if snap_from:
comment = "diff %s -> %s" %(snap_from.name, snap.name)
else:
comment = "full %s" %(snap.name)
print(">>> Serializing %s into %s" %(comment, output_dir))
error = serialize( error = serialize(
snap, snapshot,
output_dir, directory,
cfg.get("crypto", "keyfile"), cfg.get("crypto", "keyfile")
snap_from
) )
if error: if error:
print("!!! serialization failed") print("!!! serialization failed")
else: else:
snap.rank = 2 snapshot.rank = 2
snap.notes.add("manually streamed") snapshot.notes.add("fully streamed")
snapshot.dump()
if not snap_from: print("<<< %s serialized and promoted to Rank %d" %(snapshot.name, snapshot.rank))
snap.notes.add("fully streamed")
snap.dump()
print("<<< %s serialized and promoted to Rank 2" %(snap.name))
def do_gc(args=None): def do_gc(args=None):
""" """
Drops old snapshots. Drops old snapshots.
If the only arg is "greedy", drops ALL snapshots except the youngest
Rank 2. If it's a number, drops that many oldest snapshots.
""" """
if args: counts = [0, 0, 0] # Rank 0, 1, 2
if args[0] == "greedy": limits = [
newest = list_snapshots(2)[-1] cfg.getint("gc", "rank_0_count"),
cfg.getint("gc", "rank_1_count"),
cfg.getint("gc", "rank_2_count")
]
if newest: for snap in reversed(list_snapshots()):
print(">>> Dropping all snapshots except the newest Rank 2 in 5 s...") counts[snap.rank] += 1
time.sleep(5)
for snap in list_snapshots(): if counts[snap.rank] > limits[snap.rank]:
if snap.name != newest.name: print(" >> delete Rank %d %s" %(snap.rank, snap.name))
snap.drop()
else: snap.drop()
print("!!! no Rank 2 snapshot exists")
sys.exit(1)
else:
try:
count = int(args[0])
except ValueError:
print("!!! %s is not a count of snapshots to delete" %(args[0]))
sys.exit(1)
snaps = list_snapshots()[:count]
for snap in snaps:
print(" %d %s %s" %(snap.rank, snap.name, ", ".join(snap.notes)))
print(">>> These snapshots will be dropped in 5 s...")
time.sleep(5)
for snap in snaps:
snap.drop()
else:
counts = [0, 0, 0] # Rank 0, 1, 2
limits = [
cfg.getint("gc", "rank_0_count"),
cfg.getint("gc", "rank_1_count"),
cfg.getint("gc", "rank_2_count")
]
for snap in reversed(list_snapshots()):
counts[snap.rank] += 1
if counts[snap.rank] > limits[snap.rank]:
print(" >> delete Rank %d %s" %(snap.rank, snap.name))
snap.drop()
verb_router = { verb_router = {
"snapshot": do_create, "snapshot": do_create,
@ -588,17 +450,12 @@ def print_help():
{cmd} VERB [args] {cmd} VERB [args]
Verbs: Verbs:
snapshot [--process] [--rank2] snapshot [--process]
Create a snapshot. If --process is passed, do all optional work. If Create a snapshot. If --process is passed, do all optional work.
--rank2 is passed, the new snapshot is automatically promoted to Rank 2
and --process is implied.
stream full SNAPSHOT OUTPUT_DIR stream SNAPSHOT OUTPUT_DIR
Streams the SNAPSHOT into OUTPUT_DIR as a full (milestone) bin. Streams the SNAPSHOT into OUTPUT_DIR as a full (milestone) bin.
stream diff SNAPSHOT_FROM SNAPSHOT_TO OUTPUT_DIR
Streams the difference between SNAPSHOTs into OUTPUT_DIR as a diff bin.
list list
List snapshots and their rank. List snapshots and their rank.
@ -607,12 +464,6 @@ Verbs:
gc gc
Drops old local snapshots based on garbage collector settings. Drops old local snapshots based on garbage collector settings.
gc greedy
Drop ALL snapshots except the newest Rank 2.
gc COUNT
Drop COUNT oldest snapshots.
""".format(cmd=sys.argv[0])) """.format(cmd=sys.argv[0]))
if __name__ == "__main__": if __name__ == "__main__":
@ -629,8 +480,7 @@ if __name__ == "__main__":
try: try:
verb_router[verb](sys.argv[2:]) verb_router[verb](sys.argv[2:])
except UsageError as e: except UsageError:
print("!!! %s" %(e))
print() print()
print_help() print_help()
sys.exit(1) sys.exit(1)

View file

@ -30,15 +30,9 @@ dir = /mnt/pool/subvol/_backup
[gc] [gc]
# How many snapshots of each Rank to keep around. # How many snapshots of each Rank to keep around.
# Note: pruning is only performed when a new Rank 2 snapshot is attempted.
# Default values are 25, 36, and 42, i.e. 5 hours for Rank 0, 2 days for Rank 1, # Default values are 25, 36, and 42, i.e. 5 hours for Rank 0, 2 days for Rank 1,
# and a week for Rank 2. # and a week for Rank 2.
rank_0_count=25 rank_0_count=25
rank_1_count=36 rank_1_count=36
rank_2_count=42 rank_2_count=42
[monitoring]
# GETs this URL before starting a rank2 snapdhot
rank2_start_url =
# GETs this URL after successfully completing a rank2 snapshot
rank2_end_url =

View file

@ -1,6 +1,5 @@
[Unit] [Unit]
Description=Filesystem snapshot (with offsite backup) Description=Filesystem snapshot (with offsite backup)
ConditionPathExists=!/run/lock/btv/serialization.lock
[Service] [Service]
Type=oneshot Type=oneshot

View file

@ -1,24 +0,0 @@
#! /bin/zsh
TIMESTAMP=($(basename "$(pwd)"))
OUTDIR="$1"
KEYFILE="$2"
function die {
>&2 echo "$2"
exit $1
}
[[ "$0" != "./unpack.sh" ]] && die 1 "This can only be executed from the snapshot directory itself."
[[ ! -d "$OUTDIR" ]] && die 1 "The first argument must be a directory to unpack subvolumes into."
[[ ! -f "$KEYFILE" ]] && die 1 "The second argument must be a readable keyfile."
./check-integrity.sh || die 2 "This snapshot failed integrity checks."
### end of checks
for ARCHIVE in *btrfs.zst.aes
do
openssl enc -d -aes-256-cbc -pbkdf2 -salt -pass "file:$KEYFILE" < "$ARCHIVE" | zstd -d | btrfs receive "$OUTDIR" || die 3 "Failed to unpack subvolume."
SUBVOL_NAME=${ARCHIVE%%.btrfs.zst.aes}
mv "${OUTDIR}/${SUBVOL_NAME}" "${OUTDIR}/${SUBVOL_NAME}.${TIMESTAMP[1]}" || die 4 "Failed to rename subvolume."
done