Empty folders in backup and restore, a full disk stops a restore, progress in the Bin
- Record folders without files in the meta record of a snapshot and restore them (#27) - Errors writing to the restore directory stop the restore, also with --salvage - Resume: check the folder before skipping, clean temporaries in folders that are complete - Clean only read-ahead folders of our own; refresh the lease on skipped files - Drop tables a failed database restore created; keep --salvage from calling a restored database fresh - Retry a pack that fails to open on the storage (SMB) - Without cron, running backups take turns from the open page - Bin: read in parts with a counter and progress bar, show read errors, refresh when a run ends
This commit is contained in:
@@ -124,7 +124,13 @@ final class StorageBackend implements IBackend, IReadsAhead {
|
||||
// null: not supported or failing for now; false: not opened, which need not mean
|
||||
// missing. Either way the storage's own stream decides.
|
||||
}
|
||||
$fh = $this->storage->fopen($this->abs($path), 'r');
|
||||
try {
|
||||
$fh = $this->storage->fopen($this->abs($path), 'r');
|
||||
} catch (\Exception $e) {
|
||||
// Some storages throw for what is not a missing file (SMB: a connection that broke).
|
||||
// As a BackendException, the caller reads once more and --salvage can go on.
|
||||
throw new BackendException('Cannot read ' . $path . ': ' . $e->getMessage(), 0, $e);
|
||||
}
|
||||
if ($fh === false) {
|
||||
throw new BackendException('Not found: ' . $path);
|
||||
}
|
||||
|
||||
@@ -82,6 +82,8 @@ final class AdminApiController extends Controller {
|
||||
|
||||
/** Seconds of backup work one tick() does: well within a normal web request. */
|
||||
public const TICK_SECONDS = 10.0;
|
||||
/** Seconds one request for the Bin reads, before the page shows how far it is and asks for more. */
|
||||
private const TRASH_PAGE_SECONDS = 2.0;
|
||||
|
||||
/**
|
||||
* Move running backups forward from the open admin page, when background jobs do not run on a
|
||||
@@ -95,7 +97,10 @@ final class AdminApiController extends Controller {
|
||||
}
|
||||
$deadline = microtime(true) + self::TICK_SECONDS;
|
||||
$stepped = 0;
|
||||
foreach ($this->runs->findRunning(null, BackupService::KIND_FULL) as $run) {
|
||||
$running = $this->runs->findRunning(null, BackupService::KIND_FULL);
|
||||
// Longest wait first: a run that takes all of a tick does not keep another from its turn.
|
||||
usort($running, static fn ($a, $b) => $a->getUpdatedAt() <=> $b->getUpdatedAt());
|
||||
foreach ($running as $run) {
|
||||
if (microtime(true) >= $deadline) {
|
||||
break;
|
||||
}
|
||||
@@ -174,15 +179,18 @@ final class AdminApiController extends Controller {
|
||||
}
|
||||
}
|
||||
|
||||
public function trash(int $targetId): JSONResponse {
|
||||
public function trash(int $targetId, int $offset = 0): JSONResponse {
|
||||
try {
|
||||
$delay = $this->keys->deleteDelayDays() * 86400;
|
||||
$repo = $this->targets->repository($this->targets->get((string)$targetId));
|
||||
// In parts, so that the page can show how far it is (a read per removed restore point).
|
||||
$page = $repo->trashPage($offset, microtime(true) + self::TRASH_PAGE_SECONDS);
|
||||
$out = [];
|
||||
foreach ($this->targets->repository($this->targets->get((string)$targetId))->trash() as $id => $info) {
|
||||
foreach ($page['items'] as $id => $info) {
|
||||
$out[] = ['id' => $id, 'label' => $info['label'], 'time' => $info['time'], 'removedAt' => $info['forgottenAt'],
|
||||
'by' => $info['by'], 'deleteAfter' => $info['forgottenAt'] + $delay];
|
||||
}
|
||||
return new JSONResponse($out);
|
||||
return new JSONResponse(['total' => $page['total'], 'items' => $out]);
|
||||
} catch (\Throwable $e) {
|
||||
return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
+29
-6
@@ -157,13 +157,18 @@ final class DbDumper {
|
||||
}
|
||||
// Tables the backup knows the structure of are created first (DDL is not transactional
|
||||
// everywhere); tables it does not know are left out, as asked.
|
||||
foreach ($missing as $table) {
|
||||
if (isset($manifest['tables'][$table]['schema'])) {
|
||||
$this->db->createSchemaManager()->createTable(self::tableFromStructure($table, $manifest['tables'][$table]['schema'], $this->tableOptions()));
|
||||
$report['created'][] = $table;
|
||||
} else {
|
||||
$report['missing'][] = $table;
|
||||
try {
|
||||
foreach ($missing as $table) {
|
||||
if (isset($manifest['tables'][$table]['schema'])) {
|
||||
$this->db->createSchemaManager()->createTable(self::tableFromStructure($table, $manifest['tables'][$table]['schema'], $this->tableOptions()));
|
||||
$report['created'][] = $table;
|
||||
} else {
|
||||
$report['missing'][] = $table;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->dropCreatedTables($report);
|
||||
throw $e;
|
||||
}
|
||||
$total = 0;
|
||||
$foreignKeys = [];
|
||||
@@ -242,11 +247,29 @@ final class DbDumper {
|
||||
if (in_array('SET FOREIGN_KEY_CHECKS = 1', $foreignKeys, true)) {
|
||||
$this->db->executeStatement('SET FOREIGN_KEY_CHECKS = 1');
|
||||
}
|
||||
$this->dropCreatedTables($report);
|
||||
throw $e;
|
||||
}
|
||||
return $total;
|
||||
}
|
||||
|
||||
/**
|
||||
* The restore did not go through: a rollback does not undo CREATE TABLE everywhere. Left behind,
|
||||
* the empty tables of an app would stop that app's installation later ("table already exists").
|
||||
*
|
||||
* @param array{created: list<string>, ...} $report
|
||||
*/
|
||||
private function dropCreatedTables(array &$report): void {
|
||||
foreach ($report['created'] as $table) {
|
||||
try {
|
||||
$this->db->createSchemaManager()->dropTable($table);
|
||||
} catch (\Throwable) {
|
||||
// the error that brought us here is the one to report; a run again uses the table
|
||||
}
|
||||
}
|
||||
$report['created'] = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* The table prefix of the instance the dump was made on (from its appconfig table), or null
|
||||
* when the dump has none.
|
||||
|
||||
+33
-1
@@ -55,9 +55,28 @@ final class BackupRun {
|
||||
});
|
||||
$parent = new ParentCursor($repo, $state['parent'], $state['parentPos']);
|
||||
$lines = [];
|
||||
$emptyDirs = [];
|
||||
$finishedWalk = true;
|
||||
|
||||
foreach ($walker->walk($state['after']) as $logical => $path) {
|
||||
if (is_dir($path)) {
|
||||
$mtime = @filemtime($path);
|
||||
if ($mtime === false) {
|
||||
continue; // removed while walking
|
||||
}
|
||||
if (mb_check_encoding($logical, 'UTF-8')) {
|
||||
$emptyDirs[] = json_encode([$logical, $mtime], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
|
||||
$stats['emptyDirs'] = ($stats['emptyDirs'] ?? 0) + 1;
|
||||
} else {
|
||||
self::recordUnreadable($state, $stats, $logical); // a snapshot only holds UTF-8
|
||||
}
|
||||
$state['after'] = $logical;
|
||||
if (microtime(true) >= $deadline) {
|
||||
$finishedWalk = false;
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
$version = Repository::fileVersion($path);
|
||||
if ($version === 'missing') {
|
||||
continue; // deleted while walking
|
||||
@@ -136,6 +155,11 @@ final class BackupRun {
|
||||
$writer->write(implode("\n", $lines) . "\n");
|
||||
$state['segments'][] = $writer->finish();
|
||||
}
|
||||
if ($emptyDirs !== []) {
|
||||
$writer = $repo->blobWriter($stats);
|
||||
$writer->write(implode("\n", $emptyDirs) . "\n");
|
||||
$state['dirSegments'][] = $writer->finish();
|
||||
}
|
||||
$repo->flushPacks();
|
||||
$state['parentPos'] = $parent->position();
|
||||
$state['stats'] = $stats;
|
||||
@@ -150,10 +174,18 @@ final class BackupRun {
|
||||
if ($state['phase'] === 'finish') {
|
||||
// Every segment ends with a newline, so they read as one stream: fetched together,
|
||||
// several at a time where the location allows it.
|
||||
// Empty folders go in the meta record, not between the files: a version that does not
|
||||
// know them reads the snapshot as before, and only restores no empty folders.
|
||||
$emptyDirs = [];
|
||||
foreach (isset($state['dirSegments']) ? $repo->readLines(array_values(array_merge(...$state['dirSegments']))) : [] as $line) {
|
||||
if ($line !== '') {
|
||||
$emptyDirs[] = json_decode($line, true, 8, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
}
|
||||
$repo->putSnapshot($state['snapshot'],
|
||||
['time' => $state['started'], 'parent' => $state['parent'], 'label' => $state['label'],
|
||||
'roots' => array_keys($state['roots']), 'stats' => $state['stats'],
|
||||
'warnings' => self::warnings($state)] + $state['meta'],
|
||||
'warnings' => self::warnings($state)] + ($emptyDirs === [] ? [] : ['emptyDirs' => $emptyDirs]) + $state['meta'],
|
||||
$repo->readLines(array_values(array_merge(...$state['segments']))));
|
||||
$state['phase'] = 'commit';
|
||||
}
|
||||
|
||||
+11
-2
@@ -9,7 +9,9 @@ namespace OCA\NgBackup\Job;
|
||||
|
||||
/**
|
||||
* Walks one or more source roots in a stable depth-first order (entries of a directory sorted
|
||||
* by name, children right after their directory), yielding "<root>/<relative path>" for files.
|
||||
* by name, children right after their directory), yielding "<root>/<relative path>" for files
|
||||
* and for directories with nothing in them to walk (the caller tells them apart with is_dir()).
|
||||
* A root itself without a name is never yielded.
|
||||
*
|
||||
* The same order is used for comparing paths (compare()), so a walk can resume after a cursor
|
||||
* and be merge-joined with the previous snapshot without holding any list in memory.
|
||||
@@ -66,6 +68,7 @@ final class TreeWalker {
|
||||
}
|
||||
$names = array_values(array_diff($names, ['.', '..']));
|
||||
sort($names, SORT_STRING);
|
||||
$empty = true; // no file or directory below it that is walked, whatever the cursor skips
|
||||
foreach ($names as $name) {
|
||||
$childLogical = $logical === '' ? $name : $logical . '/' . $name;
|
||||
$childAbs = $abs . '/' . $name;
|
||||
@@ -73,20 +76,26 @@ final class TreeWalker {
|
||||
continue; // never follow symlinks out of the data directory
|
||||
}
|
||||
if (is_dir($childAbs)) {
|
||||
$empty = $empty && $this->isExcluded($childLogical, true);
|
||||
// Skip a directory entirely if everything in it sorts before the cursor.
|
||||
if ($after !== null && self::compare($childLogical, $after) < 0 && !self::isPrefix($childLogical, $after)) {
|
||||
continue;
|
||||
}
|
||||
yield from $this->walkDir($childAbs, $childLogical, $after);
|
||||
} elseif (is_file($childAbs)) {
|
||||
$excluded = $this->isExcluded($childLogical, false);
|
||||
$empty = $empty && $excluded;
|
||||
if ($after !== null && self::compare($childLogical, $after) <= 0) {
|
||||
continue;
|
||||
}
|
||||
if (!$this->isExcluded($childLogical, false)) {
|
||||
if (!$excluded) {
|
||||
yield $childLogical => $childAbs;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($empty && $logical !== '' && ($after === null || self::compare($logical, $after) > 0)) {
|
||||
yield $logical => $abs;
|
||||
}
|
||||
}
|
||||
|
||||
private function isExcluded(string $logical, bool $isDir): bool {
|
||||
|
||||
+131
-21
@@ -44,6 +44,8 @@ final class Repository {
|
||||
private ?int $maxBytes = null;
|
||||
private Catalog $catalog;
|
||||
private PackReader $packReader;
|
||||
/** @var array<string, array<string, mixed>> meta record of the snapshot read last */
|
||||
private array $lastMeta = [];
|
||||
/** verify() lists the pack folders from this many packs on (see presentPacks()); tests lower it. */
|
||||
public int $listPacksAbove = 256;
|
||||
|
||||
@@ -257,8 +259,8 @@ final class Repository {
|
||||
// that was being written when the process was killed stays (8 MiB, DR test 2026-11-05)
|
||||
$stale = glob(rtrim($target, '/') . '/.ngb-restore-spool-*', GLOB_ONLYDIR);
|
||||
foreach ($stale === false ? [] : $stale as $dir) {
|
||||
if (is_link($dir)) {
|
||||
continue;
|
||||
if (is_link($dir) || preg_match('/^\.ngb-restore-spool-[0-9a-f]{8}$/', basename($dir)) !== 1) {
|
||||
continue; // not a spool of ours, whatever it is called
|
||||
}
|
||||
$files = glob($dir . '/*');
|
||||
foreach ($files === false ? [] : $files as $file) {
|
||||
@@ -313,40 +315,39 @@ final class Repository {
|
||||
if (!self::underPrefix($entry['p'], $prefix)) {
|
||||
continue;
|
||||
}
|
||||
if ($heartbeat !== null) {
|
||||
$heartbeat(); // also for a long row of files that are skipped or have no data
|
||||
}
|
||||
$dest = self::destination($target, $entry['p'], $prefix, $stripPrefix);
|
||||
if ($keepUnchanged && self::inPlace($dest, $entry)) {
|
||||
// below the target as written by an earlier run of this restore (checked then)
|
||||
$count++;
|
||||
continue;
|
||||
}
|
||||
if (!is_dir(dirname($dest))) {
|
||||
mkdir(dirname($dest), 0700, true);
|
||||
}
|
||||
// Defence in depth: the resolved directory must stay below the target (no symlink escapes).
|
||||
$base = realpath($target);
|
||||
$dir = realpath(dirname($dest));
|
||||
if ($base === false || $dir === false || ($dir !== $base && !str_starts_with($dir, $base . '/')) || is_link($dest)) {
|
||||
throw new RepositoryException('Refusing to write outside the restore directory: ' . $entry['p']);
|
||||
$dir = self::directoryInTarget($target, dirname($dest), $entry['p']);
|
||||
if (is_link($dest)) {
|
||||
throw new RestoreTargetException('Refusing to write outside the restore directory: ' . $entry['p']);
|
||||
}
|
||||
if (!isset($cleaned[$dir])) {
|
||||
self::removeStaleTemporaries($dir);
|
||||
$cleaned[$dir] = true;
|
||||
}
|
||||
if ($keepUnchanged && self::inPlace($dest, $entry)) {
|
||||
$count++;
|
||||
continue;
|
||||
}
|
||||
// Write next to the destination and rename after all blobs verified: at most one file
|
||||
// is ever present twice.
|
||||
$tmp = $dest . '.ngb-restore-' . bin2hex(random_bytes(6));
|
||||
$out = @fopen($tmp, 'xb'); // fails if anything (including a symlink) already exists there
|
||||
if ($out === false) {
|
||||
throw new RepositoryException('Cannot create a temporary file for ' . $entry['p']);
|
||||
throw new RestoreTargetException('Cannot create a temporary file for ' . $entry['p'] . ' (disk full, or no permission?)');
|
||||
}
|
||||
try {
|
||||
foreach ($entry['b'] as $id) {
|
||||
fwrite($out, $this->restoreBlob($id, $plan));
|
||||
self::writeAll($out, $this->restoreBlob($id, $plan), $entry['p']);
|
||||
if ($heartbeat !== null) {
|
||||
$heartbeat();
|
||||
}
|
||||
}
|
||||
fclose($out);
|
||||
if (!@fclose($out)) {
|
||||
throw new RestoreTargetException('Cannot write ' . $entry['p'] . ' to the restore directory (disk full?)');
|
||||
}
|
||||
clearstatcache(true, $tmp);
|
||||
if (filesize($tmp) !== $entry['s']) {
|
||||
throw new RepositoryException('Size mismatch restoring ' . $entry['p']);
|
||||
@@ -363,19 +364,102 @@ final class Repository {
|
||||
}
|
||||
$skipped[$entry['p']] = $e->getMessage();
|
||||
continue;
|
||||
} catch (\Throwable $e) {
|
||||
if (is_resource($out)) {
|
||||
@fclose($out);
|
||||
}
|
||||
@unlink($tmp);
|
||||
throw $e;
|
||||
}
|
||||
if (!rename($tmp, $dest)) {
|
||||
@unlink($tmp);
|
||||
throw new RepositoryException('Cannot put the restored file in place: ' . $entry['p']);
|
||||
throw new RestoreTargetException('Cannot put the restored file in place: ' . $entry['p']);
|
||||
}
|
||||
if (!touch($dest, $entry['m'])) {
|
||||
throw new RepositoryException('Cannot set the modification time of ' . $entry['p']);
|
||||
throw new RestoreTargetException('Cannot set the modification time of ' . $entry['p']);
|
||||
}
|
||||
$count++;
|
||||
}
|
||||
// Last: a file can only be restored into a folder that exists, and the meta record was
|
||||
// read along with the files, so this asks nothing more of the location.
|
||||
foreach ($this->emptyDirs($snapshotId) as [$path, $mtime]) {
|
||||
if (self::underPrefix($path, $prefix)) {
|
||||
self::restoreEmptyDir($target, self::destination($target, $path, $prefix, $stripPrefix), $path, $mtime);
|
||||
}
|
||||
}
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* The folders without anything in them that a snapshot recorded (none before 1.0.3).
|
||||
*
|
||||
* @return list<array{0: string, 1: int}> path, modification time
|
||||
*/
|
||||
public function emptyDirs(string $snapshotId): array {
|
||||
$meta = $this->lastMeta[$snapshotId] ?? $this->snapshotMeta($snapshotId);
|
||||
$dirs = $meta['emptyDirs'] ?? [];
|
||||
foreach (is_array($dirs) ? $dirs : [null] as $dir) {
|
||||
if (!is_array($dir) || !is_string($dir[0] ?? null) || !self::isSafePath($dir[0]) || !is_int($dir[1] ?? null)) {
|
||||
throw new RepositoryException('Invalid empty folder in snapshot ' . $snapshotId);
|
||||
}
|
||||
}
|
||||
/** @var list<array{0: string, 1: int}> */
|
||||
return $dirs;
|
||||
}
|
||||
|
||||
private static function restoreEmptyDir(string $target, string $dest, string $path, int $mtime): void {
|
||||
if (is_link($dest)) {
|
||||
throw new RestoreTargetException('Refusing to write outside the restore directory: ' . $path);
|
||||
}
|
||||
$existed = is_dir($dest);
|
||||
self::directoryInTarget($target, $dest, $path);
|
||||
if (!$existed && !@touch($dest, $mtime)) {
|
||||
throw new RestoreTargetException('Cannot set the modification time of ' . $path);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure a folder exists below the target, without following a link out of it: what exists
|
||||
* of the path is checked before anything is created.
|
||||
*
|
||||
* @return string the folder's real path
|
||||
*/
|
||||
private static function directoryInTarget(string $target, string $dir, string $path): string {
|
||||
if (!is_dir($target) && !@mkdir($target, 0700, true) && !is_dir($target)) {
|
||||
throw new RestoreTargetException('Cannot create the restore directory ' . $target);
|
||||
}
|
||||
$base = realpath($target);
|
||||
$existing = $dir;
|
||||
while (!file_exists($existing) && !is_link($existing) && dirname($existing) !== $existing) {
|
||||
$existing = dirname($existing);
|
||||
}
|
||||
$real = realpath($existing);
|
||||
if ($base === false || $real === false || ($real !== $base && !str_starts_with($real, $base . '/'))) {
|
||||
throw new RestoreTargetException('Refusing to write outside the restore directory: ' . $path);
|
||||
}
|
||||
if ($existing === $dir) {
|
||||
if (!is_dir($real)) {
|
||||
throw new RestoreTargetException('Cannot create the folder for ' . $path . ': a file is in the way');
|
||||
}
|
||||
return $real;
|
||||
}
|
||||
if (!@mkdir($dir, 0700, true) && !is_dir($dir)) {
|
||||
throw new RestoreTargetException('Cannot create the folder for ' . $path . ' (disk full, no permission, or a file in the way?)');
|
||||
}
|
||||
return (string)realpath($dir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write all of $data, or stop: a short write leaves a file that looks damaged only by its size.
|
||||
*
|
||||
* @param resource $out
|
||||
*/
|
||||
public static function writeAll($out, string $data, string $path): void {
|
||||
if (@fwrite($out, $data) !== strlen($data)) {
|
||||
throw new RestoreTargetException('Cannot write ' . $path . ' to the restore directory (disk full?)');
|
||||
}
|
||||
}
|
||||
|
||||
/** Where restore() writes the snapshot path $path. */
|
||||
private static function destination(string $target, string $path, string $prefix, bool $stripPrefix): string {
|
||||
$relPath = $path;
|
||||
@@ -722,6 +806,29 @@ final class Repository {
|
||||
return array_keys($doomed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Part of trash(), for a page that shows how far it is: each entry is a read of its own on the
|
||||
* location. Reads on from $offset until $deadline (microtime(true)), at least one entry.
|
||||
*
|
||||
* @return array{total: int, items: array<string, array<string, mixed>>}
|
||||
*/
|
||||
public function trashPage(int $offset, float $deadline): array {
|
||||
$ids = array_map('strval', array_keys($this->catalog->trash));
|
||||
sort($ids, SORT_STRING);
|
||||
$items = [];
|
||||
foreach (array_slice($ids, max(0, $offset)) as $id) {
|
||||
try {
|
||||
$items[$id] = $this->trashInfo($id);
|
||||
} catch (\Throwable) {
|
||||
throw new RepositoryException("Trash entry $id listed in the catalog is missing on the location (possible tampering)");
|
||||
}
|
||||
if (microtime(true) >= $deadline) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return ['total' => count($ids), 'items' => $items];
|
||||
}
|
||||
|
||||
private function trashInfo(string $snapshotId): array {
|
||||
return json_decode($this->getObject('trash/info/' . $snapshotId), true, 512, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
@@ -1281,7 +1388,10 @@ final class Repository {
|
||||
$this->cipher->decrypt($fh, $plain, 'snapshot:' . $snapshotId);
|
||||
fclose($fh);
|
||||
rewind($plain);
|
||||
fgets($plain); // meta line
|
||||
$meta = json_decode((string)fgets($plain), true);
|
||||
if (is_array($meta)) {
|
||||
$this->lastMeta = [$snapshotId => $meta]; // a snapshot never changes
|
||||
}
|
||||
while (($line = fgets($plain)) !== false) {
|
||||
$entry = json_decode($line, true, 512, JSON_THROW_ON_ERROR);
|
||||
if (!is_array($entry) || !is_string($entry['p'] ?? null) || !self::isSafePath($entry['p'])
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\NgBackup\Repository;
|
||||
|
||||
/**
|
||||
* A restore cannot write to where it restores to (a full disk, permissions, something in the way).
|
||||
* The backup is fine, so this never counts as damaged data: the restore stops, also with --salvage.
|
||||
*/
|
||||
final class RestoreTargetException extends \RuntimeException {
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\NgBackup\Service;
|
||||
|
||||
/** A step after the database was committed failed: the database is no longer the fresh one. */
|
||||
final class DatabaseAlreadyRestoredException extends \RuntimeException {
|
||||
}
|
||||
@@ -175,6 +175,8 @@ final class RestoreFullService {
|
||||
if ($dbError === null) {
|
||||
try {
|
||||
$this->restoreDatabase($repo, $meta, $manifest, $dbReport, $beat, $skipMissingTables);
|
||||
} catch (DatabaseAlreadyRestoredException $e) {
|
||||
throw $e;
|
||||
} catch (\Throwable $e) {
|
||||
if (!$salvage) {
|
||||
throw $e;
|
||||
@@ -259,8 +261,20 @@ final class RestoreFullService {
|
||||
$dumper = new DbDumper($this->db->getInner(), $prefix);
|
||||
try {
|
||||
$dumper->restore($repo, $manifest, $dbReport, $beat, $skipMissingTables);
|
||||
} finally {
|
||||
} catch (\Throwable $e) {
|
||||
try {
|
||||
$this->restoreOwnAppConfig($ownAppConfig);
|
||||
} catch (\Throwable) {
|
||||
// rolled back, so they are still there: the error that brought us here is the one to report
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
try {
|
||||
$this->restoreOwnAppConfig($ownAppConfig);
|
||||
} catch (\Throwable $e) {
|
||||
// The old server's database is in place by now: not a case for "the database stays as
|
||||
// the fresh installation made it" under --salvage. A run again restores these rows.
|
||||
throw new DatabaseAlreadyRestoredException('The database was restored, but NG Backup could not put back its own settings: ' . $e->getMessage(), 0, $e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -75,6 +75,12 @@ final class RestoreService {
|
||||
}
|
||||
$children[$name] = $c;
|
||||
}
|
||||
foreach ($this->targets->repository($target)->emptyDirs($snapshotId) as [$dir]) {
|
||||
if ($prefix === '' || str_starts_with($dir, $prefix)) {
|
||||
$name = explode('/', substr($dir, strlen($prefix)), 2)[0];
|
||||
$children[$name] ??= ['name' => $name, 'type' => 'dir', 'size' => 0, 'mtime' => null, 'files' => 0];
|
||||
}
|
||||
}
|
||||
ksort($children, SORT_STRING);
|
||||
return array_values($children);
|
||||
}
|
||||
@@ -123,7 +129,17 @@ final class RestoreService {
|
||||
$entries[substr($e['p'], strlen($source) + 1)] = $e;
|
||||
}
|
||||
}
|
||||
if ($entries === []) {
|
||||
// Folders without anything in them, relative to $source ('' when $source itself is one).
|
||||
/** @var array<array-key, true> $emptyDirs a folder called "2026" is an integer key */
|
||||
$emptyDirs = [];
|
||||
foreach ($single ? [] : $repo->emptyDirs($snapshotId) as [$dir]) {
|
||||
if ($dir === $source) {
|
||||
$emptyDirs[''] = true;
|
||||
} elseif (str_starts_with($dir, $source . '/')) {
|
||||
$emptyDirs[substr($dir, strlen($source) + 1)] = true;
|
||||
}
|
||||
}
|
||||
if ($entries === [] && $emptyDirs === []) {
|
||||
throw new NotFoundException("Nothing at $source in snapshot $snapshotId");
|
||||
}
|
||||
|
||||
@@ -156,9 +172,13 @@ final class RestoreService {
|
||||
}
|
||||
}
|
||||
|
||||
foreach (array_keys($emptyDirs) as $dir) {
|
||||
$this->ensureFolder($dest, (string)$dir);
|
||||
}
|
||||
|
||||
$trashed = 0;
|
||||
if ($mode === self::MODE_REPLACE && !$single) {
|
||||
$trashed = $this->trashExtras($dest, '', array_flip(array_keys($names)));
|
||||
$trashed = $this->trashExtras($dest, '', array_flip(array_keys($names)) + $emptyDirs);
|
||||
}
|
||||
return ['restored' => $restored, 'trashed' => $trashed, 'target' => $uid . ':' . $userFolder->getRelativePath($dest->getPath())];
|
||||
}
|
||||
@@ -262,7 +282,7 @@ final class RestoreService {
|
||||
foreach ($folder->getDirectoryListing() as $node) {
|
||||
$rel = $prefix === '' ? $node->getName() : $prefix . '/' . $node->getName();
|
||||
if ($node instanceof Folder) {
|
||||
$hasKept = false;
|
||||
$hasKept = isset($keep[$rel]); // a folder that was empty in the snapshot
|
||||
foreach (array_keys($keep) as $k) {
|
||||
if (str_starts_with((string)$k, $rel . '/')) {
|
||||
$hasKept = true;
|
||||
|
||||
Reference in New Issue
Block a user