Add final runtime and disk usage summary to mail reports
This commit is contained in:
1 parent
a5a7e38853
commit
be48ee85ef
13 files changed
+142
-9
No files matched your search
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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'))
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user