diff --git a/Dokumentation.md b/Dokumentation.md index 7c1b5f9..b1dfa19 100644 --- a/Dokumentation.md +++ b/Dokumentation.md @@ -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 diff --git a/README.md b/README.md index 47a5adc..b4bcde5 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/RESTORE_TEST.md b/RESTORE_TEST.md index 1e37fd4..ee3e30c 100644 --- a/RESTORE_TEST.md +++ b/RESTORE_TEST.md @@ -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. diff --git a/UPDATE_ANLEITUNG.md b/UPDATE_ANLEITUNG.md index 132907b..e32e5df 100644 --- a/UPDATE_ANLEITUNG.md +++ b/UPDATE_ANLEITUNG.md @@ -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 diff --git a/install.sh b/install.sh index 96e12b9..bdd52e3 100755 --- a/install.sh +++ b/install.sh @@ -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 diff --git a/lib/common.sh b/lib/common.sh index d18ef79..9ad21a0 100644 --- a/lib/common.sh +++ b/lib/common.sh @@ -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" diff --git a/lib/report.py b/lib/report.py index ddb5d23..15d6bf3 100644 --- a/lib/report.py +++ b/lib/report.py @@ -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) diff --git a/lib/restore_test.py b/lib/restore_test.py index 88a199e..46b5b09 100644 --- a/lib/restore_test.py +++ b/lib/restore_test.py @@ -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) diff --git a/lib/usage.py b/lib/usage.py new file mode 100644 index 0000000..b60eac4 --- /dev/null +++ b/lib/usage.py @@ -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 diff --git a/tests/test_backup.py b/tests/test_backup.py index 7168b2e..a9a877d 100644 --- a/tests/test_backup.py +++ b/tests/test_backup.py @@ -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')) diff --git a/tests/test_restore_test.py b/tests/test_restore_test.py index 3800be0..c6556c6 100644 --- a/tests/test_restore_test.py +++ b/tests/test_restore_test.py @@ -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') diff --git a/tools/build-update-package.py b/tools/build-update-package.py index 6b132f3..da21185 100644 --- a/tools/build-update-package.py +++ b/tools/build-update-package.py @@ -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'] diff --git a/usr-local-sbin/app-restore-test.sh b/usr-local-sbin/app-restore-test.sh index 02098d3..5b99d63 100755 --- a/usr-local-sbin/app-restore-test.sh +++ b/usr-local-sbin/app-restore-test.sh @@ -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"