140 lines
6.5 KiB
Python
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)
|