M2: full backup runs (database, config, data) with occ and cron

BackupService starts a run per location (database dump in one
transaction, then config + data directory as a resumable file run),
stores the state in ngb_runs after every step, locks a run while a step
executes and records the snapshot. Previews, logs, updater backups and
per-user cache/uploads are excluded (TreeWalker now supports patterns).
BackupJob (every 5 min) starts the daily schedule and steps running runs
with a short budget under web cron and a long one under system cron.
occ: backup:run, backup:status, backup:list.

Tested on NC34/PostgreSQL: first run 128 files / 1 GiB in 64 s (443 MiB
uploaded to S3), unchanged second run 2 s / 0 bytes; a background run to
SFTP completed through cron.php.
This commit is contained in:
2026-10-02 00:53:50 +02:00
parent bc5a589ed8
commit 446e317786
12 changed files with 693 additions and 15 deletions
+18 -13
View File
@@ -17,7 +17,8 @@ namespace OCA\NgBackup\Job;
final class TreeWalker {
/**
* @param array<string, string> $roots name => absolute directory, walked in this order
* @param list<string> $exclude path prefixes ("<root>/<relative>") that are skipped entirely
* @param list<string> $exclude path prefixes ("<root>/<relative>") that are skipped entirely;
* a pattern with "*" matches one path segment (fnmatch, FNM_PATHNAME)
*/
public function __construct(
private array $roots,
@@ -51,10 +52,8 @@ final class TreeWalker {
}
private function walkDir(string $abs, string $logical, ?string $after): \Generator {
foreach ($this->exclude as $prefix) {
if ($logical !== '' && ($logical === $prefix || str_starts_with($logical, $prefix . '/'))) {
return;
}
if ($logical !== '' && $this->isExcluded($logical, true)) {
return;
}
$names = @scandir($abs);
if ($names === false) {
@@ -78,20 +77,26 @@ final class TreeWalker {
if ($after !== null && self::compare($childLogical, $after) <= 0) {
continue;
}
$excluded = false;
foreach ($this->exclude as $prefix) {
if ($childLogical === $prefix) {
$excluded = true;
break;
}
}
if (!$excluded) {
if (!$this->isExcluded($childLogical, false)) {
yield $childLogical => $childAbs;
}
}
}
}
private function isExcluded(string $logical, bool $isDir): bool {
foreach ($this->exclude as $pattern) {
if (str_contains($pattern, '*')) {
if (fnmatch($pattern, $logical, FNM_PATHNAME)) {
return true;
}
} elseif ($logical === $pattern || ($isDir && str_starts_with($logical, $pattern . '/'))) {
return true;
}
}
return false;
}
private static function isPrefix(string $dir, string $path): bool {
return str_starts_with($path, $dir . '/');
}