Protect dedicated staging disk and document verified migration
This commit is contained in:
1 parent
5ab3d078fb
commit
46644e5041
7 files changed
+158
-4
No files matched your search
@@ -1,5 +1,13 @@
|
||||
# Änderungen
|
||||
|
||||
## 2.0.0, Runner-Revision 4 — 2026-10-27
|
||||
|
||||
- Optionaler fester Mount-/UUID-Schutz für den Zwischenstand auf einer zweiten lokalen Platte, vor jeder Verzeichnisanlage und Synchronisierung.
|
||||
- Echter Staging-Pfad im bestätigten Sicherungsstand für die Abnahme von Umzügen.
|
||||
- Drei zusätzliche Tests für fehlende/falsche Datenträger und konsistente Konfiguration.
|
||||
- Dokumentierter Umzug mit geprüftem Originalbestand und Home-Symlink für den manuellen Zugriff.
|
||||
- Produktiv am 27.10.2026 mit neuem NAS-Archiv, vollständiger Datenprüfung, Repository-Check und wieder aktiven Timern abgenommen.
|
||||
|
||||
## 2.0.0 — 2026-10-26
|
||||
|
||||
- Sicherung direkt aus dem rclone-Remote statt aus dem verzögert gestarteten Desktop-FUSE-Mount.
|
||||
|
||||
+65
-2
@@ -31,7 +31,7 @@ Die Cloud ist immer die Quelle. Keine Uploads oder Löschungen in Nextcloud. Nur
|
||||
| `~/.config/nextcloud-borg-backup/passphrase` | Bestehende Borg-Passphrase, niemals regenerieren |
|
||||
| `~/.local/share/nextcloud-borg-backup/staging/` | Unverschlüsselter, privater Cloud-Zwischenstand |
|
||||
| `~/.local/share/nextcloud-borg-backup/staging.owner.json` | Bindung des Zwischenstands an die Cloud-Quelle |
|
||||
| `~/.local/state/nextcloud-borg-backup/last-success.json` | Zeitpunkt, Archivname, Dateizahl und Bytezahl der bestätigten Sicherung |
|
||||
| `~/.local/state/nextcloud-borg-backup/last-success.json` | Zeitpunkt, Archivname, Dateizahl, Bytezahl und tatsächlicher Staging-Pfad der bestätigten Sicherung |
|
||||
| `~/.local/state/nextcloud-borg-backup/last-attempt.json` | Laufstatus und Fehlerphase |
|
||||
| `~/.local/state/nextcloud-borg-backup/repository.json` | Bindung an die Borg-Repository-ID |
|
||||
| `~/.local/state/nextcloud-borg-backup/verified-inventory.json` | Zuletzt geprüfte Datei-Inventur mit Größen, Zeiten und Serverhashes |
|
||||
@@ -44,6 +44,7 @@ Vollständige Kindprozessausgaben stehen im systemd-Journal; die zusätzlichen A
|
||||
- Ausschließlich ein Lauf dank `flock`; auch manuelle Wartungsbefehle verwenden denselben Lock. Borg besitzt zusätzlich seinen eigenen Repository-Lock. Fremde Borg-Prozesse werden nicht durch automatisches `break-lock` übergangen.
|
||||
- Der NFS-Share muss exakt `EXPECTED_NAS_SOURCE` mit `EXPECTED_NAS_FSTYPE` entsprechen. Für die Workstation: `thor:/volume1/NetBackup`, `nfs4`. Autofs wird vor der Prüfung durch einen begrenzten Zugriff ausgelöst. Backup legt keine Verzeichnisse unter `/net` und kein Repository an.
|
||||
- Erste Quell-Plausibilisierung gegen das jüngste vorhandene Borg-Archiv; danach gegen den letzten bestätigten Stand. Rückgang von Dateizahl **oder** Bytezahl um mehr als `MAX_SOURCE_DROP_PERCENT=20` führt zum Abbruch vor der Synchronisierung. Leere Quelle wird immer abgewiesen.
|
||||
- Bei konfiguriertem `EXPECTED_STAGE_MOUNT`/`EXPECTED_STAGE_UUID` werden der exakte lokale Mount und seine UUID vor der Anlage des Zwischenstands geprüft; ein fehlender oder fremder Datenträger führt zum Abbruch. Beide Werte müssen zusammen gesetzt werden.
|
||||
- Staging muss lokal sein, darf keine Symlinks/Mounts enthalten und darf sich nicht mit State, Repository oder `~/Nextcloud` überschneiden. Ein bereits gefülltes, fremdes Verzeichnis wird nicht übernommen.
|
||||
- Hashvergleich, wenn jede Quelldatei MD5/SHA1 anbietet. Dateien ohne Serverhash werden vor dem Sync mit `rclone copy --ignore-times` erneut gelesen und anschließend zusätzlich per `rclone check --download` verglichen. Leere Dateien sind bereits durch Größe null eindeutig. Bei Zeilenumbrüchen in ungehashten Dateinamen erfolgt ein vollständiger Bytevergleich aller Dateien, weil rclones Dateiliste solche Namen nicht darstellen kann. Das kostet zusätzlich Übertragungszeit, vermeidet aber den unsicheren Rückfall auf reine Größenvergleiche.
|
||||
- Zusätzlich berechnet Python die lokalen SHA1-/MD5-Werte unabhängig von rclone und vergleicht jede verfügbare Cloud-Prüfsumme sowie die Dateigrößen.
|
||||
@@ -106,7 +107,7 @@ Passphrase und exportierten Schlüssel unabhängig vom NAS und der Workstation s
|
||||
|
||||
`make check` prüft Bash-Syntax, Python, alle vier systemd-Units und automatisierte Ausfallszenarien. Integrationstests verwenden echtes rclone mit lokalem Test-Remote sowie echtes Borg in temporären Verzeichnissen: Archiv erstellen, Daten prüfen, extrahieren und Bytevergleich. Sie verändern weder echte Cloud noch produktives Repository.
|
||||
|
||||
18 Tests prüfen außerdem leere/geschrumpfte Quelle, falsches NAS, fremdes/symlinkendes Staging, Netzfehler in Sync und Check, geänderte Cloud-Inventur, Repository-Austausch, konkurrierende Läufe, Timeout, Zeitrücksprung und Wartungswiederholung. Gleiche Dateigröße und gleicher Zeitstempel bei geändertem Inhalt werden im Integrationstest ausdrücklich abgedeckt.
|
||||
21 Tests prüfen außerdem leere/geschrumpfte Quelle, falsches NAS, fremdes/symlinkendes Staging, Netzfehler in Sync und Check, geänderte Cloud-Inventur, Repository-Austausch, konkurrierende Läufe, Timeout, Zeitrücksprung und Wartungswiederholung. Gleiche Dateigröße und gleicher Zeitstempel bei geändertem Inhalt werden im Integrationstest ausdrücklich abgedeckt.
|
||||
|
||||
Der Installer erhält private Konfiguration und Schlüssel, hält vorhandenen Backup-Dienst und Timer gemeinsam an, ersetzt Programme und Units atomar und startet die Timer neu. Der Update-Test prüft die Erhaltung von Konfiguration und Schlüssel sowie den Timer-Stopp. Eine fehlende Passphrase bei vorhandenem Repository führt zum Abbruch. Die Deinstallation stoppt Backup/Timer und entfernt Programme/Units; Daten, Schlüssel, State und Archive bleiben erhalten.
|
||||
|
||||
@@ -151,3 +152,65 @@ Der vollständige Lauf am 26.10.2026 dauerte von 12:26:38 bis 13:01:15 Uhr MEZ u
|
||||
Die Wiederherstellungsprobe ist eine Stichprobe von drei Dateien; die Borg-Datenprüfung erfasst den vollständigen neuen Archivstand. Die extrahierten Proben und deren Hash-Nachweis liegen privat unter `~/.local/state/nextcloud-borg-backup/restore-proof-20261026/` bzw. `restore-proof.json`. Dateinamen und private Inhalte wurden nicht in Git übernommen.
|
||||
|
||||
Nach dieser Abnahme wurde der endgültige Installer nochmals produktiv ausgeführt. Konfiguration und Passphrase blieben erhalten, installierte Programme entsprechen dem Repository. Die eigene rclone-Konfiguration wurde auf Dateirechte 0600 beschränkt. Der Zwischenstand belegt etwa 36 GiB; anschließend waren auf dem Home-Dateisystem noch etwa 124 GiB frei.
|
||||
|
||||
|
||||
## Zwischenstand auf der Datenplatte ab 27.10.2026
|
||||
|
||||
Die Workstation verwendet nun den tatsächlichen Pfad
|
||||
`/mnt/data/johannes/nextcloud-borg-backup/staging`. Der bisherige Home-Pfad
|
||||
`~/.local/share/nextcloud-borg-backup/staging` und der zusätzliche sichtbare
|
||||
Zugang `~/Nextcloud-Zwischenstand` sind Symlinks für den manuellen Zugriff.
|
||||
Auch `staging.owner.json` ist vom bisherigen Home-Ort auf die tatsächliche
|
||||
Owner-Datei auf der Datenplatte verlinkt. Im Backup ist ausschließlich der echte
|
||||
Pfad konfiguriert; die bestehende Ablehnung symlinkender Staging-Pfade bleibt aktiv.
|
||||
|
||||
`EXPECTED_STAGE_MOUNT=/mnt/data` und die fest eingetragene UUID der Datenplatte
|
||||
werden vor dem Anlegen des Zwischenstands und vor dem Sync geprüft. Fehlt die
|
||||
Platte oder liegt ein fremdes Dateisystem unter `/mnt/data`, bricht der Lauf ab,
|
||||
statt die Systemplatte mit einem neu angelegten Zwischenstand zu belegen.
|
||||
Die UUID wird nicht bei jedem Lauf dynamisch neu übernommen. Die Konfiguration
|
||||
ist privat; auf anderen Rechnern den tatsächlichen Mount und dessen UUID bewusst
|
||||
hinterlegen oder beide optionalen Schutzwerte leer lassen.
|
||||
|
||||
Der Umzug wurde bei angehaltenen Timern und mit Backup-Lock durchgeführt.
|
||||
8.218 Dateien mit exakt 38.274.641.627 Bytes wurden kopiert, Quelldateien mit
|
||||
SHA-256 geprüft und neue Datenkopien unabhängig zurückgelesen. Identische Dateien
|
||||
sind auf Btrfs unabhängige Reflinks. Das unveränderte Original blieb nach der
|
||||
Symlink-Umschaltung zunächst erhalten. Die endgültige Freigabe zum Entfernen
|
||||
dieser alten lokalen Kopie ist an eine neue vollständige Sicherung am neuen
|
||||
Pfad gebunden. Schlüssel, NAS-Repository und dessen Identitätsbindung wurden
|
||||
nicht verlagert.
|
||||
|
||||
Die Programme sind gezielt installiert, ohne den normalen Installer während der
|
||||
Migration aufzurufen. Runner-Revision 4 und Wrapper entsprechen den
|
||||
Repository-Dateien. `make check` besteht mit 21 Tests, einschließlich echter
|
||||
lokaler rclone-/Borg-Sicherung und Restore sowie fehlendem/falschem Datenträger.
|
||||
Die drei neuen Mount-Tests prüfen insbesondere, dass vor einem Fehler kein
|
||||
Staging-Verzeichnis angelegt wird.
|
||||
|
||||
|
||||
### Produktive Umzugsabnahme
|
||||
|
||||
Am 27.10.2026 wurde vom neuen tatsächlichen Staging-Pfad eine vollständige
|
||||
Sicherung ausgeführt. Der Lauf von 18:05:41 bis 19:26:28 Uhr MEZ endete mit
|
||||
Status 0 und erfolgreicher Wartung:
|
||||
|
||||
| Prüfung | Ergebnis |
|
||||
| --- | --- |
|
||||
| Neues NAS-Archiv | `nextcloud-2026-10-27_18-50-45` |
|
||||
| Bestand | 8.218 Dateien, 38.274.641.627 Originalbytes |
|
||||
| Cloud-Abgleich und unabhängige lokale Hashes | Erfolgreich; 230 Dateien ohne geeigneten Serverhash zusätzlich per Download verglichen |
|
||||
| Wechselnde Hash-Metadaten | 29 Dateien zusätzlich inhaltlich geprüft, keine Inhaltsabweichung |
|
||||
| Archiv-Datenprüfung | `borg check --archives-only --verify-data --glob-archives <neues Archiv>` erfolgreich |
|
||||
| Repository und Wartung | Repository-Check, Retention und Compact erfolgreich |
|
||||
| Ursprünglicher Zwischenstand | Erst nach erfolgreichem neuen Backup entfernt |
|
||||
| Automatik und Zustand | Beide Timer wieder `active/waiting`, `health` meldet `healthy: true` |
|
||||
|
||||
Die private Ausgangskonfiguration, bisherigen Programme, Owner-Datei und
|
||||
Umzugs-/Prüfberichte sind unter
|
||||
`~/.local/state/archwork-storage-20261027/` gesichert. Keine dieser privaten
|
||||
Dateien gehört ins Repository. Die vorherige Konfiguration darf nicht blind
|
||||
zurückkopiert werden: Der alte Home-Pfad ist jetzt ein Symlink und wäre als
|
||||
Backup-Arbeitsverzeichnis absichtlich unzulässig. Für einen erneuten Umzug
|
||||
wieder den tatsächlichen Zielpfad konfigurieren, Owner-Datei und feste
|
||||
Datenträgeridentität passend setzen und vor Entfernen der alten Daten prüfen.
|
||||
@@ -31,6 +31,34 @@ Bestehende Konfiguration, Passphrase und Historie bleiben erhalten. Neue Einstel
|
||||
|
||||
Der lokale Zwischenstand benötigt ungefähr die Datenmenge der Cloud. Zusätzlich fordert der konservative Speichercheck vor jedem Lauf noch einmal diese Datenmenge plus 5 GiB freien Speicher. Ein Zwischenstand gehört auf einen privaten lokalen Datenträger, niemals auf den Desktop-Mount.
|
||||
|
||||
## Zwischenstand auf einer zweiten lokalen Platte
|
||||
|
||||
`STAGING_DIR` muss der echte absolute Verzeichnispfad sein. Ein Home-Symlink
|
||||
kann für den manuellen Zugriff daneben bestehen, darf aber nicht als
|
||||
Backup-Arbeitsverzeichnis konfiguriert werden. Für eine separate Datenplatte
|
||||
beide Schutzwerte fest hinterlegen:
|
||||
|
||||
```bash
|
||||
STAGING_DIR="/mnt/data/johannes/nextcloud-borg-backup/staging"
|
||||
EXPECTED_STAGE_MOUNT="/mnt/data"
|
||||
EXPECTED_STAGE_UUID="UUID-DER-DATENPLATTE"
|
||||
```
|
||||
|
||||
Die UUID einmal mit `findmnt -no UUID --mountpoint /mnt/data` auslesen und
|
||||
als festen Wert eintragen. Nicht bei jedem Lauf dynamisch neu ableiten.
|
||||
Die Prüfung lehnt einen fehlenden Mount oder anderen Datenträger vor dem
|
||||
Anlegen des Zwischenstands und vor dem Sync ab. Ohne beide Optionen bleibt
|
||||
der bisherige Betrieb im Home-Verzeichnis kompatibel.
|
||||
|
||||
Vor einem Umzug Timer anhalten, laufende Sicherung beenden lassen und den
|
||||
Backup-Lock übernehmen. Daten kopieren und Inhalte/Metadaten prüfen; Original
|
||||
zunächst behalten. `staging.owner.json` am neuen Ort muss denselben Remote und
|
||||
den neuen tatsächlichen Pfad enthalten. Erst dann Konfiguration umstellen,
|
||||
optional am ursprünglichen Home-Pfad einen Symlink anlegen und eine vollständige
|
||||
Sicherung prüfen. Die alte Kopie erst nach erfolgreicher Abnahme entfernen;
|
||||
danach Timer wieder einschalten. Schlüssel, Borg-Repository und Historie bleiben
|
||||
an ihren bisherigen Orten.
|
||||
|
||||
## Betrieb und Wiederherstellung
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
# Trusted Bash configuration; never commit credentials or passphrases.
|
||||
REMOTE="Nextcloud:"
|
||||
STAGING_DIR="${HOME}/.local/share/nextcloud-borg-backup/staging"
|
||||
# Optional dedicated local disk: set both values; checked before writing staging.
|
||||
EXPECTED_STAGE_MOUNT=""
|
||||
EXPECTED_STAGE_UUID=""
|
||||
REPO_DIR="/net/thor/volume1/NetBackup/Nextcloud/borg-repo"
|
||||
EXPECTED_NAS_SOURCE="thor:/volume1/NetBackup"
|
||||
EXPECTED_NAS_FSTYPE="nfs4"
|
||||
|
||||
@@ -175,6 +175,8 @@ cmd_runner() {
|
||||
# New settings are optional in existing configs, and inherited by the runner.
|
||||
export REMOTE="${REMOTE:-Nextcloud:}"
|
||||
export STAGING_DIR="${STAGING_DIR:-${HOME}/.local/share/nextcloud-borg-backup/staging}"
|
||||
export EXPECTED_STAGE_MOUNT="${EXPECTED_STAGE_MOUNT:-}"
|
||||
export EXPECTED_STAGE_UUID="${EXPECTED_STAGE_UUID:-}"
|
||||
export EXPECTED_NAS_SOURCE="${EXPECTED_NAS_SOURCE:-thor:/volume1/NetBackup}"
|
||||
export EXPECTED_NAS_FSTYPE="${EXPECTED_NAS_FSTYPE:-nfs4}"
|
||||
export BACKUP_INTERVAL_HOURS="${BACKUP_INTERVAL_HOURS:-24}"
|
||||
|
||||
@@ -186,6 +186,22 @@ class Runner:
|
||||
return count, size
|
||||
|
||||
def staging_preflight(self, manifest):
|
||||
# Check a dedicated staging disk before mkdir or any sync. Without this
|
||||
# guard a missing mount could silently put the staging tree on root.
|
||||
stage_mount = os.environ.get('EXPECTED_STAGE_MOUNT', '')
|
||||
stage_uuid = os.environ.get('EXPECTED_STAGE_UUID', '')
|
||||
if bool(stage_mount) != bool(stage_uuid):
|
||||
raise BackupError('EXPECTED_STAGE_MOUNT and EXPECTED_STAGE_UUID must be configured together')
|
||||
if stage_mount:
|
||||
mount_path = Path(stage_mount).absolute()
|
||||
if mount_path not in self.stage.parents or mount_path.resolve() != mount_path:
|
||||
raise BackupError('Staging must be below the configured staging mount')
|
||||
mounts = json.loads(self.run(['findmnt', '--json', '--target', mount_path,
|
||||
'--output', 'TARGET,UUID,FSTYPE'], capture=True))['filesystems']
|
||||
if (len(mounts) != 1 or mounts[0].get('target') != str(mount_path)
|
||||
or mounts[0].get('uuid') != stage_uuid
|
||||
or mounts[0].get('fstype') not in ('ext4', 'btrfs', 'xfs', 'f2fs', 'zfs')):
|
||||
raise BackupError('Dedicated staging disk is missing or has the wrong identity')
|
||||
# Refuse symlinked, overlapping, mounted or foreign staging paths before rclone sync.
|
||||
for other in (self.repo, self.state, Path.home() / 'Nextcloud'):
|
||||
if self.stage == other or self.stage in other.parents or other in self.stage.parents:
|
||||
@@ -339,7 +355,7 @@ class Runner:
|
||||
self.run([self.borg, 'check', '--repository-only', self.repo])
|
||||
# Success means a complete verified archive. Retention failure must not lose this fact.
|
||||
self.success = {'completed_at': time.time(), 'archive': archive, 'files': count,
|
||||
'bytes': size, 'repo': str(self.repo), 'remote': self.remote,
|
||||
'bytes': size, 'repo': str(self.repo), 'remote': self.remote, 'stage': str(self.stage),
|
||||
'archive_id': data['archives'][0]['id'], 'maintenance_pending': True}
|
||||
atomic_json(self.success_file, self.success)
|
||||
log(f'BACKUP VERIFIED: {archive} ({count} files, {size} bytes)')
|
||||
@@ -400,7 +416,7 @@ def main():
|
||||
sys.stdout = Tee()
|
||||
# Child output goes to journal; lifecycle/phase/status additionally go to the run log.
|
||||
try:
|
||||
log('Verified cloud-to-NAS runner 2.0.0, revision 3')
|
||||
log('Verified cloud-to-NAS runner 2.0.0, revision 4')
|
||||
runner.backup('--if-due' in sys.argv)
|
||||
finally:
|
||||
sys.stdout = sys.__stdout__
|
||||
|
||||
@@ -70,6 +70,39 @@ class BackupTests(unittest.TestCase):
|
||||
with self.assertRaisesRegex(m.BackupError, 'unowned'):
|
||||
self.runner.staging_preflight(self.manifest())
|
||||
|
||||
def test_dedicated_stage_mount_missing_or_wrong_uuid_does_not_create_directory(self):
|
||||
disk = self.root/'disk'
|
||||
self.runner.stage = disk/'private/staging'
|
||||
for actual in ({'target': '/', 'uuid': 'system-disk', 'fstype': 'btrfs'},
|
||||
{'target': str(disk), 'uuid': 'wrong-disk', 'fstype': 'btrfs'}):
|
||||
with self.subTest(actual=actual), patch.dict(os.environ, {
|
||||
'EXPECTED_STAGE_MOUNT': str(disk), 'EXPECTED_STAGE_UUID': 'archive-disk'}), \
|
||||
patch.object(self.runner, 'run', return_value=json.dumps({'filesystems': [actual]})):
|
||||
with self.assertRaisesRegex(m.BackupError, 'missing or has the wrong identity'):
|
||||
self.runner.staging_preflight(self.manifest())
|
||||
self.assertFalse(disk.exists())
|
||||
|
||||
def test_dedicated_stage_mount_verified_before_owned_stage_creation(self):
|
||||
disk = self.root/'disk'
|
||||
disk.mkdir()
|
||||
self.runner.stage = disk/'staging'
|
||||
with patch.dict(os.environ, {'EXPECTED_STAGE_MOUNT': str(disk), 'EXPECTED_STAGE_UUID': 'archive-disk'}), \
|
||||
patch.object(self.runner, 'run', side_effect=[json.dumps({'filesystems': [
|
||||
{'target': str(disk), 'uuid': 'archive-disk', 'fstype': 'btrfs'}]}),
|
||||
json.dumps({'filesystems': [{'fstype': 'btrfs'}]})]):
|
||||
self.runner.staging_preflight(self.manifest())
|
||||
self.assertEqual(m.read_json(disk/'staging.owner.json'),
|
||||
{'remote': self.runner.remote, 'stage': str(self.runner.stage)})
|
||||
|
||||
def test_dedicated_stage_mount_configuration_must_be_paired_and_contain_stage(self):
|
||||
for values in ({'EXPECTED_STAGE_MOUNT': str(self.root), 'EXPECTED_STAGE_UUID': ''},
|
||||
{'EXPECTED_STAGE_MOUNT': '', 'EXPECTED_STAGE_UUID': 'archive-disk'},
|
||||
{'EXPECTED_STAGE_MOUNT': str(self.root/'other-disk'), 'EXPECTED_STAGE_UUID': 'archive-disk'}):
|
||||
with self.subTest(values=values), patch.dict(os.environ, values):
|
||||
with self.assertRaises(m.BackupError):
|
||||
self.runner.staging_preflight(self.manifest())
|
||||
self.assertFalse(self.runner.stage.exists())
|
||||
|
||||
def test_subprocess_failure_never_becomes_success(self):
|
||||
with patch.object(self.runner, 'perform', side_effect=m.BackupError('network failure')), \
|
||||
patch.object(self.runner, 'notify') as notify:
|
||||
@@ -221,6 +254,7 @@ class BackupTests(unittest.TestCase):
|
||||
self.runner.backup()
|
||||
success = m.read_json(self.runner.success_file)
|
||||
self.assertEqual(success['files'], 2)
|
||||
self.assertEqual(success['stage'], str(self.runner.stage))
|
||||
dest = self.root/'restore'
|
||||
dest.mkdir()
|
||||
subprocess.run(['borg', 'extract', f'{self.runner.repo}::{success["archive"]}'],
|
||||
|
||||
Reference in new issue
Block a user