Filters
How to search
  • Plain words find names, texts and examples.
  • "two words" finds the exact phrase.
  • --name or -x finds an option.
  • #word looks only inside commands.
  • @grep finds lessons and labs that use grep.
  • Filters: cat:network · in:labs · in:notes · level:rhcsa · ch:8 · root:yes. Put ! before a word to leave it out. OR between words means one of them.
  • ↑ ↓ to move · Enter to open · Esc to close · / to search

Python scriptsPythonFixes series

A restore drill in Python

The backup job is green for eleven months, and the first restore takes four hours because nobody checked the archive opens. A drill asks one question: can this file give the data back?

Quick reference

The full script is at the end of this note: restore_drill.py (standard library only, no pip install).

Run it

python3 restore_drill.py backup.tar --expected etc.txt

Example output

text
FOUND 1 of 2
DRILL FAIL 1 missing etc/secret.key

Explained

What it does

It opens the tar with tarfile and calls getmembers, so nothing is extracted. It reads the expected paths, normalises the names and checks them. The unpacked total must clear --min-mb, and a zero-byte file is a finding.

What the screen shows

The 14 lines open the archive, index the names and count what is restorable. On the dirty fixture: FOUND 1 of 2, then DRILL FAIL 1 missing etc/secret.key. The full script adds RESTORE lines and the MISSING, EMPTY and SMALL flags.

How to run it

python3 restore_drill.py backup.tar --expected etc.txt, after the upload or weekly in cron. Exit 0 is clean, 1 is unreadable, missing, empty or small, 2 is a usage error, including an unreadable expected list.

The fix

A missing path is a backup-job bug: the dump went to the wrong directory or the exclude grew. Re-run the job, then drill the new archive. An unreadable file means the upload finished and the tar never opened.

Prevention

Review the list when a table or a config file is added, and drill the newest archive, not last quarter's.

The honest caveat: listing proves the archive opens and the names exist; it does not prove the bytes. Only a real restore of one file does.

The lesson: a backup without a restore is a receipt for a promise.

In one line

A backup is a receipt, not a restore. A drill is the test that turns the receipt into evidence.

Drill the newest

python3 restore_drill.py backup.tar

Script: restore_drill.py

Download: scripts/restore_drill.py

python
#!/usr/bin/env python3
"""restore_drill.py - verify that a tar backup could be restored, without restoring it.
 
Opens a tar archive read-only and lists its members. Every path in the
expected-paths file must be present, every expected entry must hold bytes,
and the unpacked total must reach --min-mb. Nothing is extracted, so the
drill is safe against a production backup and safe in CI.
 
Expected-paths file: one path per line. Blank lines and lines starting with
"#" are ignored. Paths are matched after normalisation, so "./etc/app.conf"
and "etc/app.conf" are the same entry. A directory path matches every member
under it, which is how you assert that a whole tree is present.
 
Usage:
    python3 restore_drill.py backup.tar --expected etc.txt
    python3 restore_drill.py backup.tar.gz --expected etc.txt --min-mb 50
    python3 restore_drill.py --help
 
CI (fail the job when the drill fails):
    python3 restore_drill.py /backups/app.tar --expected /etc/s0sta/expected.txt || exit 1
 
Cron (weekly drill against the newest archive):
    0 3 * * 0 python3 /opt/s0sta/restore_drill.py /backups/latest.tar --expected /etc/s0sta/expected.txt
 
Exit codes: 0 every expected path is present and the size is sane,
1 the archive will not open, a path is missing or the size is too small,
2 usage or the expected-paths file cannot be read.
 
Read-only: the archive is opened with tarfile and only getmembers() is
called; nothing is extracted, written or executed. The restore command
printed at the end is a suggestion for a human, never run here.
 
By s0sta.com
"""
import argparse
import sys
import tarfile
 
MIB = 1024.0 * 1024.0
 
 
def load_expected(path):
    """Return the expected paths, or raise ValueError."""
    try:
        with open(path, "r", encoding="utf-8") as handle:
            lines = handle.read().splitlines()
    except (OSError, UnicodeError) as exc:
        raise ValueError("%s: %s" % (path, exc))
    expected = []
    for line in lines:
        item = line.strip()
        if item and not item.startswith("#"):
            expected.append(item)
    if not expected:
        raise ValueError("%s: no expected paths" % path)
    return expected
 
 
def norm(name):
    """Normalise a tar member name for comparison."""
    while name.startswith("./"):
        name = name[2:]
    return name.lstrip("/").rstrip("/")
 
 
def args_for(argv):
    parser = argparse.ArgumentParser(
        description="Check that a tar backup holds the expected paths.")
    parser.add_argument("archive", help="tar file to inspect")
    parser.add_argument("--expected", required=True,
                        help="file with one expected path per line")
    parser.add_argument("--min-mb", type=int, default=1,
                        help="minimum unpacked size in MB (default 1)")
    args = parser.parse_args(argv)
    if args.min_mb < 0:
        parser.error("--min-mb must be zero or more")
    return args
 
 
def main(argv=None):
    args = args_for(argv)
    try:
        expected = load_expected(args.expected)
    except ValueError as exc:
        print("restore_drill: %s" % exc, file=sys.stderr)
        return 2
 
    try:
        with tarfile.open(args.archive, "r:*") as archive:
            members = archive.getmembers()
    except (tarfile.TarError, OSError) as exc:
        print("DRILL FAIL %s: %s" % (args.archive, exc))
        print("SUGGEST check the backup job, re-run it, then drill the new archive")
        return 1
 
    exact = {}
    dirs = set()
    for member in members:
        name = norm(member.name)
        if member.isdir():
            dirs.add(name)
        else:
            exact[name] = member
    total = sum(m.size for m in members if m.isfile())
    mib = total / MIB
 
    found = []
    missing = []
    empty = []
    for path in expected:
        key = norm(path)
        member = exact.get(key)
        if member is not None:
            found.append("RESTORE %s %d bytes" % (path, member.size))
            if member.size == 0:
                empty.append(path)
            continue
        under = [(n, m.size) for n, m in exact.items()
                 if n.startswith(key + "/")]
        if key in dirs or under:
            size = sum(s for _, s in under)
            word = "file" if len(under) == 1 else "files"
            found.append("RESTORE %s/ %d %s, %d bytes"
                         % (key, len(under), word, size))
            if size == 0:
                empty.append(path)
            continue
        missing.append(path)
 
    small = total < args.min_mb * 1024 * 1024
    print("ARCHIVE %s %d members %.1f MiB" % (args.archive, len(members), mib))
    for line in found:
        print(line)
    for path in missing:
        print("MISSING %s" % path)
    for path in empty:
        print("EMPTY %s" % path)
    if small:
        print("SMALL unpacked %.1f MiB < %d MiB" % (mib, args.min_mb))
 
    if missing or empty or small:
        print("DRILL FAIL %d missing, %d empty, %.1f MiB"
              % (len(missing), len(empty), mib))
        print("SUGGEST check the backup job, re-run it, then drill the new archive")
        return 1
 
    print("DRILL OK %d paths, %.1f MiB" % (len(expected), mib))
    print("SUGGEST mkdir -p /tmp/restore && tar -xf %s -C /tmp/restore"
          % args.archive)
    return 0
 
 
if __name__ == "__main__":
    sys.exit(main())

More notes: Python scripts

Open in the notes app: search, filters, copy This note on GitHub