Retry restore-test downloads safely and expose bounded diagnostics
This commit is contained in:
1 parent
4f220dbc64
commit
3d16f33f24
10 files changed
+417
-22
No files matched your search
+4
-2
@@ -222,8 +222,10 @@ Die sechs Gesamtversuche gelten nicht pauschal für jeden rclone-Befehl:
|
|||||||
jedoch Wiederholungen fehlgeschlagener Datei-Leseoperationen. Deshalb verwendet
|
jedoch Wiederholungen fehlgeschlagener Datei-Leseoperationen. Deshalb verwendet
|
||||||
app-backup jetzt einen eigenen begrenzten Wrapper nur für vollständige
|
app-backup jetzt einen eigenen begrenzten Wrapper nur für vollständige
|
||||||
Rücklesevergleiche (Backup und Platzbereinigung). Er greift auch mit alten
|
Rücklesevergleiche (Backup und Platzbereinigung). Er greift auch mit alten
|
||||||
Konfigurationen. Die einzelnen Restore-Downloads und Uploads behalten ihre
|
Konfigurationen. Uploads und manuelle `app-restore.sh`-Downloads behalten ihre
|
||||||
bisherigen Einstellungen. Ein verbleibender Prüffehler beendet das Backup
|
bisherigen Einstellungen. Der separate monatliche Restore-Test verwendet seit
|
||||||
|
dem Folgefix ebenfalls begrenzte Downloads; siehe [RESTORE_TEST.md](RESTORE_TEST.md).
|
||||||
|
Ein verbleibender Prüffehler beendet das Backup
|
||||||
sichtbar. Quellen:
|
sichtbar. Quellen:
|
||||||
[Kopierversuche](https://github.com/rclone/rclone/blob/v1.74.3/cmd/cmd.go),
|
[Kopierversuche](https://github.com/rclone/rclone/blob/v1.74.3/cmd/cmd.go),
|
||||||
[Check-Aufruf](https://github.com/rclone/rclone/blob/v1.74.3/cmd/check/check.go),
|
[Check-Aufruf](https://github.com/rclone/rclone/blob/v1.74.3/cmd/check/check.go),
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ Die vollständige Analyse, Architektur, Konfiguration, Betriebsanleitung und ver
|
|||||||
Der [Vorfall vom 15.09.2026 und die korrigierte Rückleseprüfung](REMOTE_READBACK.md)
|
Der [Vorfall vom 15.09.2026 und die korrigierte Rückleseprüfung](REMOTE_READBACK.md)
|
||||||
sind dokumentiert: begrenzte vollständige Prüfanläufe, eigene Zeitlimits,
|
sind dokumentiert: begrenzte vollständige Prüfanläufe, eigene Zeitlimits,
|
||||||
sichtbare Dateiergebnisse und eindeutiger Hinweis auf erhaltene lokale Archive.
|
sichtbare Dateiergebnisse und eindeutiger Hinweis auf erhaltene lokale Archive.
|
||||||
|
Die anschließende Restore-Abnahme verwendet jetzt ebenfalls begrenzte Downloads
|
||||||
|
mit Wiederholungen und sicheren Fehlerdetails; siehe
|
||||||
|
[Restore-Test: Downloadfehler](RESTORE_TEST.md#downloadfehler-bei-der-abnahme-am-15092026).
|
||||||
|
|
||||||
Neu: [Monatlicher Offsite-Restore-Test](RESTORE_TEST.md) mit vollständiger Prüfung
|
Neu: [Monatlicher Offsite-Restore-Test](RESTORE_TEST.md) mit vollständiger Prüfung
|
||||||
aller Komponenten, isoliertem MariaDB-Import und Bereinigung auch bei Fehlern.
|
aller Komponenten, isoliertem MariaDB-Import und Bereinigung auch bei Fehlern.
|
||||||
|
|||||||
+30
-3
@@ -151,8 +151,10 @@ sich allein auf temporäres Staging; jetzt nennt das Fehlerlog zusätzlich den
|
|||||||
erhaltenen lokalen Sicherungssatz. Die Speicherbereinigung entfernt keine
|
erhaltenen lokalen Sicherungssatz. Die Speicherbereinigung entfernt keine
|
||||||
lokale Kopie, deren Offsite-Prüfung scheitert.
|
lokale Kopie, deren Offsite-Prüfung scheitert.
|
||||||
|
|
||||||
Uploads, einzelne Restore-Downloads und deren Einstellungen bleiben beim
|
Uploads und manuelle `app-restore.sh`-Downloads bleiben beim bisherigen Verfahren.
|
||||||
bisherigen Verfahren. Das Update repariert weder ein beschädigtes OneDrive-Objekt
|
Die einzelnen Downloads des separaten monatlichen Restore-Tests verwenden seit
|
||||||
|
dem unten beschriebenen Folgefix ebenfalls begrenzte Versuche. Das Update
|
||||||
|
repariert weder ein beschädigtes OneDrive-Objekt
|
||||||
noch veröffentlicht es alte fehlgeschlagene Läufe automatisch. Insbesondere
|
noch veröffentlicht es alte fehlgeschlagene Läufe automatisch. Insbesondere
|
||||||
werden keine Crypt-Schlüssel geändert und keine Inhaltsprüfungen abgeschwächt.
|
werden keine Crypt-Schlüssel geändert und keine Inhaltsprüfungen abgeschwächt.
|
||||||
|
|
||||||
@@ -207,6 +209,29 @@ erst nach erfolgreicher Update-Abnahme aktiviert. Der konkrete Rollback-Pfad
|
|||||||
steht im Update-Ergebnisordner (`rollback-update.sh`); bei einem Rollback
|
steht im Update-Ergebnisordner (`rollback-update.sh`); bei einem Rollback
|
||||||
kehren auch die bisherigen Grenzen der Rückleseprüfung zurück.
|
kehren auch die bisherigen Grenzen der Rückleseprüfung zurück.
|
||||||
|
|
||||||
|
### Ergebnis des ersten Deployments und Folgefix
|
||||||
|
|
||||||
|
Der Betreiber installierte `4f220dbc6447` über den Updateordner
|
||||||
|
`/var/backups/app-backup-upgrade-vuQRJCvw`. Die Vorbereitung prüfte den Satz
|
||||||
|
vom 12.09. erfolgreich im ersten Versuch nach rund 13 Minuten, bevor seine lokale
|
||||||
|
Kopie zur Platzbeschaffung entfernt wurde. Der neue Satz
|
||||||
|
`appbackup_2026-09-15_09-03-06_20b0f7a6` bestand am 15.09. um 11:32:41 MESZ
|
||||||
|
die vollständige Rückleseprüfung: 15 passende Dateien, einschließlich
|
||||||
|
`nextcloud-data.tar.zst`, erster Versuch, 3 min 42,7 s. Um 11:32:50 wurden
|
||||||
|
Abschlussmarker und Backup-Erfolg bestätigt. Damit ist dieser neue Offsite-Satz
|
||||||
|
inhaltlich geprüft; das sagt nichts über den Zustand des alten fehlgeschlagenen
|
||||||
|
Satzes von 00:30 UTC aus.
|
||||||
|
|
||||||
|
Die separate Restore-Abnahme scheiterte anschließend beim erneuten Download
|
||||||
|
der Nextcloud-Daten mit `Command failed: bash`, meldete aber `cleanup OK`.
|
||||||
|
Metadaten, drei Datenbanken, WordPress und Nextcloud waren bereits bestanden.
|
||||||
|
Die erste Korrektur begrenzte die `check`-Aufrufe, nicht den eigenen `cat`-Pfad
|
||||||
|
dieses Tests. Zusätzlich unterdrückte dessen Supervisor die konkrete
|
||||||
|
Unterprozessfehlermeldung. Die nachträgliche genaue Providerursache ist daher
|
||||||
|
aus diesem Log nicht rekonstruierbar. Der Folgefix erfasst diesen Downloadpfad
|
||||||
|
mit Wiederholungen, Zeitlimits und sicheren Diagnosen; siehe
|
||||||
|
[Restore-Test-Analyse](RESTORE_TEST.md#downloadfehler-bei-der-abnahme-am-15092026).
|
||||||
|
|
||||||
Regressionstests verwenden echte lokale Crypt-Transfers, tar/zstd und isolierte
|
Regressionstests verwenden echte lokale Crypt-Transfers, tar/zstd und isolierte
|
||||||
MariaDB. Sie prüfen vorübergehende Fehler, echte beschädigte Crypt-Dateiköpfe,
|
MariaDB. Sie prüfen vorübergehende Fehler, echte beschädigte Crypt-Dateiköpfe,
|
||||||
bleibende Fehler, Fristablauf mit Wiederholung, Signalabbruch, erhaltene lokale
|
bleibende Fehler, Fristablauf mit Wiederholung, Signalabbruch, erhaltene lokale
|
||||||
@@ -223,4 +248,6 @@ Validierung am 15.09.2026:
|
|||||||
beschädigter Crypt-Header, Fristablauf mit erneutem Erfolg, dauerhafter Stillstand,
|
beschädigter Crypt-Header, Fristablauf mit erneutem Erfolg, dauerhafter Stillstand,
|
||||||
Signalabbruch, gleich große Inhaltsbeschädigung, geschützte Platzbereinigung
|
Signalabbruch, gleich große Inhaltsbeschädigung, geschützte Platzbereinigung
|
||||||
sowie Paketinstallation und kombiniertes Rollback.
|
sowie Paketinstallation und kombiniertes Rollback.
|
||||||
- Das Produktionssystem wurde nicht verändert; die OneDrive-Abnahme steht aus.
|
- Zum Zeitpunkt dieser lokalen Tests war das Produktionssystem noch nicht
|
||||||
|
verändert. Das nachfolgende Deployment bestand das Backup; die vollständige
|
||||||
|
Restore-Abnahme scheiterte wie oben beschrieben.
|
||||||
@@ -6,6 +6,82 @@ entschlüsselt, validiert und in ein privates Testverzeichnis entpackt. SQL-Dump
|
|||||||
werden zusätzlich in eine isolierte MariaDB importiert und mit `mariadb-check`
|
werden zusätzlich in eine isolierte MariaDB importiert und mit `mariadb-check`
|
||||||
geprüft. Produktive Anwendungen werden dabei nicht angehalten oder überschrieben.
|
geprüft. Produktive Anwendungen werden dabei nicht angehalten oder überschrieben.
|
||||||
|
|
||||||
|
## Downloadfehler bei der Abnahme am 15.09.2026
|
||||||
|
|
||||||
|
Das Update `4f220dbc6447` erzeugte den Satz
|
||||||
|
`appbackup_2026-09-15_09-03-06_20b0f7a6`. Sein Upload und der vollständige
|
||||||
|
Bytevergleich aller 15 Dateien bestanden, auch für die rund 5 GiB große
|
||||||
|
`nextcloud-data.tar.zst`. Der Abschlussmarker wurde bestätigt. Anschließend
|
||||||
|
bestand der separate Restore-Test Metadaten, drei Datenbanken, WordPress und
|
||||||
|
Nextcloud; beim erneuten Abruf der Nextcloud-Daten folgte `Command failed: bash`.
|
||||||
|
Der Test meldete `FAIL; cleanup OK`, das gesamte Update deshalb `FAILED`.
|
||||||
|
|
||||||
|
Der Fehler ist dem Shell-Aufruf für den Komponentendownload einschließlich
|
||||||
|
Remote-Vorprüfung zuzuordnen: Tar-Prüfung und Entpacken würden einen anderen
|
||||||
|
Programmnamen melden; Fehler der separaten Platzbeschaffung werden bereits
|
||||||
|
mit einer eigenen Meldung versehen. Der Supervisor verwarf jedoch stderr
|
||||||
|
und nannte weder Exit-Code noch genaue Phase. Ob diesmal Crypt-Header, Timeout,
|
||||||
|
HTTP-Fehler oder eine andere Bridge-Ursache vorlag, ist damit **nicht mehr aus
|
||||||
|
diesem Log rekonstruierbar**. Ein erneuter `bad magic string` ist nicht belegt.
|
||||||
|
|
||||||
|
Die erste Korrektur erfasste `rclone check`, aber nicht diesen eigenen
|
||||||
|
`rclone cat`-Pfad. Auch `cat` wiederholt den gesamten Befehl in rclone 1.74.3
|
||||||
|
nicht automatisch über `--retries`.
|
||||||
|
Quelle: [rclone-1.74.3-cat-Aufruf](https://github.com/rclone/rclone/blob/v1.74.3/cmd/cat/cat.go).
|
||||||
|
|
||||||
|
### Korrigierter Ablauf
|
||||||
|
|
||||||
|
Der Restore-Test verwendet für seine lesenden Remote-Aufrufe jetzt dieselben
|
||||||
|
Konfigurationswerte wie die Backup-Rückleseprüfung:
|
||||||
|
|
||||||
|
- `RCLONE_CHECK_ATTEMPTS=3`: höchstens drei Versuche je Metadatenabruf/Download.
|
||||||
|
- `RCLONE_CHECK_DEADLINE=3600`: maximal eine Stunde je Versuch einschließlich
|
||||||
|
Remote-Vorprüfung. Der Python-Supervisor beendet die eigene Prozessgruppe bei
|
||||||
|
Überschreitung mit TERM, nötigenfalls nach fünf Sekunden mit KILL.
|
||||||
|
- `RCLONE_CHECK_TIMEOUT=300` und `RCLONE_CHECK_LOW_LEVEL_RETRIES=3`: kürzere
|
||||||
|
rclone-Leerlauffrist und begrenzte interne Versuche, auch bei der Vorprüfung.
|
||||||
|
- `RCLONE_CHECK_RETRY_DELAY=30`: Pause zwischen fehlgeschlagenen Versuchen.
|
||||||
|
Die Gesamtfrist `RESTORE_TEST_TIMEOUT` und die Speicherüberwachung bleiben
|
||||||
|
während Downloads und Wartepausen wirksam. Die Fristen addieren sich nicht
|
||||||
|
zu einer unbegrenzten Verlängerung des gesamten Restore-Tests.
|
||||||
|
|
||||||
|
Bei einer Wiederholung wird die angefangene Archivdatei auf null gekürzt und
|
||||||
|
vollständig neu geladen; es wird niemals an Teilbytes angehängt. Metadaten
|
||||||
|
werden je Versuch in einem neuen begrenzten Puffer gelesen. Erst ein erfolgreich
|
||||||
|
beendeter Download mit passender Größe und SHA-256 darf zur Archivprüfung und
|
||||||
|
zum Entpacken gelangen. Größenüberschreitungen, Prüfsummenfehler, Speichergrenzen,
|
||||||
|
Gesamtfrist und Signalabbrüche lösen keine Download-Wiederholung aus.
|
||||||
|
Die Platzbereinigung wird nicht als Remote-Leseoperation wiederholt.
|
||||||
|
|
||||||
|
Jede Komponente meldet und speichert nun ihre konkrete Phase: Download,
|
||||||
|
Prüfsumme, Archivprüfung, Platzabschätzung, Entpacken, DB-Import/-Prüfung und
|
||||||
|
Bereinigung. Während eines Remote-Aufrufs erscheint etwa jede Minute
|
||||||
|
ein Fortschrittsbericht mit gelesenen Bytes und verstrichener Zeit, auch bei
|
||||||
|
Stillstand. Nicht erfolgreiche Prozesse nennen Exit-Code oder Fristablauf.
|
||||||
|
|
||||||
|
Remote-stderr wird parallel zum Archivstrom gelesen und ausschließlich in feste
|
||||||
|
Fehlerklassen übersetzt, etwa `crypt-header`, `io-timeout`, `tls`, `dns`,
|
||||||
|
`connection`, `http-429` oder `http-503`. Die Auswertung hat einen begrenzten
|
||||||
|
Textpuffer; große stderr-Ausgaben können die Download-Pipe nicht blockieren.
|
||||||
|
Es werden keine rohen URLs, Tokens, Header, Dateiinhalte oder Providertexte
|
||||||
|
übernommen. Unbekannte Meldungen erscheinen als `unclassified remote error`.
|
||||||
|
Diese Klassifizierung ist ein Diagnosehinweis, kein Beweis der Providerursache.
|
||||||
|
DB- und sonstige Tool-stderr-Ausgaben bleiben vollständig unterdrückt, da sie
|
||||||
|
SQL-Inhalte enthalten können. Phase und Exit-Code bleiben trotzdem sichtbar.
|
||||||
|
|
||||||
|
Ein bleibender Fehler erhält den bereits verifizierten Backup-Satz und den
|
||||||
|
vorherigen Restore-Erfolgsnachweis, meldet die betroffene Komponente als `FAIL`
|
||||||
|
und bereinigt die Testdaten. Nach fehlgeschlagener Update-Abnahme bleiben
|
||||||
|
Backup- und Restore-Test-Timer gestoppt. Das neue Paket erneut im Wartungsfenster
|
||||||
|
installieren; es führt ein neues Backup samt vollständiger Restore-Abnahme aus.
|
||||||
|
Erst die neue `result.txt` mit `SUCCESS` bestätigt die gesamte Abnahme.
|
||||||
|
[Installationsschritte](UPDATE_ANLEITUNG.md).
|
||||||
|
|
||||||
|
Der vorhandene erfolgreiche Backup-Satz darf alternativ nach Installation des
|
||||||
|
Fixes mit `app-restore-test.sh --remote-run` erneut geprüft werden. Dabei bleibt
|
||||||
|
die Altersgrenze aktiv. Ein solcher Einzeltest setzt nicht nachträglich das
|
||||||
|
alte Update-Ergebnis auf SUCCESS und aktiviert keine Update-Timer.
|
||||||
|
|
||||||
## Speicherbedarf und Bereinigung
|
## Speicherbedarf und Bereinigung
|
||||||
|
|
||||||
Wegen des knappen Speichers auf Gemini läuft immer nur **eine Komponente**:
|
Wegen des knappen Speichers auf Gemini läuft immer nur **eine Komponente**:
|
||||||
@@ -221,6 +297,22 @@ Technische Referenzen: [Docker run](https://docs.docker.com/reference/cli/docker
|
|||||||
|
|
||||||
## Entwicklung und lokale Verifikation
|
## Entwicklung und lokale Verifikation
|
||||||
|
|
||||||
|
Der Folgefix vom 15.09.2026 bestand alle **33 Restore-Test-Fälle** mit der von
|
||||||
|
Gemini kopierten rclone-1.74.3-Binärdatei und dem echten lokalen
|
||||||
|
`mariadb:10.11`-Image. Geprüft wurden auch Teil-Downloads/Metadaten mit erneutem
|
||||||
|
Erfolg, dauerhaft fehlgeschlagene Downloads, Fristablauf, Signalabbruch,
|
||||||
|
Gesamtfrist während einer Retry-Pause und zu große Antworten. Große stderr-
|
||||||
|
Ausgaben blockierten nicht; synthetische Tokens und SQL-Inhalte erschienen
|
||||||
|
weder im Log noch im Status oder Bericht. Die Testcontainer und Testdaten waren
|
||||||
|
anschließend entfernt. Diese lokalen Tests ersetzen nicht die erneute
|
||||||
|
OneDrive-/Rocky-Abnahme auf Gemini.
|
||||||
|
|
||||||
|
Die vollständige Basissuite `tests/run.sh` bestand ebenfalls: Bash-Syntax,
|
||||||
|
ShellCheck und 111 erfolgreiche Tests; fünf optionale Docker-Fälle waren in
|
||||||
|
diesem netzlosen Basistest ausgelassen und wurden im oben genannten echten
|
||||||
|
MariaDB-Lauf bestanden. Insgesamt sind damit alle **116 unterschiedlichen
|
||||||
|
Tests** erfolgreich abgedeckt, einschließlich Paketinstallation und Rollback.
|
||||||
|
|
||||||
Am 12.09.2026 bestanden **100 unterschiedliche Tests**: 71 Backup-/Restore-/
|
Am 12.09.2026 bestanden **100 unterschiedliche Tests**: 71 Backup-/Restore-/
|
||||||
Installer-Regressionstests, 23 Restore-Test-Fälle mit dem offiziellen lokalen
|
Installer-Regressionstests, 23 Restore-Test-Fälle mit dem offiziellen lokalen
|
||||||
MariaDB-10.11-Image und 6 gezielte Scratch-/Recovery-Schutzprüfungen. ShellCheck
|
MariaDB-10.11-Image und 6 gezielte Scratch-/Recovery-Schutzprüfungen. ShellCheck
|
||||||
|
|||||||
@@ -1,5 +1,26 @@
|
|||||||
# Automatisches Update-Paket
|
# Automatisches Update-Paket
|
||||||
|
|
||||||
|
## Nachtrag vom 15.09.2026: Restore-Abnahme nach erfolgreichem Backup
|
||||||
|
|
||||||
|
Das Paket `4f220dbc6447` bestand auf Gemini das neue Backup einschließlich
|
||||||
|
vollständiger Remote-Rückleseprüfung. Danach scheiterte der separate Restore-Test
|
||||||
|
bei `nextcloud-data` mit `Command failed: bash`; seine Bereinigung war erfolgreich.
|
||||||
|
Die erste Korrektur erfasste diesen eigenen Downloadpfad noch nicht.
|
||||||
|
|
||||||
|
Das aktuelle Paket begrenzt und wiederholt auch die Restore-Test-Downloads.
|
||||||
|
Es meldet die genaue Komponente/Phase, Exit-Code, empfangene Bytes und sichere
|
||||||
|
Fehlerklassen. Angefangene Downloads werden vor Wiederholung auf null gekürzt.
|
||||||
|
Details und Grenzen stehen in [RESTORE_TEST.md](RESTORE_TEST.md).
|
||||||
|
|
||||||
|
Bei diesem Updatefehler bleibt das erfolgreich erstellte Backup erhalten,
|
||||||
|
das Paket ist installiert, Backup- und Restore-Test-Timer bleiben jedoch gestoppt.
|
||||||
|
Das korrigierte Paket wie unten per SCP übertragen und im Wartungsfenster normal
|
||||||
|
starten. Es erstellt ein neues Abnahmebackup und prüft dieses vollständig.
|
||||||
|
Ein bereits aktivierter Restore-Test benötigt keine weitere Aktivierungsoption.
|
||||||
|
Erst `SUCCESS` in der **neuen** `result.txt` bestätigt die gesamte Update-Abnahme
|
||||||
|
und die Timer-Aktivierung; die alte Fehlerdatei bleibt ein historischer Nachweis.
|
||||||
|
Rollback ist für die erneute Paketinstallation nicht erforderlich.
|
||||||
|
|
||||||
## Korrektur vom 15.09.2026: hängende Rückleseprüfung
|
## Korrektur vom 15.09.2026: hängende Rückleseprüfung
|
||||||
|
|
||||||
Das aktuelle Paket ergänzt drei vollständige Rückleseversuche mit eigenen
|
Das aktuelle Paket ergänzt drei vollständige Rückleseversuche mit eigenen
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ RCLONE_TIMEOUT="1h"
|
|||||||
RCLONE_CONTIMEOUT="30s"
|
RCLONE_CONTIMEOUT="30s"
|
||||||
# Periodic transfer/check statistics in journald, including otherwise quiet uploads.
|
# Periodic transfer/check statistics in journald, including otherwise quiet uploads.
|
||||||
RCLONE_STATS_INTERVAL="1m"
|
RCLONE_STATS_INTERVAL="1m"
|
||||||
# Full readback attempts, independent of upload retries. All times are seconds.
|
# Readback policy: full comparisons and individual monthly restore-test reads.
|
||||||
|
# Independent of upload retries. All times are seconds.
|
||||||
# Deadline includes backend waits; raise it for larger/slower full comparisons.
|
# Deadline includes backend waits; raise it for larger/slower full comparisons.
|
||||||
RCLONE_CHECK_ATTEMPTS=3
|
RCLONE_CHECK_ATTEMPTS=3
|
||||||
RCLONE_CHECK_RETRY_DELAY=30
|
RCLONE_CHECK_RETRY_DELAY=30
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ if [[ ${1:-} == space ]]; then
|
|||||||
ensure_space "$WORKDIR" "$2"
|
ensure_space "$WORKDIR" "$2"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
# These reads belong to verification, not upload. Apply the shorter read I/O
|
||||||
|
# policy even during crypt preflight. The Python parent owns deadlines/retries
|
||||||
|
# and resets partial output before retry; retrying cat here could append bytes.
|
||||||
|
RCLONE_TIMEOUT="${RCLONE_CHECK_TIMEOUT}s"
|
||||||
|
RCLONE_LOW_LEVEL_RETRIES=$RCLONE_CHECK_LOW_LEVEL_RETRIES
|
||||||
check_remote >&2
|
check_remote >&2
|
||||||
# rclone is restricted to read operations here, including metadata reads.
|
# rclone is restricted to read operations here, including metadata reads.
|
||||||
case ${1:-} in lsf|cat) rc "$@" ;; *) die 'Unsupported restore-test remote operation' ;; esac
|
case ${1:-} in lsf|cat) rc "$@" ;; *) die 'Unsupported restore-test remote operation' ;; esac
|
||||||
+131
-16
@@ -36,6 +36,53 @@ class TestError(Exception):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class CommandError(TestError):
|
||||||
|
"""Only process failures/timeouts are retryable; disk and size guards are not."""
|
||||||
|
def __init__(self, operation, code, total, diagnostics='unclassified', timed_out=False):
|
||||||
|
self.code = code
|
||||||
|
self.timed_out = timed_out
|
||||||
|
reason = 'deadline exceeded' if timed_out else f'exit {code}'
|
||||||
|
super().__init__(f'{operation} failed ({reason}; received {total} bytes; {diagnostics})')
|
||||||
|
|
||||||
|
|
||||||
|
class RemoteDiagnostics:
|
||||||
|
"""Classify stderr without retaining URLs, tokens, filenames or raw payloads.
|
||||||
|
|
||||||
|
A denylist cannot reliably redact provider messages. Emit only fixed labels
|
||||||
|
and HTTP status numbers, draining both pipes to avoid a full-stderr deadlock.
|
||||||
|
SQL/tool stderr outside the remote bridge stays suppressed.
|
||||||
|
"""
|
||||||
|
PATTERNS = {
|
||||||
|
'crypt-header': ('bad magic string', 'too short to be encrypted', 'truncated block header'),
|
||||||
|
'crypt-authentication': ('failed to authenticate decrypted block',),
|
||||||
|
'io-timeout': ('i/o timeout', 'context deadline exceeded', 'timed out'),
|
||||||
|
'dns': ('no such host', 'temporary failure in name resolution'),
|
||||||
|
'connection': ('connection reset', 'connection refused', 'unexpected eof'),
|
||||||
|
'tls': ('x509:', 'tls handshake'),
|
||||||
|
'throttled': ('too many requests', 'activitylimitreached', 'throttled'),
|
||||||
|
'not-found': ('object not found', 'directory not found'),
|
||||||
|
'authentication': ('unauthorized', 'invalid_grant', 'invalid token'),
|
||||||
|
'local-space': ('no space left on device',),
|
||||||
|
'configuration': ('unsupported rclone', 'invalid remote', 'config file not found', 'crypt remote required'),
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.tail = ''
|
||||||
|
self.labels = set()
|
||||||
|
|
||||||
|
def feed(self, chunk):
|
||||||
|
text = self.tail + chunk.decode(errors='replace').lower()
|
||||||
|
for label, patterns in self.PATTERNS.items():
|
||||||
|
if any(pattern in text for pattern in patterns):
|
||||||
|
self.labels.add(label)
|
||||||
|
for code in re.findall(r'(?:http(?:/\d(?:\.\d)?)?\s+|status(?: code)?[\s:=]+)([45]\d\d)\b', text):
|
||||||
|
self.labels.add('http-' + code)
|
||||||
|
self.tail = text[-256:]
|
||||||
|
|
||||||
|
def summary(self):
|
||||||
|
return ', '.join(sorted(self.labels)) or 'unclassified remote error (raw details suppressed)'
|
||||||
|
|
||||||
|
|
||||||
def atomic_json(path, data):
|
def atomic_json(path, data):
|
||||||
temp = path.with_suffix('.tmp')
|
temp = path.with_suffix('.tmp')
|
||||||
temp.write_text(json.dumps(data, ensure_ascii=True, indent=2) + '\n')
|
temp.write_text(json.dumps(data, ensure_ascii=True, indent=2) + '\n')
|
||||||
@@ -161,27 +208,40 @@ class Rehearsal:
|
|||||||
if self.args.max_work_gb and self.used + additional > self.args.max_work_gb * GIB:
|
if self.args.max_work_gb and self.used + additional > self.args.max_work_gb * GIB:
|
||||||
raise TestError('Restore-test disk budget exceeded')
|
raise TestError('Restore-test disk budget exceeded')
|
||||||
|
|
||||||
def command(self, command, *, capture=0, output=None, limit=None, stdin=None, timeout=120):
|
def command(self, command, *, capture=0, output=None, limit=None, stdin=None, timeout=120,
|
||||||
|
operation=None, remote_diagnostics=False):
|
||||||
"""Bound stdout and runtime, monitor disk, reap children even on interruption.
|
"""Bound stdout and runtime, monitor disk, reap children even on interruption.
|
||||||
|
|
||||||
stderr is intentionally not copied: DB errors can contain restored data.
|
Only the remote bridge gets classified stderr; raw stderr is never logged.
|
||||||
Errors identify the failed operation without logging SQL or credentials.
|
|
||||||
"""
|
"""
|
||||||
result = bytearray()
|
result = bytearray()
|
||||||
total = 0
|
total = 0
|
||||||
|
operation = operation or Path(command[0]).name
|
||||||
|
diagnostics = RemoteDiagnostics()
|
||||||
|
started = time.monotonic()
|
||||||
|
next_progress = started + 60
|
||||||
end = time.monotonic() + timeout
|
end = time.monotonic() + timeout
|
||||||
proc = subprocess.Popen(command, stdin=stdin or subprocess.DEVNULL,
|
proc = subprocess.Popen(command, stdin=stdin or subprocess.DEVNULL,
|
||||||
stdout=subprocess.PIPE if capture or output is not None else subprocess.DEVNULL,
|
stdout=subprocess.PIPE if capture or output is not None else subprocess.DEVNULL,
|
||||||
stderr=subprocess.DEVNULL, start_new_session=True)
|
stderr=subprocess.PIPE if remote_diagnostics else subprocess.DEVNULL,
|
||||||
|
start_new_session=True)
|
||||||
selector = selectors.DefaultSelector()
|
selector = selectors.DefaultSelector()
|
||||||
if proc.stdout:
|
if proc.stdout:
|
||||||
selector.register(proc.stdout, selectors.EVENT_READ)
|
selector.register(proc.stdout, selectors.EVENT_READ, 'stdout')
|
||||||
|
if proc.stderr:
|
||||||
|
selector.register(proc.stderr, selectors.EVENT_READ, 'stderr')
|
||||||
try:
|
try:
|
||||||
self.remember_child(proc)
|
self.remember_child(proc)
|
||||||
while selector.get_map() or proc.poll() is None:
|
while selector.get_map() or proc.poll() is None:
|
||||||
self.disk()
|
self.disk()
|
||||||
|
if remote_diagnostics and time.monotonic() >= next_progress:
|
||||||
|
self.log.info('%s progress: %s/%s bytes, elapsed %ss', operation, total,
|
||||||
|
limit if limit is not None else 'metadata', int(time.monotonic()-started))
|
||||||
|
next_progress = time.monotonic() + 60
|
||||||
if time.monotonic() >= end:
|
if time.monotonic() >= end:
|
||||||
raise TestError('Timed out: ' + Path(command[0]).name)
|
raise CommandError(operation, None, total,
|
||||||
|
diagnostics.summary() if remote_diagnostics else 'tool details suppressed',
|
||||||
|
timed_out=True)
|
||||||
if not selector.get_map():
|
if not selector.get_map():
|
||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
continue
|
continue
|
||||||
@@ -190,6 +250,9 @@ class Rehearsal:
|
|||||||
if not chunk:
|
if not chunk:
|
||||||
selector.unregister(key.fileobj)
|
selector.unregister(key.fileobj)
|
||||||
continue
|
continue
|
||||||
|
if key.data == 'stderr':
|
||||||
|
diagnostics.feed(chunk)
|
||||||
|
continue
|
||||||
total += len(chunk)
|
total += len(chunk)
|
||||||
if total > (limit if limit is not None else capture):
|
if total > (limit if limit is not None else capture):
|
||||||
raise TestError('Remote output exceeds expected size')
|
raise TestError('Remote output exceeds expected size')
|
||||||
@@ -198,8 +261,10 @@ class Rehearsal:
|
|||||||
output.write(chunk)
|
output.write(chunk)
|
||||||
else:
|
else:
|
||||||
result.extend(chunk)
|
result.extend(chunk)
|
||||||
if proc.wait() != 0:
|
code = proc.wait()
|
||||||
raise TestError('Command failed: ' + Path(command[0]).name)
|
if code != 0:
|
||||||
|
raise CommandError(operation, code, total,
|
||||||
|
diagnostics.summary() if remote_diagnostics else 'tool details suppressed')
|
||||||
return bytes(result)
|
return bytes(result)
|
||||||
finally:
|
finally:
|
||||||
selector.close()
|
selector.close()
|
||||||
@@ -212,11 +277,38 @@ class Rehearsal:
|
|||||||
proc.wait()
|
proc.wait()
|
||||||
if proc.stdout:
|
if proc.stdout:
|
||||||
proc.stdout.close()
|
proc.stdout.close()
|
||||||
|
if proc.stderr:
|
||||||
|
proc.stderr.close()
|
||||||
(self.root / 'child.json').unlink(missing_ok=True)
|
(self.root / 'child.json').unlink(missing_ok=True)
|
||||||
|
|
||||||
def remote(self, *args, **kwargs):
|
def remote(self, *args, **kwargs):
|
||||||
return self.command(['bash', self.args.remote_check, self.args.config, *args],
|
command = ['bash', self.args.remote_check, self.args.config, *args]
|
||||||
timeout=self.args.timeout, **kwargs)
|
# Space recovery can delete eligible local copies and has its own checks;
|
||||||
|
# it must not be replayed as though it were a read-only cat/lsf request.
|
||||||
|
if args[0] == 'space':
|
||||||
|
return self.command(command, timeout=self.args.timeout, operation='Space recovery', **kwargs)
|
||||||
|
for attempt in range(1, self.args.remote_attempts + 1):
|
||||||
|
self.disk()
|
||||||
|
output = kwargs.get('output')
|
||||||
|
if output is not None:
|
||||||
|
# Reuse one file, never append an aborted cat stream to a retry.
|
||||||
|
output.seek(0)
|
||||||
|
output.truncate()
|
||||||
|
self.log.info('Remote %s attempt %s/%s (deadline %ss)', args[0], attempt,
|
||||||
|
self.args.remote_attempts, self.args.remote_deadline)
|
||||||
|
try:
|
||||||
|
return self.command(command, timeout=self.args.remote_deadline,
|
||||||
|
operation='Remote ' + args[0], remote_diagnostics=True, **kwargs)
|
||||||
|
except CommandError as exc:
|
||||||
|
self.log.warning('%s', exc)
|
||||||
|
if (attempt == self.args.remote_attempts or
|
||||||
|
(exc.code is not None and (exc.code < 0 or exc.code >= 128))):
|
||||||
|
raise
|
||||||
|
# Keep disk/overall time and signal handling active during backoff.
|
||||||
|
end = time.monotonic() + self.args.remote_retry_delay
|
||||||
|
while time.monotonic() < end:
|
||||||
|
self.disk()
|
||||||
|
time.sleep(min(0.2, max(0, end-time.monotonic())))
|
||||||
|
|
||||||
def cleanup(self):
|
def cleanup(self):
|
||||||
self.stop_orphan()
|
self.stop_orphan()
|
||||||
@@ -372,41 +464,55 @@ exec mariadbd --no-defaults --user=root --datadir=/var/lib/mysql/db --socket=/va
|
|||||||
self.state['components'].append(component)
|
self.state['components'].append(component)
|
||||||
self.save()
|
self.save()
|
||||||
self.log.info('Component %s: download, verify, extract', item['label'])
|
self.log.info('Component %s: download, verify, extract', item['label'])
|
||||||
|
self.phase(component, 'download')
|
||||||
self.disk(additional=item['size'], force=True)
|
self.disk(additional=item['size'], force=True)
|
||||||
archive = self.scratch / item['file']
|
archive = self.scratch / item['file']
|
||||||
with archive.open('wb') as output:
|
with archive.open('wb') as output:
|
||||||
self.remote('cat', self.args.remote.rstrip('/') + '/' + self.state['run'] + '/' + item['file'],
|
self.remote('cat', self.args.remote.rstrip('/') + '/' + self.state['run'] + '/' + item['file'],
|
||||||
output=output, limit=item['size'])
|
output=output, limit=item['size'])
|
||||||
|
self.phase(component, 'checksum')
|
||||||
if archive.stat().st_size != item['size'] or backup_set.digest(archive) != item['sha256']:
|
if archive.stat().st_size != item['size'] or backup_set.digest(archive) != item['sha256']:
|
||||||
raise TestError('Archive checksum/size mismatch: ' + item['label'])
|
raise TestError('Archive checksum/size mismatch: ' + item['label'])
|
||||||
# Run shared validation as a monitored child, so even corrupt streams
|
# Run shared validation as a monitored child, so even corrupt streams
|
||||||
# and metadata-heavy archives cannot bypass the total timeout.
|
# and metadata-heavy archives cannot bypass the total timeout.
|
||||||
|
self.phase(component, 'archive validation')
|
||||||
self.command([sys.executable, str(Path(__file__).with_name('backup_set.py')),
|
self.command([sys.executable, str(Path(__file__).with_name('backup_set.py')),
|
||||||
'tar', str(archive), item['root']], timeout=self.args.timeout)
|
'tar', str(archive), item['root']], timeout=self.args.timeout, operation='Archive validation')
|
||||||
|
self.phase(component, 'space estimate')
|
||||||
unpacked, entries = self.expand_size(archive)
|
unpacked, entries = self.expand_size(archive)
|
||||||
self.disk(additional=unpacked, force=True)
|
self.disk(additional=unpacked, force=True)
|
||||||
extracted = self.scratch / 'extracted'
|
extracted = self.scratch / 'extracted'
|
||||||
extracted.mkdir(mode=0o700)
|
extracted.mkdir(mode=0o700)
|
||||||
|
self.phase(component, 'extract')
|
||||||
self.command(['tar', '--extract', '--file', str(archive), '--acls', '--xattrs', '--selinux',
|
self.command(['tar', '--extract', '--file', str(archive), '--acls', '--xattrs', '--selinux',
|
||||||
'--numeric-owner', '--same-owner', '-C', str(extracted)], timeout=self.args.timeout)
|
'--numeric-owner', '--same-owner', '-C', str(extracted)], timeout=self.args.timeout,
|
||||||
|
operation='Archive extraction')
|
||||||
# The compressed input is no longer needed; free it BEFORE DB import.
|
# The compressed input is no longer needed; free it BEFORE DB import.
|
||||||
archive.unlink()
|
archive.unlink()
|
||||||
component.update(extracted_bytes=unpacked, entries=entries)
|
component.update(extracted_bytes=unpacked, entries=entries)
|
||||||
if item['label'].startswith('db-'):
|
if item['label'].startswith('db-'):
|
||||||
|
self.phase(component, 'database import/check')
|
||||||
sql = extracted / item['root']
|
sql = extracted / item['root']
|
||||||
if not sql.is_file() or sql.is_symlink() or not sql.stat().st_size:
|
if not sql.is_file() or sql.is_symlink() or not sql.stat().st_size:
|
||||||
raise TestError('SQL dump missing or empty')
|
raise TestError('SQL dump missing or empty')
|
||||||
self.disk(additional=512*1024*1024, force=True)
|
self.disk(additional=512*1024*1024, force=True)
|
||||||
component['tables'] = self.database(sql)
|
component['tables'] = self.database(sql)
|
||||||
|
self.phase(component, 'cleanup')
|
||||||
self.monitor = False
|
self.monitor = False
|
||||||
self.cleanup()
|
self.cleanup()
|
||||||
self.scratch.mkdir(mode=0o700)
|
self.scratch.mkdir(mode=0o700)
|
||||||
self.monitor = True
|
self.monitor = True
|
||||||
component['status'] = 'SUCCESS'
|
component['status'] = 'SUCCESS'
|
||||||
|
component['phase'] = 'complete'
|
||||||
self.save()
|
self.save()
|
||||||
self.log.info('Component %s: SUCCESS; files and test DB removed', item['label'])
|
self.log.info('Component %s: SUCCESS; files and test DB removed', item['label'])
|
||||||
self.state['status'] = 'SUCCESS'
|
self.state['status'] = 'SUCCESS'
|
||||||
|
|
||||||
|
def phase(self, component, phase):
|
||||||
|
component['phase'] = phase
|
||||||
|
self.save()
|
||||||
|
self.log.info('Component %s: %s', component['label'], phase)
|
||||||
|
|
||||||
def report(self):
|
def report(self):
|
||||||
fqdn = socket.getfqdn()
|
fqdn = socket.getfqdn()
|
||||||
host = self.args.hostname or fqdn
|
host = self.args.hostname or fqdn
|
||||||
@@ -421,7 +527,7 @@ exec mariadbd --no-defaults --user=root --datadir=/var/lib/mysql/db --socket=/va
|
|||||||
f"Bereinigung: {state['cleanup']}", f"Fehler: {state['error']}", '', 'Komponenten:']
|
f"Bereinigung: {state['cleanup']}", f"Fehler: {state['error']}", '', 'Komponenten:']
|
||||||
for c in state['components']:
|
for c in state['components']:
|
||||||
body.append(f"{c['label']}: {c['status']}; Archiv {c['archive_bytes']} Bytes; "
|
body.append(f"{c['label']}: {c['status']}; Archiv {c['archive_bytes']} Bytes; "
|
||||||
f"Einträge {c.get('entries', '?')}; Tabellen {c.get('tables', '-')}")
|
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.',
|
body.extend(['', 'Dateien entpackt; enthaltene DB-Dumps isoliert importiert und Tabellen geprüft.',
|
||||||
'Dies ist kein Start-/Funktionstest der produktiven Anwendungen.'])
|
'Dies ist kein Start-/Funktionstest der produktiven Anwendungen.'])
|
||||||
message.set_content('\n'.join(body))
|
message.set_content('\n'.join(body))
|
||||||
@@ -480,11 +586,16 @@ def main():
|
|||||||
parser.add_argument('--expected', action='append', default=[])
|
parser.add_argument('--expected', action='append', default=[])
|
||||||
for key in ('max-age-hours', 'timeout', 'mail-timeout'):
|
for key in ('max-age-hours', 'timeout', 'mail-timeout'):
|
||||||
parser.add_argument('--' + key, type=int, required=True)
|
parser.add_argument('--' + key, type=int, required=True)
|
||||||
|
parser.add_argument('--remote-attempts', type=int, default=3)
|
||||||
|
parser.add_argument('--remote-deadline', type=int, default=3600)
|
||||||
|
parser.add_argument('--remote-retry-delay', type=int, default=30)
|
||||||
for key in ('max-work-gb', 'min-free-gb'):
|
for key in ('max-work-gb', 'min-free-gb'):
|
||||||
parser.add_argument('--' + key, type=float, required=True)
|
parser.add_argument('--' + key, type=float, required=True)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
if not (0 <= args.max_work_gb <= 100000 and 0 <= args.min_free_gb <= 100000 and
|
if not (0 <= args.max_work_gb <= 100000 and 0 <= args.min_free_gb <= 100000 and
|
||||||
1 <= args.max_age_hours <= 8760 and 1 <= args.timeout <= 86400 and 1 <= args.mail_timeout <= 300):
|
1 <= args.max_age_hours <= 8760 and 1 <= args.timeout <= 86400 and 1 <= args.mail_timeout <= 300 and
|
||||||
|
1 <= args.remote_attempts <= 10 and 1 <= args.remote_deadline <= 86400 and
|
||||||
|
0 <= args.remote_retry_delay <= 3600):
|
||||||
parser.error('Invalid restore-test limits')
|
parser.error('Invalid restore-test limits')
|
||||||
if os.geteuid() != 0:
|
if os.geteuid() != 0:
|
||||||
parser.error('Run as root via app-restore-test.sh')
|
parser.error('Run as root via app-restore-test.sh')
|
||||||
@@ -549,8 +660,12 @@ def main():
|
|||||||
test.scratch.mkdir(mode=0o700)
|
test.scratch.mkdir(mode=0o700)
|
||||||
test.execute()
|
test.execute()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
test.state.update(status='FAIL', error=str(exc))
|
context = ''
|
||||||
test.log.error('Restore-test failed: %s', exc)
|
if test.state['components'] and test.state['components'][-1]['status'] == 'RUNNING':
|
||||||
|
current = test.state['components'][-1]
|
||||||
|
context = f"Component {current['label']} / {current.get('phase', '?')}: "
|
||||||
|
test.state.update(status='FAIL', error=context + str(exc))
|
||||||
|
test.log.error('Restore-test failed: %s', test.state['error'])
|
||||||
return test.finish()
|
return test.finish()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import sys
|
|||||||
import tarfile
|
import tarfile
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
|
from types import SimpleNamespace
|
||||||
import unittest
|
import unittest
|
||||||
from email.parser import BytesParser
|
from email.parser import BytesParser
|
||||||
from email import policy
|
from email import policy
|
||||||
@@ -54,6 +55,7 @@ RCLONE_CONFIG="{self.crypt}"
|
|||||||
RCLONE_REMOTE_BASE="crypt:sets"
|
RCLONE_REMOTE_BASE="crypt:sets"
|
||||||
RCLONE_RETRIES=1
|
RCLONE_RETRIES=1
|
||||||
RCLONE_LOW_LEVEL_RETRIES=1
|
RCLONE_LOW_LEVEL_RETRIES=1
|
||||||
|
RCLONE_CHECK_RETRY_DELAY=0
|
||||||
RESTORE_TEST_MIN_FREE_GB=0
|
RESTORE_TEST_MIN_FREE_GB=0
|
||||||
RESTORE_TEST_MAX_WORK_GB=1
|
RESTORE_TEST_MAX_WORK_GB=1
|
||||||
RESTORE_TEST_TIMEOUT=30
|
RESTORE_TEST_TIMEOUT=30
|
||||||
@@ -154,6 +156,131 @@ MAIL_SENDMAIL_BIN="{self.sendmail}"
|
|||||||
self.assertEqual(self.state()['status'],'FAIL')
|
self.assertEqual(self.state()['status'],'FAIL')
|
||||||
self.clean()
|
self.clean()
|
||||||
|
|
||||||
|
def download_failure(self, mode, target='wordpress.tar.gz'):
|
||||||
|
# Real crypt transfers surround injected stream failures. stderr includes
|
||||||
|
# a fake credential to prove that diagnostics never copy raw messages.
|
||||||
|
wrapper = self.base/'test-rclone'
|
||||||
|
counter = self.base/'attempts'
|
||||||
|
wrapper.write_text(f'''#!/usr/bin/env python3
|
||||||
|
import os, pathlib, subprocess, sys, time
|
||||||
|
args = sys.argv[1:]
|
||||||
|
if 'cat' in args and any(a.endswith('/' + {target!r}) for a in args):
|
||||||
|
counter = pathlib.Path({str(counter)!r})
|
||||||
|
attempt = int(counter.read_text()) + 1 if counter.exists() else 1
|
||||||
|
counter.write_text(str(attempt))
|
||||||
|
assert args[args.index('--timeout')+1] == '300s', args
|
||||||
|
assert args[args.index('--low-level-retries')+1] == '3', args
|
||||||
|
mode = {mode!r}
|
||||||
|
if mode == 'signal': sys.exit(143)
|
||||||
|
if mode == 'oversize':
|
||||||
|
sys.stdout.buffer.write(b'x'*1048576)
|
||||||
|
sys.exit(0)
|
||||||
|
if mode == 'hang' or (mode == 'hang-once' and attempt == 1):
|
||||||
|
sys.stdout.buffer.write(b'partial')
|
||||||
|
sys.stdout.buffer.flush()
|
||||||
|
time.sleep(20)
|
||||||
|
if mode in ('always-fail', 'stderr-flood') or (mode == 'fail-once' and attempt == 1):
|
||||||
|
# Larger than the pipe capacity; the supervisor must drain stderr concurrently.
|
||||||
|
sys.stderr.write(('https://example.invalid/?token=SECRET-DO-NOT-LOG HTTP 503 bad magic string\\n') * (2000 if mode == 'stderr-flood' else 1))
|
||||||
|
sys.stdout.buffer.write(b'partial')
|
||||||
|
sys.exit(5)
|
||||||
|
os.execv('/usr/bin/rclone', ['/usr/bin/rclone', *args])
|
||||||
|
''')
|
||||||
|
wrapper.chmod(0o700)
|
||||||
|
with self.config.open('a') as f: f.write(f'RCLONE_BIN="{wrapper}"\n')
|
||||||
|
return counter
|
||||||
|
|
||||||
|
def test_partial_download_restarts_cleanly_and_checks_real_content(self):
|
||||||
|
counter = self.download_failure('fail-once')
|
||||||
|
output = self.execute()
|
||||||
|
self.assertEqual(counter.read_text(),'2')
|
||||||
|
self.assertIn('crypt-header',output)
|
||||||
|
self.assertIn('http-503',output)
|
||||||
|
self.assertIn('exit 5',output)
|
||||||
|
self.assertNotIn('SECRET-DO-NOT-LOG',output)
|
||||||
|
self.assertEqual(self.state()['status'],'SUCCESS')
|
||||||
|
self.assertTrue(all(c['phase']=='complete' for c in self.state()['components']))
|
||||||
|
self.clean()
|
||||||
|
|
||||||
|
def test_partial_metadata_read_is_not_appended_on_retry(self):
|
||||||
|
counter = self.download_failure('fail-once', 'manifest.json')
|
||||||
|
self.execute()
|
||||||
|
self.assertEqual(counter.read_text(),'2')
|
||||||
|
self.assertEqual(self.state()['status'],'SUCCESS')
|
||||||
|
self.clean()
|
||||||
|
|
||||||
|
def test_database_stderr_stays_private_with_operation_and_exit_code(self):
|
||||||
|
rehearsal = restore_test.Rehearsal(SimpleNamespace(workdir=str(self.work), mode='run', timeout=10))
|
||||||
|
with self.assertRaises(restore_test.CommandError) as result:
|
||||||
|
rehearsal.command([sys.executable, '-c', "import sys; sys.stderr.write('SQL-SECRET'); sys.exit(2)"],
|
||||||
|
operation='Database import')
|
||||||
|
self.assertIn('Database import failed (exit 2', str(result.exception))
|
||||||
|
self.assertNotIn('SQL-SECRET', str(result.exception))
|
||||||
|
self.assertNotIn('SQL-SECRET', (self.root/'restore-test.log').read_text())
|
||||||
|
self.clean()
|
||||||
|
|
||||||
|
def test_persistent_download_error_reports_phase_and_preserves_previous_success(self):
|
||||||
|
self.execute()
|
||||||
|
previous = (self.root/'last-success.json').read_bytes()
|
||||||
|
counter = self.download_failure('always-fail')
|
||||||
|
output = self.execute(ok=False)
|
||||||
|
self.assertEqual(counter.read_text(),'3')
|
||||||
|
self.assertIn('Component wordpress / download: Remote cat failed (exit 5',output)
|
||||||
|
self.assertIn('crypt-header',self.state()['error'])
|
||||||
|
self.assertEqual((self.root/'last-success.json').read_bytes(),previous)
|
||||||
|
for path in ['state.json','restore-test.log','report.eml']:
|
||||||
|
self.assertNotIn('SECRET-DO-NOT-LOG',(self.root/path).read_text())
|
||||||
|
self.clean()
|
||||||
|
|
||||||
|
def test_remote_stderr_flood_does_not_deadlock_or_leak(self):
|
||||||
|
counter = self.download_failure('stderr-flood')
|
||||||
|
output = self.execute(ok=False)
|
||||||
|
self.assertEqual(counter.read_text(),'3')
|
||||||
|
self.assertIn('http-503',output)
|
||||||
|
self.assertNotIn('SECRET-DO-NOT-LOG',output)
|
||||||
|
self.assertLess(len(output),12000)
|
||||||
|
self.clean()
|
||||||
|
|
||||||
|
def test_stalled_download_retries_from_empty_file(self):
|
||||||
|
counter = self.download_failure('hang-once')
|
||||||
|
with self.config.open('a') as f: f.write('RCLONE_CHECK_DEADLINE=2\n')
|
||||||
|
output = self.execute()
|
||||||
|
self.assertIn('deadline exceeded',output)
|
||||||
|
self.assertEqual(counter.read_text(),'2')
|
||||||
|
self.clean()
|
||||||
|
|
||||||
|
def test_stalled_download_exhausts_attempts_and_cleans(self):
|
||||||
|
counter = self.download_failure('hang')
|
||||||
|
with self.config.open('a') as f: f.write('RCLONE_CHECK_DEADLINE=2\nRCLONE_CHECK_ATTEMPTS=2\n')
|
||||||
|
self.execute(ok=False)
|
||||||
|
self.assertEqual(counter.read_text(),'2')
|
||||||
|
self.assertIn('deadline exceeded',self.state()['error'])
|
||||||
|
self.assertEqual(self.state()['cleanup'],'OK')
|
||||||
|
self.clean()
|
||||||
|
|
||||||
|
def test_download_signal_does_not_retry(self):
|
||||||
|
counter = self.download_failure('signal')
|
||||||
|
self.execute(ok=False)
|
||||||
|
self.assertEqual(counter.read_text(),'1')
|
||||||
|
self.assertIn('exit 143',self.state()['error'])
|
||||||
|
self.clean()
|
||||||
|
|
||||||
|
def test_oversized_download_does_not_retry(self):
|
||||||
|
counter = self.download_failure('oversize')
|
||||||
|
self.execute(ok=False)
|
||||||
|
self.assertEqual(counter.read_text(),'1')
|
||||||
|
self.assertIn('exceeds expected size',self.state()['error'])
|
||||||
|
self.clean()
|
||||||
|
|
||||||
|
def test_global_timeout_stops_download_retry_backoff(self):
|
||||||
|
counter = self.download_failure('always-fail')
|
||||||
|
with self.config.open('a') as f:
|
||||||
|
f.write('RESTORE_TEST_TIMEOUT=5\nRCLONE_CHECK_RETRY_DELAY=10\n')
|
||||||
|
self.execute(ok=False)
|
||||||
|
self.assertEqual(counter.read_text(),'1')
|
||||||
|
self.assertIn('time limit exceeded',self.state()['error'])
|
||||||
|
self.clean()
|
||||||
|
|
||||||
def test_disk_budget_refuses_work_and_cleans(self):
|
def test_disk_budget_refuses_work_and_cleans(self):
|
||||||
with self.config.open('a') as f: f.write('RESTORE_TEST_MAX_WORK_GB=0.000001\n')
|
with self.config.open('a') as f: f.write('RESTORE_TEST_MAX_WORK_GB=0.000001\n')
|
||||||
self.execute(ok=False)
|
self.execute(ok=False)
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ args=("$MODE" --workdir "$WORKDIR" --prefix "$ARCHIVE_PREFIX" --remote "$RCLONE_
|
|||||||
--run "$REMOTE_RUN" --max-age-hours "$RESTORE_TEST_MAX_AGE_HOURS"
|
--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"
|
--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"
|
--timeout "$RESTORE_TEST_TIMEOUT" --image "$RESTORE_TEST_DB_IMAGE"
|
||||||
|
--remote-attempts "$RCLONE_CHECK_ATTEMPTS" --remote-retry-delay "$RCLONE_CHECK_RETRY_DELAY"
|
||||||
|
--remote-deadline "$RCLONE_CHECK_DEADLINE"
|
||||||
--hostname "$MAIL_HOSTNAME" --to "$MAIL_TO" --sender "$MAIL_FROM"
|
--hostname "$MAIL_HOSTNAME" --to "$MAIL_TO" --sender "$MAIL_FROM"
|
||||||
--sendmail "$MAIL_SENDMAIL_BIN" --mail-timeout "$MAIL_SEND_TIMEOUT" --mail "$ENABLE_MAIL_REPORT")
|
--sendmail "$MAIL_SENDMAIL_BIN" --mail-timeout "$MAIL_SEND_TIMEOUT" --mail "$ENABLE_MAIL_REPORT")
|
||||||
if [[ $MODE != cleanup ]]; then
|
if [[ $MODE != cleanup ]]; then
|
||||||
|
|||||||
Reference in new issue
Block a user