Restore detailed backup mail reports with bounded local delivery

This commit is contained in:
admin committed 2026-09-10 16:45:18 +02:00
1 parent 3374019ad6
commit 99da6460d2
10 files changed
+396 -13

No files matched your search

+136
View File
@@ -0,0 +1,136 @@
#!/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
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 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 human(value):
for unit in ('B', 'KiB', 'MiB', 'GiB', 'TiB'):
if value < 1024 or unit == 'TiB':
return f'{value:.1f} {unit}'
value /= 1024
def deliver(args):
host = socket.getfqdn()
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: Rückleseprüfung',
'MARKER': 'Nicht abgeschlossen: Abschlussmarker',
'VERIFIED': 'OK (Inhalt und Abschlussmarker zurückgelesen)'}[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])
message = EmailMessage(policy=SMTP)
# EmailMessage rejects CR/LF in headers and encodes non-ASCII safely.
sender = args.sender or f'app-backup@{host}'
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')
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)