Below is my entry into this year's Offline First Software Challenge, as pre-announced here:
(I maintain the event should be abbreviated as the OffSoffCookoff).
Consider it mostly memeware, but as is traditional in the world of Data, it is fully functional.
#!/usr/bin/env python3
# BugoutBack: a zero-configuration emergency backup tool.
#
# Whether it's flames licking at the doorway or black helicopters on the
# horizon, your last act at the keyboard should be:
#
# 1. Plug in some removable media
# 2. Run this script
# 3. Let it work for as long as you've got
# 4. Hit Ctrl-c when it's bugout time
# 5. Grab your media and run
#
# Those with time to prep may modify some variables below to priortise
# or exclude certain paths, otherwise the script will attempt to back
# up your home directory, starting with the smallest files, until disk
# space on the target media runs out, in which case a partial (but still
# mostly readable) file will remain.
import os, tarfile, gzip, shutil, signal, errno
from pathlib import Path
###
### Configure the variables below
###
# Set BACKUP_TARGET to None to auto-detect removeble media, or specify a path
# like this:
#BACKUP_TARGET = Path("/mnt")
BACKUP_TARGET = None
START_PATHS = [
Path.home(),
]
PRIORITY_PATHS = [
Path.home() / ".ssh",
Path.home() / ".gnupg",
Path.home() / ".config",
]
EXCLUDE_PATHS = [
Path.home() / ".cache",
]
###
### Leave the rest alone
###
STOP_REQUESTED = False
def handle_sigint(signum, frame):
global STOP_REQUESTED
print("Finishing current file and shutting down...")
STOP_REQUESTED = True
signal.signal(signal.SIGINT, handle_sigint)
def is_removable_device(dev_name):
base = dev_name.rstrip("0123456789")
removable_path = Path("/sys/block") / base / "removable"
try:
return removable_path.read_text().strip() == "1"
except:
return False
def detect_target():
candidates = []
with open("/proc/self/mounts") as f:
for line in f:
parts = line.split()
device, mountpoint = parts[0], parts[1]
# Only real block devices
if not device.startswith("/dev/"):
continue
dev_name = Path(device).name
if not is_removable_device(dev_name):
continue
mount_path = Path(mountpoint)
if not mount_path.exists():
continue
try:
usage = shutil.disk_usage(mount_path)
candidates.append((usage.free, mount_path))
except Exception:
continue
if not candidates:
return None
# Pick the one with most free space
candidates.sort(reverse=True)
return candidates[0][1]
def is_excluded(path, target):
for ex in EXCLUDE_PATHS:
if path == ex or ex in path.parents:
return True
if path == target or target in path.parents:
return True
return False
def is_priority(path):
for p in PRIORITY_PATHS:
if path == p or p in path.parents:
return True
return False
def scan_files(target):
files = []
def walk(root):
for dirpath, dirs, filenames in os.walk(root, topdown=True):
dirpath = Path(dirpath)
dirs[:] = [ d for d in dirs
if not is_excluded(dirpath / d, target) ]
for name in filenames:
path = dirpath / name
if is_excluded(path, target):
continue
size = path.lstat().st_size
files.append((path, size))
for P in START_PATHS:
print(f"Scanning {P}")
walk(P)
return files
def write_archive(files, target):
partial_path = target / "backup.tar.gz.partial"
final_path = target / "backup.tar.gz"
disk_full = False
try:
with open(partial_path, "wb") as raw:
with gzip.GzipFile(fileobj=raw, mode="wb") as gz:
with tarfile.open(fileobj=gz, mode="w") as tar:
for path, size in files:
if STOP_REQUESTED:
break
arcname = path.relative_to("/")
try:
tar.add(path, arcname=str(arcname))
print(f"{path} ({size} bytes)")
except OSError as e:
if e.errno == errno.ENOSPC:
print("Disk full")
disk_full = True
break
else:
raise
# Flush everything to disk to increase the chance of data survival
raw.flush()
os.fsync(raw.fileno())
os.sync()
except Exception as e:
print(f"Backup was stopped by an error: {e}")
finally:
if disk_full:
print(f"Backup file: {partial_path}")
else:
partial_path.rename(final_path)
print(f"Backup file: {final_path}")
def main():
target = BACKUP_TARGET or detect_target()
if not target:
print("No removable media found. Try plugging it in harder.")
print("Alternatively, point to the media by changing BACKUP_TARGET")
return
print(f"Backing up to: {target}")
print(f"Free space available: {shutil.disk_usage(target).free} bytes")
files = scan_files(target)
print("Sorting files...")
files.sort(key=lambda x: (0 if is_priority(x[0]) else 1, x[1]))
print("Writing backup file...")
write_archive(files, target)
print("Nothing left to do. Now bug out!")
if __name__ == "__main__":
main()