From 72969605fd39a6079ab7a47047477208a7c19ddd Mon Sep 17 00:00:00 2001 From: Johannes Rest Date: Sun, 18 Oct 2026 17:07:27 +0200 Subject: [PATCH] Estimate repair storage needs live without relying on journal history --- SPEICHER.md | 17 ++++++-- operations/gemini_backup_repair.py | 68 ++++++++++++++++++++++++++---- tests/test_gemini_backup_repair.py | 6 +++ 3 files changed, 78 insertions(+), 13 deletions(-) diff --git a/SPEICHER.md b/SPEICHER.md index dfae66a..e34b0bf 100644 --- a/SPEICHER.md +++ b/SPEICHER.md @@ -204,8 +204,8 @@ nicht als automatische Löschkandidaten freigegeben. zusammen mit der ACME-Reparatur aus dem ai-rem-maintenance-Paket gestartet: ```bash -sudo bash /var/tmp/install-gemini-repair-20261018.sh -sudo journalctl -fu jr-gemini-repair-20261018.service --no-pager +sudo bash /var/tmp/resume-gemini-repair-20261018.sh +sudo journalctl -fu jr-gemini-repair-20261018-r2.service --no-pager ``` Der einmalige Hintergrundauftrag erfordert das Betreiberpasswort. Quellen, @@ -235,7 +235,7 @@ Die Speicheroperation erhält die bisherigen Archive wie folgt: Prüfungen mit der vorgeschlagenen Konfiguration und geerbter echter Sperre. - `MIN_FREE_GB`, Aufbewahrung und Schätzung bleiben unverändert. Auf `/` verbleiben mindestens die konfigurierte Reserve plus 2 GiB. Auf `/var` wird Platz für das - zuletzt beobachtete Snapshotbudget plus Reserve plus 10 GiB zusätzlichen + aktuell geschätzte Snapshotbudget plus Reserve plus 10 GiB zusätzlichen Spielraum geschaffen. Die aktuelle Live-Schätzung ist trotzdem maßgeblich. Danach wird der normale `app-backup.service` gestartet. Der erfolgreiche @@ -249,6 +249,15 @@ Konfiguration allein stellt verlagerten Bestand und freien Platz nicht zurück; kein blindes Zurückkopieren oder Löschen. Den Nachweis pro Objekt zur gezielten Wiederherstellung beziehungsweise Auswahl historischer Restore-Quellen nutzen. -Sieben lokale Tests prüfen Datei- und Verzeichnisverlagerung, Kopierkorruption +Acht lokale Tests prüfen Datei- und Verzeichnisverlagerung, Kopierkorruption mit erhaltenem Original, Schutz vor Quellenüberschneidung/Links und Platzreserve. Die produktive Abnahme und ein vollständiger Restore-Test stehen weiterhin aus. + +### Fortsetzung nach Certbot-Abbruch + +Der erste Auftrag vom 18.10.2026 scheiterte bei Certbot, bevor die +Speicheroperation begann. Revision 2 schätzt den aktuellen Bedarf lesend aus +rsync-Dateistatistiken mit denselben Excludes und aus DB-Größen. Dateimetadaten, +SQL-Ausdehnung und Wachstumsreserve entsprechen dem bestehenden Backup-Programm. +Ältere Journalzeilen werden dafür nicht benötigt; die vorhandenen echten +`--prepare-space`-/`--check`-Prüfungen bleiben die abschließende Voraussetzung. diff --git a/operations/gemini_backup_repair.py b/operations/gemini_backup_repair.py index b0e390e..0a2c2e0 100644 --- a/operations/gemini_backup_repair.py +++ b/operations/gemini_backup_repair.py @@ -178,6 +178,61 @@ printf '%s\\0' "$LOCKFILE" "$WORKDIR" "$STAGING_ROOT" "$ARCHIVE_DIR" "$MIN_FREE_ return fields[:6], fields[6:] +def snapshot_budget(file_stats, database_sizes): + total = 0 + for size, count in file_stats: + if size < 0 or count < 0: + raise ValueError('Invalid file statistics') + total += size + count * 8192 + for size in database_sizes: + if size < 0: + raise ValueError('Invalid database size') + total += size * 3 + 67108864 + return total * 12 // 10 + 67108864 + + +def estimate_budget(): + # Same dry-run exclusions, file metadata, SQL expansion and growth allowance + # as installed lib/space.sh. No cleanup, write, service stop or journal needed. + script = r'''set -e +LIB_DIR=/usr/local/lib/app-backup +source "$1" +CONFIG_FILE=$2 +load_config +build_plan +guard_workspaces +for i in "${!LABELS[@]}"; do + check_source_path "${SOURCES[i]}" + exclusions "${LABELS[i]}" + stats=$(rsync -aHAX --dry-run --stats --numeric-ids "${EXCLUDES[@]}" -- "${SOURCES[i]}/" "$STAGING_ROOT/.space-estimate/" 8>&- 9>&-) + bytes=$(awk '/^Total file size:/ {gsub(/,/, "", $4); print $4}' <<< "$stats") + count=$(awk '/^Number of files:/ {gsub(/,/, "", $4); print $4}' <<< "$stats") + [[ $bytes =~ ^[0-9]+$ && $count =~ ^[0-9]+$ ]] || exit 1 + printf 'F %s %s\n' "$bytes" "$count" +done +for i in "${!DB_NAMES[@]}"; do + secure_file "${DB_CNFS[i]}" + size=$("$DB_CLIENT_BIN" --defaults-extra-file="${DB_CNFS[i]}" --database="${DB_NAMES[i]}" --batch --skip-column-names --execute='SELECT COALESCE(SUM(data_length+index_length),0) FROM information_schema.tables WHERE table_schema=DATABASE()') + [[ $size =~ ^[0-9]+$ ]] || exit 1 + printf 'D %s\n' "$size" +done +''' + result = subprocess.run(['bash', '-c', script, 'estimate', str(COMMON), str(CONFIG)], + check=True, capture_output=True, text=True, pass_fds=(9,), timeout=900) + files, databases = [], [] + for line in result.stdout.splitlines(): + if re.fullmatch(r'F [0-9]+ [0-9]+', line): + _, size, count = line.split() + files.append((int(size), int(count))) + elif re.fullmatch(r'D [0-9]+', line): + databases.append(int(line.split()[1])) + else: + raise RuntimeError('Unexpected source estimation output') + if not files: + raise RuntimeError('No source file estimates') + return snapshot_budget(files, databases) + + def free(path): v = os.statvfs(path) return v.f_bavail * v.f_frsize @@ -233,15 +288,10 @@ def main(): private_dir(path) if STAGING.stat().st_dev == archives.stat().st_dev or PRESERVED.stat().st_dev == archives.stat().st_dev: raise SystemExit('Required separate filesystems are absent') - # The unchanged real estimator is run again below before activating config. - journal = subprocess.run(['journalctl', '-u', 'app-backup.service', '--since', '2026-10-18', - '--no-pager', '-o', 'cat'], check=True, capture_output=True, text=True) - budgets = re.findall(r'Estimated snapshot budget: ([0-9]+) bytes', journal.stdout) - if not budgets: - raise SystemExit('No observed snapshot budget; inspect before relocation') - budget = int(budgets[-1]) + budget = estimate_budget() + print('Live snapshot budget: ' + str(budget) + ' bytes', flush=True) if free(STAGING) < budget + reserve: - raise SystemExit('Staging filesystem lacks observed budget plus unchanged reserve') + raise SystemExit('Staging filesystem lacks live budget plus unchanged reserve') original = CONFIG.read_bytes() saved = STATE / 'app-backup.conf.before' if not saved.exists(): @@ -265,7 +315,7 @@ def main(): budget + reserve + 10 * GIB, reserve + 2 * GIB) json_atomic(STATE / 'relocation-plan.json', { 'staging': str(STAGING), 'active_archives': str(archives), - 'preservation_root': str(PRESERVED), 'observed_snapshot_budget': budget, + 'preservation_root': str(PRESERVED), 'live_snapshot_budget': budget, 'moves': [{'source': str(p), 'allocated_bytes': n} for p, n in moves]}) for src, amount in moves: if free(PRESERVED) < amount + reserve + 2 * GIB: diff --git a/tests/test_gemini_backup_repair.py b/tests/test_gemini_backup_repair.py index 1c75086..19da66b 100644 --- a/tests/test_gemini_backup_repair.py +++ b/tests/test_gemini_backup_repair.py @@ -10,6 +10,12 @@ m = importlib.util.module_from_spec(spec) spec.loader.exec_module(m) class PreservationTests(unittest.TestCase): + def test_live_budget_preserves_file_metadata_sql_and_growth_allowances(self): + raw=100 + 2*8192 + 200*3 + 67108864 + self.assertEqual(m.snapshot_budget([(100,2)],[200]),raw*12//10+67108864) + with self.assertRaises(ValueError):m.snapshot_budget([(-1,2)],[]) + with self.assertRaises(ValueError):m.snapshot_budget([],[-1]) + def test_plan_keeps_root_reserve_and_stops_when_var_target_is_met(self): self.assertEqual(m.choose_moves([('a', 48), ('b', 7), ('c', 7), ('d', 7)], 26, 77, 84, 12), [('a',48),('b',7),('c',7)]) with self.assertRaises(RuntimeError):