--salvage for backup:restore and backup:restore:full: restore what can be read from a damaged backup
A file whose data cannot be read or decrypted is left out and listed, and the command exits with code 2. When the database cannot be restored (its dump is damaged, or it does not fit this installation), backup:restore:full leaves the fresh database as it is and puts the data directory and config of the backup in ngb-salvage-<snapshot> in the data directory. Without --salvage a restore of damaged data stops as before and points at the option.
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\NgBackup\Tests\Unit\Repository;
|
||||
|
||||
use OCA\NgBackup\Backend\LocalBackend;
|
||||
use OCA\NgBackup\Crypto\KeyRing;
|
||||
use OCA\NgBackup\Repository\Repository;
|
||||
use OCA\NgBackup\Tests\Unit\TempDirTrait;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* --salvage: a file whose data is damaged on the location is left out and listed, the rest of the
|
||||
* restore goes on. Without it, the restore stops at that file.
|
||||
*/
|
||||
class SalvageTest extends TestCase {
|
||||
use TempDirTrait;
|
||||
|
||||
private string $root;
|
||||
private KeyRing $keys;
|
||||
private string $src;
|
||||
private string $snapshot;
|
||||
|
||||
protected function setUp(): void {
|
||||
$this->root = $this->tempDir();
|
||||
$this->keys = KeyRing::generate();
|
||||
$this->src = $this->tempDir();
|
||||
mkdir("$this->src/docs");
|
||||
// Incompressible, 2 MiB each: every file is a blob of its own in one pack
|
||||
foreach (['a', 'b', 'c'] as $name) {
|
||||
file_put_contents("$this->src/docs/$name.bin", random_bytes(2 * 1048576));
|
||||
}
|
||||
file_put_contents("$this->src/note.txt", "small\n");
|
||||
$repo = Repository::initWithKey(new LocalBackend($this->root), $this->keys, $this->keys->wrap('pw'));
|
||||
$this->snapshot = $repo->backupDirectory($this->src)['snapshot'];
|
||||
}
|
||||
|
||||
/** Flip bytes in the middle of the (only) pack: inside one of the 2 MiB files. */
|
||||
private function damagePack(): void {
|
||||
$packs = glob($this->root . '/packs/*/*');
|
||||
$this->assertCount(1, $packs);
|
||||
$fh = fopen($packs[0], 'r+b');
|
||||
$middle = intdiv(filesize($packs[0]), 2);
|
||||
fseek($fh, $middle);
|
||||
$bytes = fread($fh, 16);
|
||||
fseek($fh, $middle);
|
||||
fwrite($fh, ~$bytes);
|
||||
fclose($fh);
|
||||
}
|
||||
|
||||
public function testSalvageLeavesOutTheDamagedFileAndRestoresTheRest(): void {
|
||||
$this->damagePack();
|
||||
$target = $this->tempDir();
|
||||
$skipped = [];
|
||||
|
||||
$restored = Repository::openWithKey(new LocalBackend($this->root), $this->keys)
|
||||
->restore($this->snapshot, $target, '', false, null, $skipped);
|
||||
|
||||
$this->assertSame(3, $restored);
|
||||
$this->assertCount(1, $skipped);
|
||||
$damaged = array_key_first($skipped);
|
||||
$this->assertMatchesRegularExpression('#^docs/[abc]\.bin$#', $damaged);
|
||||
$this->assertFileDoesNotExist("$target/$damaged");
|
||||
foreach (['docs/a.bin', 'docs/b.bin', 'docs/c.bin', 'note.txt'] as $file) {
|
||||
if ($file !== $damaged) {
|
||||
$this->assertFileEquals("$this->src/$file", "$target/$file");
|
||||
}
|
||||
}
|
||||
$this->assertSame([], glob("$target/docs/*.ngb-restore-*"), 'no temporary left of the damaged file');
|
||||
}
|
||||
|
||||
public function testWithoutSalvageTheRestoreStopsAtTheDamagedFile(): void {
|
||||
$this->damagePack();
|
||||
|
||||
$this->expectException(\Exception::class);
|
||||
Repository::openWithKey(new LocalBackend($this->root), $this->keys)->restore($this->snapshot, $this->tempDir());
|
||||
}
|
||||
|
||||
public function testSalvageOfAnUndamagedSnapshotSkipsNothing(): void {
|
||||
$target = $this->tempDir();
|
||||
$skipped = [];
|
||||
|
||||
$this->assertSame(4, Repository::openWithKey(new LocalBackend($this->root), $this->keys)
|
||||
->restore($this->snapshot, $target, '', false, null, $skipped));
|
||||
$this->assertSame([], $skipped);
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,15 @@
|
||||
# Disaster recovery: occ backup:key:import + backup:target:add + backup:restore:full on a
|
||||
# SECOND, fresh Nextcloud instance that never saw the first one, sharing only the backup
|
||||
# location (a local directory, bind-mounted into both containers) -- see PLAN.md 3.7.
|
||||
# Usage: full-restore-roundtrip.sh <nextcloud-image-tag> [sqlite|pgsql|mysql] [legacy|sftp|to-pgsql|to-mysql|to-sqlite]
|
||||
# Usage: full-restore-roundtrip.sh <nextcloud-image-tag> [sqlite|pgsql|mysql] [legacy|sftp|salvage|to-pgsql|to-mysql|to-sqlite]
|
||||
# e.g. full-restore-roundtrip.sh 35-apache pgsql
|
||||
# With "sftp" the location is an SFTP server instead of a shared directory, and the origin has a
|
||||
# file spread over several packs: the restore reads them ahead on connections of its own.
|
||||
# With "to-<db>" the restore instance runs that database whatever the origin runs (a MySQL or
|
||||
# SQLite backup records no ID counters; after the restore new rows must not collide with restored
|
||||
# ones, whatever the database on either side).
|
||||
# With "salvage" the snapshot's database dump is damaged on the location: the restore must stop
|
||||
# without changing anything, and with --salvage put the files in a folder of their own.
|
||||
# With "legacy" the origin runs NG Backup 1.0.0-rc.1 (0.9.7), whose backups do not record the
|
||||
# structure of their tables: the restore must then leave the tables of the disabled app out
|
||||
# with --skip-missing-tables, as for a real backup made before 1.0.0-rc.2.
|
||||
@@ -135,6 +137,12 @@ RUN_OUT=$(run_on ngb-frt-origin backup:run offsite -l disaster-test)
|
||||
SNAP=$(printf '%s\n' "$RUN_OUT" | head -1 | awk -F'snapshot ' '{print $2}' | tr -d ',')
|
||||
[ -n "$SNAP" ] || fail "no snapshot id from backup:run"
|
||||
ok "test data backed up, snapshot $SNAP"
|
||||
if [ "$MODE" = salvage ]; then
|
||||
# The database dump of this snapshot damaged on the location: a byte flipped in its manifest
|
||||
docker exec -u www-data ngb-frt-origin php -r '$p = "/mnt/shared/repo/db/" . $argv[1]; $d = file_get_contents($p); $i = intdiv(strlen($d), 2); $d[$i] = ~$d[$i]; file_put_contents($p, $d);' "$SNAP" \
|
||||
|| fail "damage the database dump"
|
||||
ok "database dump of the snapshot damaged on the location"
|
||||
fi
|
||||
|
||||
docker cp ngb-frt-origin:/tmp/kit.json /tmp/ngb-frt-kit.json
|
||||
docker exec ngb-frt-origin sh -c "cat /tmp/pass" > /tmp/ngb-frt-pass
|
||||
@@ -164,6 +172,30 @@ printf '%s\n' "$LIST_OUT" | grep -q "$SNAP" || {
|
||||
}
|
||||
ok "recovery kit imported, existing repository adopted, snapshot visible"
|
||||
|
||||
if [ "$MODE" = salvage ]; then
|
||||
set +e
|
||||
OUT=$(run_on ngb-frt-restore backup:restore:full offsite "$SNAP" --no-interaction 2>&1); CODE=$?
|
||||
set -e
|
||||
[ "$CODE" = 1 ] || { printf '%s\n' "$OUT" | tail -20; fail "a restore with a damaged database dump did not stop (exit $CODE)"; }
|
||||
printf '%s\n' "$OUT" | grep -- "--salvage" >/dev/null || { printf '%s\n' "$OUT" | tail -20; fail "the stopped restore does not point at --salvage"; }
|
||||
run_on ngb-frt-restore status 2>&1 | grep "maintenance: false" >/dev/null || fail "maintenance mode stayed on after a restore that changed nothing"
|
||||
ok "damaged database dump: the restore stops before changing anything and points at --salvage"
|
||||
set +e
|
||||
OUT=$(run_on ngb-frt-restore backup:restore:full offsite "$SNAP" --no-interaction --salvage 2>&1); CODE=$?
|
||||
set -e
|
||||
[ "$CODE" = 2 ] || { printf '%s\n' "$OUT" | tail -30; fail "restore:full --salvage exited with $CODE, not 2"; }
|
||||
SALVAGE=/var/www/html/data/ngb-salvage-${SNAP:0:12}
|
||||
printf '%s\n' "$OUT" | grep "$SALVAGE" >/dev/null || { printf '%s\n' "$OUT" | tail -30; fail "the output does not name the salvage folder"; }
|
||||
[ "$(docker exec ngb-frt-restore cat "$SALVAGE/data/frtest/files/Docs/note.txt")" = "hello disaster recovery" ] || fail "the user's file is not in the salvage folder"
|
||||
docker exec ngb-frt-restore test -f "$SALVAGE/config/config.php" || fail "config.php is not in the salvage folder"
|
||||
run_on ngb-frt-restore user:list | grep frtest >/dev/null && fail "the fresh database was changed (the backed-up user is there)"
|
||||
run_on ngb-frt-restore status 2>&1 | grep "maintenance: false" >/dev/null || fail "maintenance mode is still on after the salvage"
|
||||
run_on ngb-frt-restore backup:status >/dev/null || fail "backup:status fails after the salvage"
|
||||
ok "with --salvage: the fresh database untouched, data and config of the backup in $SALVAGE (exit 2)"
|
||||
echo "FULL RESTORE ROUNDTRIP PASS ($DB, salvage)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
RESTORE_OPTS=(--no-interaction --install-apps)
|
||||
if [ "$LEGACY" = legacy ] && [ "$DB" != sqlite ]; then
|
||||
# Without --skip-missing-tables an older backup must be refused, naming the app's tables
|
||||
|
||||
Reference in New Issue
Block a user