Files

140 lines
6.5 KiB
Python

#!/usr/bin/env python3
"""Build private UTF-8 backup reports; never read executable config/credentials.
Sizes are captured before staging cleanup. Delivery uses only the local MTA and
has a timeout; a reporting failure never turns a failed backup into a success.
"""
import argparse
from collections import deque
import datetime as dt
from email.message import EmailMessage
from email.policy import SMTP
import json
import os
from pathlib import Path
import socket
import subprocess
import sys
import time
from usage import human, summary
def allocated(path):
"""Report disk allocation, like du in the old report, without following links."""
result = subprocess.run(['du', '-s', '-B1', '--', str(path)],
capture_output=True, text=True, timeout=30, check=True)
return int(result.stdout.split()[0])
def snapshot(output, staging, archives):
data = {'staged': {}, 'archives': {}}
if Path(output).is_file():
data.update(json.loads(Path(output).read_text()))
captured = 'staging_total' in data
if not captured and staging and Path(staging).is_dir():
root = Path(staging)
# Measure components independently; the total accounts for shared links.
paths = sorted((root/'files').glob('*')) + [root/'db', root/'meta']
for path in paths:
if path.exists():
data['staged'][path.name] = allocated(path)
data['staging_total'] = allocated(root)
if archives and Path(archives).is_dir():
for path in sorted(Path(archives).glob('*.tar.*')):
if path.is_file() and not path.is_symlink():
data['archives'][path.name] = path.stat().st_size
Path(output).write_text(json.dumps(data), encoding='utf-8')
def deliver(args):
fqdn = socket.getfqdn()
host = args.hostname or fqdn
now = dt.datetime.now().astimezone()
state = 'SUCCESS' if args.exit_code == 0 else 'FAIL'
try:
sizes = json.loads(Path(args.sizes).read_text())
except (OSError, ValueError):
sizes = {}
# Bounded memory even for long-running rclone logs. Tail length is configurable.
with open(args.log, encoding='utf-8', errors='replace') as stream:
tail = ''.join(deque(stream, maxlen=args.tail_lines))
upload = {'NOT_STARTED': 'Nicht gestartet', 'DISABLED': 'Deaktiviert (nur lokal)',
'COPY': 'Nicht abgeschlossen: Upload', 'CHECK': 'Nicht abgeschlossen: Dateiprüfung',
'MARKER': 'Nicht abgeschlossen: Abschlussmarker',
'VERIFIED': 'OK (Dateien vorhanden, Größen und Abschlussmarker geprüft; Inhaltsprüfung beim Restore-Test)'}[args.upload_status]
body = [
'Backup Report', '=============', f'Zeit: {now.isoformat(timespec="seconds")}',
f'Host: {host}', f'Status: {state}', f'Exit code: {args.exit_code}',
f'Dauer: {max(0, int(time.time()) - args.started)}s', f'Lauf: {args.run}',
f'Letzte Phase: {args.phase}', '', f'Config: {args.config}', f'Log: {args.log}',
f'Workdir: {args.workdir}', f'Archive dir: {args.archive_dir}',
f'Kompression: {args.compression}', '', 'Remote', '------',
f'Upload: {str(args.upload_status != "DISABLED").lower()}',
f'Remote base: {args.remote or "nicht konfiguriert"}', f'Upload Status: {upload}',
'Die separate Update-Abnahme steht in result.txt; dies ist der Backup-Bericht.',
'', 'Größen', '------', 'Staging: belegter Speicher vor Bereinigung; Archive: Dateigrößen.',
]
for label, value in sizes.get('staged', {}).items():
body.append(f'{label} staged: {human(value)}')
body.append('Staging total: '+(human(sizes['staging_total']) if 'staging_total' in sizes else 'nicht verfügbar'))
body.extend(['', 'Archive', '-------'])
for name, value in sizes.get('archives', {}).items():
body.append(f'{name}: {human(value)} ({value} Bytes)')
if not sizes.get('archives'):
body.append('Keine Archive erfasst.')
else:
body.append('Archive total: '+human(sum(sizes['archives'].values())))
body.extend(['', f'Log-Auszug (letzte {args.tail_lines} Zeilen)', '-----------------', tail])
body.extend(summary(time.time() - args.started, args.workdir, args.archive_dir,
args.staging_root, args.restore_root, args.log_dir))
message = EmailMessage(policy=SMTP)
# EmailMessage rejects CR/LF in headers and encodes non-ASCII safely.
sender = args.sender or f'app-backup@{fqdn}'
if not args.to.strip() or not sender.strip():
raise ValueError('Mail recipient and sender must not be empty')
message['From'] = sender
message['To'] = args.to
message['Subject'] = f'{args.subject_prefix} {state} {host} {args.run}'
message['Date'] = now
message.set_content('\n'.join(body), charset='utf-8')
raw = message.as_bytes()
Path(args.output).write_bytes(raw)
# -oi prevents a single dot in a log line from terminating the message.
# Headers supply recipients; no shell or SMTP credentials are involved.
subprocess.run([args.sendmail, '-oi', '-t'], input=raw, check=True, timeout=args.timeout)
print(f'Mail report handed to local MTA: {args.output}')
def main():
parser = argparse.ArgumentParser()
commands = parser.add_subparsers(dest='command', required=True)
snap = commands.add_parser('snapshot')
for name in ('output', 'staging', 'archives'):
snap.add_argument(name)
mail = commands.add_parser('deliver')
mail.add_argument('--hostname', default='')
for name in ('staging-root', 'restore-root', 'log-dir'):
mail.add_argument('--'+name, required=True)
for name in ('sizes', 'log', 'output', 'to', 'sender', 'subject-prefix', 'run', 'phase',
'upload-status', 'config', 'workdir', 'archive-dir', 'compression', 'remote', 'sendmail'):
mail.add_argument('--'+name, required=True)
for name in ('exit-code', 'started', 'tail-lines', 'timeout'):
mail.add_argument('--'+name, type=int, required=True)
args = parser.parse_args()
if args.command == 'snapshot':
snapshot(args.output, args.staging, args.archives)
else:
if not 0 <= args.tail_lines <= 10000 or not 1 <= args.timeout <= 300:
raise ValueError('Invalid mail tail length or timeout')
deliver(args)
if __name__ == '__main__':
os.umask(0o077)
try:
main()
except Exception as exc:
# Do not echo header values or MTA arguments which might include addresses.
print(f'WARNING: mail report failed ({type(exc).__name__}); inspect local report/MTA.', file=sys.stderr)
sys.exit(1)