Add monthly offsite restore tests and automatic cleanup

This commit is contained in:
admin committed 2026-09-12 19:47:32 +02:00
1 parent 64eab1d3c2
commit f0c761213d
18 files changed
+1386 -16

No files matched your search

+527
View File
@@ -0,0 +1,527 @@
#!/usr/bin/env python3
"""Read-only offsite restore rehearsal. Only marked scratch data may be deleted.
The shell launcher holds the shared backup lock through this process. Docker has
one bind mount, no network/ports, no restart policy and no persistent log driver.
"""
import argparse
import datetime as dt
from email.message import EmailMessage
from email.policy import SMTP
import hashlib
import json
import logging
from logging.handlers import RotatingFileHandler
import os
from pathlib import Path
import re
import selectors
import shutil
import signal
import socket
import subprocess
import sys
import tarfile
import time
import backup_set
GIB = 1024 ** 3
MARKER = 'app-backup-restore-test-v1\n'
DOCKER = ['docker', '--host=unix:///var/run/docker.sock']
OWNER_LABEL = 'de.jr-it-services.app-backup.restore-test'
class TestError(Exception):
pass
def atomic_json(path, data):
temp = path.with_suffix('.tmp')
temp.write_text(json.dumps(data, ensure_ascii=True, indent=2) + '\n')
temp.replace(path)
def private_root(workdir, create=True):
work = Path(workdir)
# Do not follow aliases to source paths or accept broad filesystem roots.
if (not work.is_absolute() or work.resolve() != work or len(work.parts) < 3 or
any(c in str(work) for c in ':\n\r')):
raise TestError('Unsafe WORKDIR for restore test')
if work.exists() and (work.stat().st_uid != 0 or work.stat().st_mode & 0o022):
raise TestError('WORKDIR must be root-owned and not writable by group/others')
root = work / 'restore-test'
if root.is_symlink():
raise TestError('Restore-test root must not be a symlink')
if not root.exists():
if not create:
return root
root.mkdir(mode=0o700, parents=True)
(root / '.owner').write_text(MARKER)
marker = root / '.owner'
if (root.stat().st_uid != 0 or root.stat().st_mode & 0o077 or
not marker.is_file() or marker.is_symlink() or marker.read_text() != MARKER):
raise TestError('Unrecognized/non-private restore-test directory; refusing cleanup')
# These paths are written by us only. Never follow replacements into live data.
for name in ('.owner', 'scratch', 'state.json', 'state.tmp', 'last-success.json',
'last-success.tmp', 'report.eml', 'restore-test.log', 'restore-test.log.1', 'db-created',
'child.json', 'child.tmp'):
if (root / name).is_symlink():
raise TestError('Symlink inside restore-test control directory')
return root
class Rehearsal:
def __init__(self, args):
self.args = args
self.root = private_root(args.workdir, create=args.mode != 'cleanup')
self.scratch = self.root / 'scratch'
self.owner = hashlib.sha256(str(self.root).encode()).hexdigest()[:16]
self.container = 'app-backup-restore-test-' + self.owner
self.started = time.time()
self.deadline = time.monotonic() + args.timeout
self.state = dict(status='RUNNING', started_epoch=int(self.started), run='',
components=[], cleanup='PENDING', error='')
self.log = logging.getLogger('restore-test')
self.log.setLevel(logging.INFO)
for handler in self.log.handlers:
handler.close()
self.log.handlers.clear()
if self.root.exists():
handler = RotatingFileHandler(self.root / 'restore-test.log', maxBytes=512*1024, backupCount=1)
handler.setFormatter(logging.Formatter('%(asctime)s %(message)s'))
self.log.addHandler(handler)
self.log.addHandler(logging.StreamHandler(sys.stdout))
self.next_usage = 0
self.used = 0
self.monitor = False
def remember_child(self, proc):
stat = Path(f'/proc/{proc.pid}/stat').read_text().rsplit(')', 1)[1].split()
atomic_json(self.root / 'child.json', dict(pid=proc.pid, start=stat[19]))
def stop_orphan(self):
marker = self.root / 'child.json'
if not marker.exists():
return
child = json.loads(marker.read_text())
pid = child['pid']
if not isinstance(pid, int) or pid <= 1:
raise TestError('Invalid child-process marker')
def alive():
try:
stat = Path(f'/proc/{pid}/stat').read_text().rsplit(')', 1)[1].split()
return stat[19] == child['start'] and stat[0] != 'Z' and os.getpgid(pid) == pid
except (FileNotFoundError, ProcessLookupError):
return False
if alive():
os.killpg(pid, signal.SIGTERM)
end = time.monotonic() + 5
while alive() and time.monotonic() < end:
time.sleep(0.1)
if alive():
os.killpg(pid, signal.SIGKILL)
end = time.monotonic() + 5
while alive() and time.monotonic() < end:
time.sleep(0.1)
if alive():
raise TestError('Child process could not be stopped; cleanup deferred')
marker.unlink()
def save(self):
atomic_json(self.root / 'state.json', self.state)
def disk(self, additional=0, force=False):
if not self.monitor:
return
if time.monotonic() >= self.deadline:
raise TestError('Restore-test time limit exceeded')
free = shutil.disk_usage(self.root).free
if free < self.args.min_free_gb * GIB + additional:
raise TestError('Free-space reserve reached; test stopped and scratch will be cleaned')
if force or time.monotonic() >= self.next_usage:
self.used = 0
for path, directories, files in os.walk(self.scratch, followlinks=False):
for name in directories + files:
self.used += (Path(path) / name).lstat().st_blocks * 512
self.next_usage = time.monotonic() + 2
if self.used + additional > self.args.max_work_gb * GIB:
raise TestError('Restore-test disk budget exceeded')
def command(self, command, *, capture=0, output=None, limit=None, stdin=None, timeout=120):
"""Bound stdout and runtime, monitor disk, reap children even on interruption.
stderr is intentionally not copied: DB errors can contain restored data.
Errors identify the failed operation without logging SQL or credentials.
"""
result = bytearray()
total = 0
end = time.monotonic() + timeout
proc = subprocess.Popen(command, stdin=stdin or subprocess.DEVNULL,
stdout=subprocess.PIPE if capture or output is not None else subprocess.DEVNULL,
stderr=subprocess.DEVNULL, start_new_session=True)
selector = selectors.DefaultSelector()
if proc.stdout:
selector.register(proc.stdout, selectors.EVENT_READ)
try:
self.remember_child(proc)
while selector.get_map() or proc.poll() is None:
self.disk()
if time.monotonic() >= end:
raise TestError('Timed out: ' + Path(command[0]).name)
if not selector.get_map():
time.sleep(0.1)
continue
for key, _ in selector.select(0.2):
chunk = os.read(key.fd, 64*1024)
if not chunk:
selector.unregister(key.fileobj)
continue
total += len(chunk)
if total > (limit if limit is not None else capture):
raise TestError('Remote output exceeds expected size')
if output is not None:
self.disk(additional=len(chunk))
output.write(chunk)
else:
result.extend(chunk)
if proc.wait() != 0:
raise TestError('Command failed: ' + Path(command[0]).name)
return bytes(result)
finally:
selector.close()
if proc.poll() is None:
os.killpg(proc.pid, signal.SIGTERM)
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
os.killpg(proc.pid, signal.SIGKILL)
proc.wait()
if proc.stdout:
proc.stdout.close()
(self.root / 'child.json').unlink(missing_ok=True)
def remote(self, *args, **kwargs):
return self.command(['bash', self.args.remote_check, self.args.config, *args],
timeout=self.args.timeout, **kwargs)
def cleanup(self):
self.stop_orphan()
# db-created is written BEFORE docker run. A lost CLI reply cannot orphan a DB.
if (self.root / 'db-created').exists():
names = self.command(DOCKER + ['container', 'ls', '-a', '--filter',
f'label={OWNER_LABEL}={self.owner}', '--format', '{{.Names}}'],
capture=4096).decode().splitlines()
if any(name != self.container for name in names):
raise TestError('Unexpected container ownership; refusing cleanup')
if names:
self.command(DOCKER + ['container', 'rm', '-f', '-v', self.container])
(self.root / 'db-created').unlink()
if self.scratch.exists():
# shutil.rmtree uses fd-relative traversal on Linux; nested symlinks
# are unlinked, never traversed. No broad cleanup of WORKDIR or Docker.
if self.scratch.is_symlink() or self.scratch.is_mount():
raise TestError('Unsafe scratch directory; refusing cleanup')
shutil.rmtree(self.scratch)
self.state['cleanup'] = 'OK'
def preflight(self):
self.monitor = True
for executable in ('tar', 'gzip', 'zstd'):
if not shutil.which(executable):
raise TestError('Missing tool: ' + executable)
if any(label.startswith('db-') for label in self.args.expected):
self.check_image()
self.disk(force=True)
def check_image(self):
if hasattr(self, 'image_id'):
return
if not self.args.image or self.args.image.startswith('-') or any(c.isspace() for c in self.args.image):
raise TestError('Set RESTORE_TEST_DB_IMAGE to a locally prepared MariaDB image matching production')
raw = self.command(DOCKER + ['image', 'inspect', self.args.image], capture=128*1024)
info = json.loads(raw)[0]
volumes = info.get('Config', {}).get('Volumes') or {}
if set(volumes) - {'/var/lib/mysql'}:
raise TestError('Test image declares unexpected volumes')
# Store immutable local image ID: a tag change during a test cannot switch binaries.
self.image_id = info['Id']
def select(self):
base = self.args.remote.rstrip('/')
if self.args.run:
runs = [self.args.run]
else:
listing = self.remote('lsf', base, '--dirs-only', '--max-depth', '1', capture=1024*1024).decode()
runs = sorted((line.rstrip('/') for line in listing.splitlines()
if backup_set.RUN.fullmatch(line.rstrip('/')) and
line.startswith(self.args.prefix + '_')), reverse=True)
for run in runs:
if not backup_set.RUN.fullmatch(run) or not run.startswith(self.args.prefix + '_'):
raise TestError('Invalid remote run name')
files = self.remote('lsf', base + '/' + run, '--files-only', '--max-depth', '1', capture=1024*1024).decode().splitlines()
if 'COMPLETE' not in files:
if self.args.run:
raise TestError('Selected backup is incomplete')
self.log.info('Incomplete backup skipped: %s', run)
continue
self.state['run'] = run
self.save()
for name, maximum in [('manifest.json', 1024*1024), ('COMPLETE', 256)]:
data = self.remote('cat', base + '/' + run + '/' + name, capture=maximum)
(self.scratch / name).write_bytes(data)
manifest = backup_set.load_set(self.scratch, metadata_only=True)
if manifest['run_id'] != run:
raise TestError('Remote directory and manifest run differ')
age = time.time() - manifest['created_epoch']
self.state['backup_age_hours'] = round(age / 3600, 2)
if age < -300 or age > self.args.max_age_hours * 3600:
raise TestError('Backup is stale or dated in the future')
labels = {item['label'] for item in manifest['archives']}
if set(self.args.expected) - labels:
raise TestError('Backup is missing configured components')
allowed = {item['file'] for item in manifest['archives']} | {'manifest.json', 'COMPLETE', 'UPLOADED'}
if set(files) != allowed and set(files) != allowed - {'UPLOADED'}:
raise TestError('Remote backup has missing or unexpected files')
return manifest
raise TestError('No completed remote backup found')
def expand_size(self, archive):
# Full tar validation is performed first by the shared validator. A second
# streaming pass estimates logical size, including sparse files and metadata.
unpacked = 0
count = 0
proc = subprocess.Popen(['zstd' if archive.suffix == '.zst' else 'gzip', '-dc', '--', str(archive)],
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, start_new_session=True)
try:
self.remember_child(proc)
with tarfile.open(fileobj=proc.stdout, mode='r|') as tar:
for item in tar:
self.disk()
unpacked += ((item.size + 4095) // 4096) * 4096 + 4096
count += 1
while proc.stdout.read(1024*1024):
self.disk()
if proc.wait() != 0:
raise TestError('Archive decompression failed')
return unpacked, count
finally:
proc.stdout.close()
if proc.poll() is None:
proc.terminate()
proc.wait()
(self.root / 'child.json').unlink(missing_ok=True)
def database(self, sql):
self.check_image()
# No imported code can reach a host DB socket, production path or network.
data = self.scratch / 'database'
data.mkdir(mode=0o700)
(self.root / 'db-created').write_text(self.container + '\n')
startup = '''set -eu
mkdir -p /var/lib/mysql/tmp
mariadb-install-db --no-defaults --datadir=/var/lib/mysql/db --auth-root-authentication-method=normal --skip-test-db --user=root --tmpdir=/var/lib/mysql/tmp >/dev/null 2>&1
exec mariadbd --no-defaults --user=root --datadir=/var/lib/mysql/db --socket=/var/lib/mysql/mysql.sock --pid-file=/var/lib/mysql/mysql.pid --tmpdir=/var/lib/mysql/tmp --skip-networking --skip-log-bin --event-scheduler=OFF --local-infile=0 --innodb-buffer-pool-size=64M --innodb-log-file-size=16M --max-allowed-packet=64M
'''
self.command(DOCKER + ['run', '-d', '--pull=never', '--name', self.container,
'--label', f'{OWNER_LABEL}={self.owner}', '--network=none', '--read-only',
'--cap-drop=ALL', '--security-opt=no-new-privileges', '--user=0:0',
'--memory=512m', '--memory-swap=512m', '--cpus=1', '--pids-limit=128',
'--log-driver=none', '--no-healthcheck', '--volume', str(data) + ':/var/lib/mysql:Z',
'--entrypoint=/bin/sh', self.image_id, '-c', startup])
client = DOCKER + ['exec', '-i', self.container, 'mariadb', '--no-defaults',
'--socket=/var/lib/mysql/mysql.sock', '-uroot', '--batch', '--skip-column-names']
ready = time.monotonic() + 90
while True:
try:
self.command(client + ['--execute=SELECT 1'], capture=64, timeout=10)
break
except TestError:
self.disk()
if time.monotonic() > ready:
raise TestError('Isolated MariaDB did not become ready') from None
time.sleep(0.5)
self.command(client + ['--execute=CREATE DATABASE restore_test'])
with sql.open('rb') as stream:
# --binary-mode disables client commands in batch SQL (e.g. system/source).
self.command(client + ['--binary-mode', 'restore_test'], stdin=stream, timeout=self.args.timeout)
self.command(DOCKER + ['exec', self.container, 'mariadb-check', '--no-defaults',
'--socket=/var/lib/mysql/mysql.sock', '-uroot', '--all-databases'], timeout=self.args.timeout)
tables = self.command(client + ["--execute=SELECT COUNT(*) FROM information_schema.tables WHERE table_schema NOT IN ('mysql','information_schema','performance_schema','sys')"], capture=128).decode().strip()
return int(tables)
def execute(self):
self.preflight()
manifest = self.select()
self.log.info('Testing %s, %s components, sequentially', self.state['run'], len(manifest['archives']))
for item in manifest['archives']:
component = dict(label=item['label'], status='RUNNING', archive_bytes=item['size'])
self.state['components'].append(component)
self.save()
self.log.info('Component %s: download, verify, extract', item['label'])
self.disk(additional=item['size'], force=True)
archive = self.scratch / item['file']
with archive.open('wb') as output:
self.remote('cat', self.args.remote.rstrip('/') + '/' + self.state['run'] + '/' + item['file'],
output=output, limit=item['size'])
if archive.stat().st_size != item['size'] or backup_set.digest(archive) != item['sha256']:
raise TestError('Archive checksum/size mismatch: ' + item['label'])
# Run shared validation as a monitored child, so even corrupt streams
# and metadata-heavy archives cannot bypass the total timeout.
self.command([sys.executable, str(Path(__file__).with_name('backup_set.py')),
'tar', str(archive), item['root']], timeout=self.args.timeout)
unpacked, entries = self.expand_size(archive)
self.disk(additional=unpacked, force=True)
extracted = self.scratch / 'extracted'
extracted.mkdir(mode=0o700)
self.command(['tar', '--extract', '--file', str(archive), '--acls', '--xattrs', '--selinux',
'--numeric-owner', '--same-owner', '-C', str(extracted)], timeout=self.args.timeout)
# The compressed input is no longer needed; free it BEFORE DB import.
archive.unlink()
component.update(extracted_bytes=unpacked, entries=entries)
if item['label'].startswith('db-'):
sql = extracted / item['root']
if not sql.is_file() or sql.is_symlink() or not sql.stat().st_size:
raise TestError('SQL dump missing or empty')
self.disk(additional=512*1024*1024, force=True)
component['tables'] = self.database(sql)
self.monitor = False
self.cleanup()
self.scratch.mkdir(mode=0o700)
self.monitor = True
component['status'] = 'SUCCESS'
self.save()
self.log.info('Component %s: SUCCESS; files and test DB removed', item['label'])
self.state['status'] = 'SUCCESS'
def report(self):
fqdn = socket.getfqdn()
host = self.args.hostname or fqdn
state = self.state
message = EmailMessage(policy=SMTP)
message['From'] = self.args.sender or 'app-backup@' + fqdn
message['To'] = self.args.to
message['Subject'] = f"[app-restore-test] {state['status']} {host} {state['run']}"
message['Date'] = dt.datetime.now().astimezone()
body = ['Restore-Test', f'Host: {host}', f"Status: {state['status']}", f"Sicherung: {state['run']}",
f"Alter: {state.get('backup_age_hours', '?')} Stunden", f"Dauer: {state['duration_seconds']}s",
f"Bereinigung: {state['cleanup']}", f"Fehler: {state['error']}", '', 'Komponenten:']
for c in state['components']:
body.append(f"{c['label']}: {c['status']}; Archiv {c['archive_bytes']} Bytes; "
f"Einträge {c.get('entries', '?')}; Tabellen {c.get('tables', '-')}")
body.extend(['', 'Dateien entpackt; enthaltene DB-Dumps isoliert importiert und Tabellen geprüft.',
'Dies ist kein Start-/Funktionstest der produktiven Anwendungen.'])
message.set_content('\n'.join(body))
raw = message.as_bytes()
(self.root / 'report.eml').write_bytes(raw)
if self.args.mail == 'true':
if not self.args.to.strip() or not str(message['From']).strip():
raise TestError('Empty mail recipient or sender')
with io_bytes(raw) as stream:
self.command([self.args.sendmail, '-oi', '-t'], stdin=stream, timeout=self.args.mail_timeout)
def finish(self):
self.monitor = False
for sig in (signal.SIGINT, signal.SIGTERM):
signal.signal(sig, signal.SIG_IGN)
try:
self.cleanup()
except Exception as exc:
self.state.update(status='FAIL', cleanup='FAILED', error='Cleanup failed: ' + str(exc))
self.log.error('%s', self.state['error'])
for c in self.state['components']:
if c['status'] == 'RUNNING':
c['status'] = 'FAIL'
self.state['duration_seconds'] = max(0, int(time.time() - self.state['started_epoch']))
self.state['finished_epoch'] = int(time.time())
self.save()
if self.state['status'] == 'SUCCESS':
atomic_json(self.root / 'last-success.json', self.state)
try:
self.report()
self.state['mail'] = 'SENT' if self.args.mail == 'true' else 'DISABLED'
except Exception as exc:
self.state['mail'] = 'FAILED'
self.log.error('Restore-test report failed (%s); inspect state.json', type(exc).__name__)
self.save()
self.log.info('Restore-test %s; cleanup %s', self.state['status'], self.state['cleanup'])
return 0 if self.state['status'] == 'SUCCESS' and self.state['mail'] != 'FAILED' else 1
def io_bytes(raw):
# A real temporary FD for Popen stdin, unlinked immediately on creation.
import tempfile
stream = tempfile.TemporaryFile()
stream.write(raw)
stream.seek(0)
return stream
def main():
parser = argparse.ArgumentParser()
parser.add_argument('mode', choices=('run', 'check', 'cleanup'))
for key in ('workdir', 'prefix', 'remote', 'run', 'image', 'hostname', 'to', 'sender', 'sendmail', 'mail'):
parser.add_argument('--' + key, required=True)
for key in ('remote-check', 'config'):
parser.add_argument('--' + key)
parser.add_argument('--expected', action='append', default=[])
for key in ('max-age-hours', 'timeout', 'mail-timeout'):
parser.add_argument('--' + key, type=int, required=True)
for key in ('max-work-gb', 'min-free-gb'):
parser.add_argument('--' + key, type=float, required=True)
args = parser.parse_args()
if not (0 < args.max_work_gb <= 100000 and 0 <= args.min_free_gb <= 100000 and
1 <= args.max_age_hours <= 8760 and 1 <= args.timeout <= 86400 and 1 <= args.mail_timeout <= 300):
parser.error('Invalid restore-test limits')
if os.geteuid() != 0:
parser.error('Run as root via app-restore-test.sh')
test = Rehearsal(args)
if args.mode == 'cleanup' and not test.root.exists():
return 0
if args.mode == 'check':
if any((test.root / name).exists() for name in ('scratch', 'db-created', 'child.json')):
raise TestError('Interrupted test data found; run --cleanup before --check')
test.preflight()
test.log.info('Restore-test prerequisites and disk reserve OK; no image pulled or backup downloaded')
return 0
previous = test.root / 'state.json'
if previous.exists():
try:
old = json.loads(previous.read_text())
if not isinstance(old, dict) or not {'status', 'cleanup', 'started_epoch', 'components', 'run'} <= old.keys():
raise ValueError('Invalid state')
except (ValueError, OSError):
old = dict(test.state)
if old.get('status') == 'RUNNING' or old.get('cleanup') != 'OK':
test.state = old
test.state.update(status='FAIL', error='Previous restore test interrupted; recovery cleanup')
result = test.finish()
if args.mode == 'cleanup' or test.state['cleanup'] != 'OK':
return result
test = Rehearsal(args)
if args.mode == 'cleanup':
test.cleanup()
return 0
def interrupted(sig, _):
raise TestError('Interrupted by signal ' + str(sig))
for sig in (signal.SIGINT, signal.SIGTERM):
signal.signal(sig, interrupted)
try:
test.cleanup()
test.state['cleanup'] = 'PENDING'
test.save()
test.scratch.mkdir(mode=0o700)
test.execute()
except Exception as exc:
test.state.update(status='FAIL', error=str(exc))
test.log.error('Restore-test failed: %s', exc)
return test.finish()
if __name__ == '__main__':
os.umask(0o077)
try:
sys.exit(main())
except Exception as exc:
print(f'ERROR: restore-test {type(exc).__name__}: {exc}', file=sys.stderr)
sys.exit(1)