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

+12
View File
@@ -447,6 +447,18 @@ addieren. Es werden keine Live-Quellen erneut kopiert. Fehlende Messwerte heiße
unvollständig sein; Status und letzte Phase sind maßgeblich. Die Messung erfolgt
mit `du`, je Aufruf auf 30 Sekunden begrenzt, und verlängert nicht die Dienstpause.
Am Schluss von Backup- und Restore-Test-Mail steht eine aktuelle Platzübersicht
nach der Bereinigung. Sie enthält die gesamte Laufzeit, die belegten Bytes des
lokalen Arbeitsbereichs, der Archive, des Staging, des Restore-Arbeitsbereichs,
des Restore-Tests und der Protokolle/Berichte. Der freie Platz wird für jeden
betroffenen lokalen Datenträger einmal ermittelt, auch wenn Archive oder Logs
auf einer anderen Partition liegen. Teilbereiche sind im Arbeitsbereich
enthalten und dürfen nicht nochmals zu dessen Gesamtwert addiert werden. Die
Messung nutzt `du -sx` (belegte Blöcke, keine Symlink-Ziele oder eingehängte
Fremddatenträger); fehlende oder nicht rechtzeitig messbare Werte erscheinen als
„nicht verfügbar“. Remote-Cloud-Speicher und produktive Quelldaten sind nicht
Teil dieser lokalen Platzbilanz.
Vor dem Lesen des Log-Auszuges wird der Logger beendet und auf vollständiges
Schreiben gewartet. Mailheader werden über die Python-Mailbibliothek erzeugt;
eingeschleuste Header-Zeilenumbrüche werden abgewiesen. `sendmail -oi -t` schützt
+4
View File
@@ -257,6 +257,10 @@ Jeder beendete Backup-Lauf kann wieder einen ausführlichen UTF-8-Bericht senden
Status `SUCCESS`/`FAIL`, Host, Laufzeit, letzte Phase, Upload-/Prüfergebnis,
Staging-Größen, Archivgrößen und Protokollauszug. Der Betreff verwendet wieder
das Schema `[app-backup] SUCCESS/FAIL HOST LAUF`.
Am Ende stehen zusätzlich die gesamte Verarbeitungsdauer, die nach Bereinigung
belegten lokalen Arbeitsbereiche (Archive, Staging, Restore und Restore-Test),
Protokolle sowie der freie Platz auf den zugehörigen Datenträgern. Der monatliche
Restore-Test erhält dieselbe Schlussübersicht.
In `/etc/app-backup/app-backup.conf` werden vorhandene Einstellungen weiterverwendet:
+4
View File
@@ -91,6 +91,10 @@ alte Update-Ergebnis auf SUCCESS und aktiviert keine Update-Timer.
## Speicherbedarf und Bereinigung
Der Mailbericht endet mit Laufzeit, belegtem lokalen Platz für Backups,
Arbeitsbereiche, Restore-Test und Protokolle sowie freiem Platz auf den
zugehörigen Datenträgern. Die Werte werden nach der Bereinigung gemessen.
Wegen des knappen Speichers auf Gemini läuft immer nur **eine Komponente**:
1. Archiv herunterladen; Größe und SHA-256 gegen das Manifest prüfen.
+23
View File
@@ -1,5 +1,28 @@
# Automatisches Update-Paket
## Update vom 25.09.2026: Laufzeit und Speicher im Mailbericht
Backup- und monatlicher Restore-Test-Mailbericht enden jetzt mit einer Übersicht
über die gesamte Verarbeitungsdauer, belegten lokalen Platz für Arbeitsbereich,
Archive, Staging, Restore, Restore-Test und Protokolle sowie freien Platz auf
jedem betroffenen Datenträger. Die Messung erfolgt nach der Bereinigung; eine
fehlgeschlagene Einzelmessung erscheint als „nicht verfügbar“. Die bisherigen
Staging- und Archivgrößen vor der Bereinigung bleiben im Backup-Bericht erhalten.
Der Report liest keine produktiven Quelldaten und ermittelt keinen belegten
Cloud-Speicher.
Das `.run`-Paket wird wie im Abschnitt
[Übertragen und starten](#übertragen-und-starten) per SCP kopiert und
installiert. Bestehende Mail-Einstellungen bleiben erhalten; neue
Konfigurationswerte sind nicht nötig. Die Mail des Abnahmebackups enthält die
neue Übersicht. Der Restore-Test erhält sie bei seinem nächsten Lauf.
Lokal bestanden ShellCheck, Syntaxprüfung und die vollständige isolierte Suite:
137 Tests, davon acht planmäßig übersprungen. Die Suite verwendet echte Archive,
eine isolierte MariaDB und lokale rclone-Crypt-Ziele. Die produktive Installation
und die Messwerte auf Gemini müssen nach dem Update anhand der Mail geprüft
werden.
## Update vom 21.09.2026: Speicherblockade auflösen
Das Paket enthält die unten beschriebenen Änderungen vom 16.09. und die neue
+1
View File
@@ -70,6 +70,7 @@ add lib/space.py /usr/local/lib/app-backup/space.py 640
add lib/prepare-restore-test.sh /usr/local/lib/app-backup/prepare-restore-test.sh 640
add lib/backup_set.py /usr/local/lib/app-backup/backup_set.py 640
add lib/report.py /usr/local/lib/app-backup/report.py 640
add lib/usage.py /usr/local/lib/app-backup/usage.py 640
add systemd/app-backup.service /etc/systemd/system/app-backup.service 644
# Preserve existing schedules. The new timer template remains available as .dist.
if [[ -e /etc/systemd/system/app-backup.timer ]]; then
+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
+8
View File
@@ -787,6 +787,7 @@ else: raise SystemExit('Unexpected Docker command')
backup_message=BytesParser(policy=policy.default).parsebytes(saved.read_bytes())
self.assertIn('OK (Dateien vorhanden, Größen und Abschlussmarker',backup_message.get_content())
self.assertEqual(Path('/usr/local/lib/app-backup/report.py').read_bytes(),(REPO/'lib/report.py').read_bytes())
self.assertEqual(Path('/usr/local/lib/app-backup/usage.py').read_bytes(),(REPO/'lib/usage.py').read_bytes())
self.assertEqual(Path('/usr/local/lib/app-backup/restore_test.py').read_bytes(),(REPO/'lib/restore_test.py').read_bytes())
self.assertTrue(Path('/etc/systemd/system/app-restore-test.timer').is_file())
self.assertEqual(json.loads(self.state.read_text())['services']['app-restore-test.timer'],'active')
@@ -1234,6 +1235,13 @@ REPORT_SECRET='config-content-must-not-enter-the-mail'
'Deaktiviert (nur lokal)',f'SUCCESS: {run.name}']:
self.assertIn(value,body)
self.assertNotIn('config-content-must-not-enter-the-mail',body)
summary=body.split('Laufzeit und Speicher am Ende',1)[1]
self.assertIn('Verarbeitung:',summary)
self.assertIn('Arbeitsbereich gesamt:',summary)
self.assertIn('Backups (lokale Archive):',summary)
self.assertIn('Restore-Test:',summary)
self.assertIn('Frei auf Datenträger Arbeitsbereich:',summary)
self.assertRegex(summary,r'Backups \(lokale Archive\): (?!nicht verfügbar)')
self.assertFalse(list((self.base/'work/staging').iterdir()))
self.assertEqual((self.base/'mail-args').read_text(),'-oi -t')
saved=next((self.base/'logs').glob('report_*.eml'))
+5
View File
@@ -135,6 +135,11 @@ MAIL_SENDMAIL_BIN="{self.sendmail}"
mail = BytesParser(policy=policy.default).parsebytes((self.base/'mail.eml').read_bytes())
self.assertIn('[app-restore-test] SUCCESS gemini.jr-it-services.de',str(mail['Subject']))
self.assertIn('Bereinigung: OK',mail.get_content())
summary=mail.get_content().split('Laufzeit und Speicher am Ende',1)[1]
self.assertIn('Verarbeitung:',summary)
self.assertIn('Restore-Test:',summary)
self.assertIn('Frei auf Datenträger Arbeitsbereich:',summary)
self.assertRegex(summary,r'Restore-Test: (?!nicht verfügbar)')
self.assertNotIn('expected data',mail.get_content())
self.assertEqual((self.root/'last-success.json').stat().st_mode & 0o777,0o600)
self.execute('--cleanup')
+1 -1
View File
@@ -11,7 +11,7 @@ import tarfile
ROOT = Path(__file__).resolve().parents[1]
FILES = ['install.sh', 'update.sh', 'README.md', 'Dokumentation.md', 'UPDATE_ANLEITUNG.md',
'lib/common.sh', 'lib/locking.sh', 'lib/backup_set.py', 'lib/report.py', 'lib/update-discover.py',
'lib/common.sh', 'lib/locking.sh', 'lib/backup_set.py', 'lib/report.py', 'lib/usage.py', 'lib/update-discover.py',
'usr-local-sbin/app-backup.sh', 'usr-local-sbin/app-restore.sh',
'systemd/app-backup.service', 'systemd/app-backup.timer', 'logrotate/app-backup',
'etc/app-backup/app-backup.conf']
+1
View File
@@ -28,6 +28,7 @@ guard_workspaces
source "$LIB_DIR/locking.sh"
acquire_backup_lock 75 || exit $?
args=("$MODE" --workdir "$WORKDIR" --prefix "$ARCHIVE_PREFIX" --remote "$RCLONE_REMOTE_BASE"
--archive-dir "$ARCHIVE_DIR" --staging-root "$STAGING_ROOT" --restore-root "$RESTORE_ROOT" --log-dir "$LOG_DIR"
--run "$REMOTE_RUN" --max-age-hours "$RESTORE_TEST_MAX_AGE_HOURS"
--max-work-gb "$RESTORE_TEST_MAX_WORK_GB" --min-free-gb "$RESTORE_TEST_MIN_FREE_GB"
--timeout "$RESTORE_TEST_TIMEOUT" --image "$RESTORE_TEST_DB_IMAGE"