Add final runtime and disk usage summary to mail reports

This commit is contained in:
admin committed 2026-09-25 13:35:01 +02:00
1 parent a5a7e38853
commit be48ee85ef
13 files changed
+142 -9

No files matched your search

+1
View File
@@ -618,6 +618,7 @@ finish() {
--hostname="$MAIL_HOSTNAME" --subject-prefix "$MAIL_SUBJECT_PREFIX" --run "$RUN_ID" --exit-code "$code" \
--started "$RUN_STARTED_EPOCH" --phase "$BACKUP_PHASE" --upload-status "$UPLOAD_STATUS" \
--config "$CONFIG_FILE" --workdir "$WORKDIR" --archive-dir "$ARCHIVE_DIR" \
--staging-root "$STAGING_ROOT" --restore-root "$RESTORE_ROOT" --log-dir "$LOG_DIR" \
--compression "$COMPRESSOR" --remote "$RCLONE_REMOTE_BASE" --sendmail "$MAIL_SENDMAIL_BIN" \
--tail-lines "$MAIL_INCLUDE_LOG_TAIL_LINES" --timeout "$MAIL_SEND_TIMEOUT" 2>&1 | tee -a "$LOG_FILE"
rm -f -- "$report_sizes"
+5 -7
View File
@@ -16,6 +16,7 @@ import socket
import subprocess
import sys
import time
from usage import human, summary
def allocated(path):
@@ -45,13 +46,6 @@ def snapshot(output, staging, archives):
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):
fqdn = socket.getfqdn()
host = args.hostname or fqdn
@@ -91,6 +85,8 @@ def deliver(args):
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}'
@@ -117,6 +113,8 @@ def main():
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)
+5 -1
View File
@@ -25,6 +25,7 @@ import tarfile
import time
import backup_set
from usage import summary
GIB = 1024 ** 3
MARKER = 'app-backup-restore-test-v1\n'
@@ -530,6 +531,8 @@ exec mariadbd --no-defaults --user=root --datadir=/var/lib/mysql/db --socket=/va
f"Einträge {c.get('entries', '?')}; Tabellen {c.get('tables', '-')}; Phase {c.get('phase', '?')}")
body.extend(['', 'Dateien entpackt; enthaltene DB-Dumps isoliert importiert und Tabellen geprüft.',
'Dies ist kein Start-/Funktionstest der produktiven Anwendungen.'])
body.extend(summary(time.time() - self.started, self.args.workdir, self.args.archive_dir,
self.args.staging_root, self.args.restore_root, self.args.log_dir))
message.set_content('\n'.join(body))
raw = message.as_bytes()
(self.root / 'report.eml').write_bytes(raw)
@@ -579,7 +582,8 @@ def io_bytes(raw):
def main():
parser = argparse.ArgumentParser()
parser.add_argument('mode', choices=('run', 'check', 'cleanup', 'prepare'))
for key in ('workdir', 'prefix', 'remote', 'run', 'image', 'hostname', 'to', 'sender', 'sendmail', 'mail'):
for key in ('workdir', 'archive-dir', 'staging-root', 'restore-root', 'log-dir',
'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)
+72
View File
@@ -0,0 +1,72 @@
"""Best-effort, bounded disk usage summary for backup and restore mail reports."""
from pathlib import Path
import os
import subprocess
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 duration(seconds):
hours, remainder = divmod(max(0, int(seconds)), 3600)
minutes, seconds = divmod(remainder, 60)
return f'{hours}h {minutes:02d}m {seconds:02d}s'
def allocated(path):
"""Count allocated bytes without following symlinks or crossing filesystems."""
try:
result = subprocess.run(['du', '-sx', '-B1', '--', str(path)],
capture_output=True, text=True, timeout=30, check=True)
return int(result.stdout.split()[0])
except (OSError, ValueError, IndexError, subprocess.SubprocessError):
return None
def free_space(path):
try:
stat = os.statvfs(path)
return stat.f_bavail * stat.f_frsize
except OSError:
return None
def summary(seconds, workdir, archives, staging, restore, logs):
"""Measure the final local state, after temporary-file cleanup."""
lines = ['', 'Laufzeit und Speicher am Ende', '----------------------------',
f'Verarbeitung: {duration(seconds)} ({max(0, int(seconds))}s)']
paths = [('Arbeitsbereich gesamt', workdir), ('Backups (lokale Archive)', archives),
('Backup-Staging', staging), ('Restore-Arbeitsbereich', restore),
('Restore-Test', str(Path(workdir) / 'restore-test')), ('Protokolle und Berichte', logs)]
for label, name in paths:
if not name:
continue
path = Path(name)
amount = allocated(path) if path.exists() and not path.is_symlink() else None
lines.append(f'{label}: {human(amount) if amount is not None else "nicht verfügbar"} ({path})')
devices = set()
for label, name in [('Arbeitsbereich', workdir), ('Archive', archives),
('Staging', staging), ('Restore', restore), ('Restore-Test', str(Path(workdir) / 'restore-test')),
('Protokolle', logs)]:
if not name:
continue
try:
path = Path(name)
if path.is_symlink():
continue
device = path.stat().st_dev
except OSError:
continue
if device in devices:
continue
devices.add(device)
amount = free_space(path)
lines.append(f'Frei auf Datenträger {label}: '
f'{human(amount) if amount is not None else "nicht verfügbar"} ({path})')
lines.append('Teilbereiche sind im Arbeitsbereich enthalten; getrennte Datenträger werden separat gemessen.')
return lines