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:
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\NgBackup\BackgroundJob;
|
||||
|
||||
use OCA\NgBackup\AppInfo\Application;
|
||||
use OCA\NgBackup\Db\RunMapper;
|
||||
use OCA\NgBackup\Service\BackupService;
|
||||
use OCA\NgBackup\Service\TargetService;
|
||||
use OCP\AppFramework\Utility\ITimeFactory;
|
||||
use OCP\BackgroundJob\IJob;
|
||||
use OCP\BackgroundJob\TimedJob;
|
||||
use OCP\IAppConfig;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Starts scheduled backups and moves running ones forward, a time-boxed step per cron run.
|
||||
* With system cron a step may take minutes; with webcron/AJAX cron it is kept short so it fits
|
||||
* in a normal web request (hosted Nextcloud).
|
||||
*/
|
||||
class BackupJob extends TimedJob {
|
||||
public function __construct(
|
||||
ITimeFactory $time,
|
||||
private BackupService $backups,
|
||||
private TargetService $targets,
|
||||
private RunMapper $runs,
|
||||
private IAppConfig $appConfig,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
parent::__construct($time);
|
||||
$this->setInterval(300);
|
||||
$this->setTimeSensitivity(IJob::TIME_SENSITIVE);
|
||||
}
|
||||
|
||||
protected function run($argument): void {
|
||||
$budget = PHP_SAPI === 'cli' ? 240.0 : 20.0;
|
||||
$deadline = microtime(true) + $budget;
|
||||
$this->startScheduled();
|
||||
foreach ($this->runs->findRunning() as $run) {
|
||||
while (microtime(true) < $deadline && $run->getStatus() === 'running') {
|
||||
$run = $this->backups->step($run, min($deadline, microtime(true) + $budget));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Daily schedule: appconfig ng_backup/schedule_time "HH:MM" (server time); empty = no schedule. */
|
||||
private function startScheduled(): void {
|
||||
$at = $this->appConfig->getValueString(Application::APP_ID, 'schedule_time', '');
|
||||
if (!preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', $at)) {
|
||||
return;
|
||||
}
|
||||
$todayAt = strtotime(date('Y-m-d') . ' ' . $at);
|
||||
if (time() < $todayAt) {
|
||||
return;
|
||||
}
|
||||
foreach ($this->targets->list() as $t) {
|
||||
$last = $this->runs->lastFinished($t->getId(), BackupService::KIND_FULL);
|
||||
if ($this->runs->findRunning($t->getId()) !== [] || ($last !== null && $last->getStartedAt() >= $todayAt)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$this->backups->start($t, 'scheduled');
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->warning('NG Backup: scheduled backup to ' . $t->getName() . ' not started: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\NgBackup\Command;
|
||||
|
||||
use OCA\NgBackup\Db\SnapshotMapper;
|
||||
use OCA\NgBackup\Service\TargetService;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Helper\Table;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class ListSnapshots extends Command {
|
||||
public function __construct(
|
||||
private TargetService $targets,
|
||||
private SnapshotMapper $snapshots,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void {
|
||||
$this->setName('backup:list')
|
||||
->setDescription('List the snapshots (restore points) on a location')
|
||||
->addArgument('target', InputArgument::OPTIONAL, 'Location name or id (default: all)');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$name = $input->getArgument('target');
|
||||
$targets = $name === null ? $this->targets->list() : [$this->targets->get($name)];
|
||||
$rows = [];
|
||||
foreach ($targets as $t) {
|
||||
foreach ($this->snapshots->findForTarget($t->getId()) as $s) {
|
||||
$rows[] = [$t->getName(), $s->getSnapshotId(), date('Y-m-d H:i', $s->getCreatedAt()), $s->getKind(),
|
||||
$s->getFiles(), sprintf('%.1f GiB', ($s->getBytes() ?? 0) / 1073741824), $s->getLabel() ?? ''];
|
||||
}
|
||||
}
|
||||
if ($rows === []) {
|
||||
$output->writeln('No snapshots yet (occ backup:run).');
|
||||
return 0;
|
||||
}
|
||||
(new Table($output))->setHeaders(['location', 'snapshot', 'date', 'kind', 'files', 'size', 'label'])->setRows($rows)->render();
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\NgBackup\Command;
|
||||
|
||||
use OCA\NgBackup\Db\Run;
|
||||
use OCA\NgBackup\Service\BackupService;
|
||||
use OCA\NgBackup\Service\TargetService;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class RunBackup extends Command {
|
||||
public function __construct(
|
||||
private BackupService $backups,
|
||||
private TargetService $targets,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void {
|
||||
$this->setName('backup:run')
|
||||
->setDescription('Back up this Nextcloud (database, config, data) to a location now')
|
||||
->addArgument('target', InputArgument::OPTIONAL, 'Location name or id (default: all locations)')
|
||||
->addOption('label', 'l', InputOption::VALUE_REQUIRED, 'Label for the snapshot', '')
|
||||
->addOption('background', 'b', InputOption::VALUE_NONE, 'Only start the run; background jobs finish it')
|
||||
->setHelp(<<<'HELP'
|
||||
Starts a backup (or continues one that is already running for that location) and runs it to
|
||||
the end in this process. Unchanged files are not read again; only new data is uploaded.
|
||||
|
||||
Examples:
|
||||
occ backup:run back up to every location
|
||||
occ backup:run offsite -l "before upgrade"
|
||||
occ backup:run offsite --background
|
||||
HELP);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$name = $input->getArgument('target');
|
||||
try {
|
||||
$targets = $name === null ? $this->targets->list() : [$this->targets->get($name)];
|
||||
} catch (\Throwable) {
|
||||
$output->writeln('<error>No such location.</error>');
|
||||
return 1;
|
||||
}
|
||||
if ($targets === []) {
|
||||
$output->writeln('<error>No backup locations yet (occ backup:target:add).</error>');
|
||||
return 1;
|
||||
}
|
||||
$failed = 0;
|
||||
foreach ($targets as $t) {
|
||||
try {
|
||||
$run = $this->backups->start($t, (string)$input->getOption('label'));
|
||||
} catch (\Throwable $e) {
|
||||
$output->writeln('<error>' . $t->getName() . ': ' . $e->getMessage() . '</error>');
|
||||
$failed++;
|
||||
continue;
|
||||
}
|
||||
$output->writeln(sprintf('<info>%s</info>: run %d, snapshot %s', $t->getName(), $run->getId(), $run->getSnapshotId()));
|
||||
if ($input->getOption('background')) {
|
||||
continue;
|
||||
}
|
||||
$start = microtime(true);
|
||||
$run = $this->backups->runToCompletion($run, function (Run $r) use ($output, $start) {
|
||||
$s = json_decode($r->getStats() ?? '{}', true);
|
||||
$f = $s['files'] ?? [];
|
||||
$output->writeln(sprintf(' %5.0fs %-6s files %d (read %d, unchanged %d), uploaded %.1f MiB',
|
||||
microtime(true) - $start, $r->getPhase(), $f['files'] ?? 0, $f['read'] ?? 0, $f['reused'] ?? 0, ($f['uploaded'] ?? 0) / 1048576));
|
||||
});
|
||||
if ($run->getStatus() === Run::DONE) {
|
||||
$output->writeln(sprintf(' done in %.0fs', microtime(true) - $start));
|
||||
} else {
|
||||
$output->writeln('<error> ' . $run->getStatus() . ': ' . $run->getError() . '</error>');
|
||||
$failed++;
|
||||
}
|
||||
}
|
||||
return $failed === 0 ? 0 : 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\NgBackup\Command;
|
||||
|
||||
use OCA\NgBackup\Db\RunMapper;
|
||||
use OCA\NgBackup\Service\KeyService;
|
||||
use OCA\NgBackup\Service\TargetService;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Helper\Table;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class Status extends Command {
|
||||
public function __construct(
|
||||
private KeyService $keys,
|
||||
private TargetService $targets,
|
||||
private RunMapper $runs,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void {
|
||||
$this->setName('backup:status')->setDescription('Show the backup key, locations and recent runs');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
if (!$this->keys->isInitialized()) {
|
||||
$output->writeln('Key: <error>not created</error> (occ backup:key:init)');
|
||||
} else {
|
||||
$c = $this->keys->recoveryKitConfirmation();
|
||||
$output->writeln('Key: ' . $this->keys->fingerprint() . ', recovery kit '
|
||||
. ($c ? 'confirmed by ' . $c['uid'] . ' on ' . date('Y-m-d H:i', $c['time']) : '<error>not confirmed</error> (occ backup:key:confirm)'));
|
||||
}
|
||||
$names = [];
|
||||
foreach ($this->targets->list() as $t) {
|
||||
$names[$t->getId()] = $t->getName();
|
||||
}
|
||||
$output->writeln('Locations: ' . ($names === [] ? 'none' : implode(', ', $names)));
|
||||
$rows = [];
|
||||
foreach ($this->runs->findRecent(10) as $r) {
|
||||
$s = json_decode($r->getStats() ?? '{}', true)['files'] ?? [];
|
||||
$rows[] = [$r->getId(), $names[$r->getTargetId()] ?? '#' . $r->getTargetId(), $r->getKind(), $r->getStatus() . ($r->getStatus() === 'running' ? ' (' . $r->getPhase() . ')' : ''),
|
||||
date('Y-m-d H:i', $r->getStartedAt()), $r->getFinishedAt() ? gmdate('H:i:s', $r->getFinishedAt() - $r->getStartedAt()) : '',
|
||||
$s['files'] ?? '', isset($s['uploaded']) ? sprintf('%.1f MiB', $s['uploaded'] / 1048576) : '', mb_strimwidth((string)$r->getError(), 0, 50, '…')];
|
||||
}
|
||||
if ($rows !== []) {
|
||||
(new Table($output))->setHeaders(['run', 'location', 'kind', 'status', 'started', 'took', 'files', 'uploaded', 'error'])->setRows($rows)->render();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\NgBackup\Db;
|
||||
|
||||
use OCP\AppFramework\Db\Entity;
|
||||
|
||||
/**
|
||||
* @method int getTargetId()
|
||||
* @method void setTargetId(int $v)
|
||||
* @method string getKind()
|
||||
* @method void setKind(string $v)
|
||||
* @method string getStatus()
|
||||
* @method void setStatus(string $v)
|
||||
* @method string getPhase()
|
||||
* @method void setPhase(string $v)
|
||||
* @method string getState()
|
||||
* @method void setState(string $v)
|
||||
* @method string|null getStats()
|
||||
* @method void setStats(?string $v)
|
||||
* @method string|null getError()
|
||||
* @method void setError(?string $v)
|
||||
* @method string|null getSnapshotId()
|
||||
* @method void setSnapshotId(?string $v)
|
||||
* @method int getStartedAt()
|
||||
* @method void setStartedAt(int $v)
|
||||
* @method int getUpdatedAt()
|
||||
* @method void setUpdatedAt(int $v)
|
||||
* @method int|null getFinishedAt()
|
||||
* @method void setFinishedAt(?int $v)
|
||||
*/
|
||||
class Run extends Entity {
|
||||
public const RUNNING = 'running';
|
||||
public const DONE = 'done';
|
||||
public const FAILED = 'failed';
|
||||
public const CANCELLED = 'cancelled';
|
||||
|
||||
protected int $targetId = 0;
|
||||
protected string $kind = '';
|
||||
protected string $status = '';
|
||||
protected string $phase = '';
|
||||
protected string $state = '{}';
|
||||
protected ?string $stats = null;
|
||||
protected ?string $error = null;
|
||||
protected ?string $snapshotId = null;
|
||||
protected int $startedAt = 0;
|
||||
protected int $updatedAt = 0;
|
||||
protected ?int $finishedAt = null;
|
||||
|
||||
public function __construct() {
|
||||
$this->addType('targetId', 'integer');
|
||||
$this->addType('startedAt', 'integer');
|
||||
$this->addType('updatedAt', 'integer');
|
||||
$this->addType('finishedAt', 'integer');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\NgBackup\Db;
|
||||
|
||||
use OCP\AppFramework\Db\QBMapper;
|
||||
use OCP\DB\QueryBuilder\IQueryBuilder;
|
||||
use OCP\IDBConnection;
|
||||
|
||||
/** @template-extends QBMapper<Run> */
|
||||
class RunMapper extends QBMapper {
|
||||
public function __construct(IDBConnection $db) {
|
||||
parent::__construct($db, 'ngb_runs', Run::class);
|
||||
}
|
||||
|
||||
public function find(int $id): Run {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')->from($this->getTableName())->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT)));
|
||||
return $this->findEntity($qb);
|
||||
}
|
||||
|
||||
/** @return list<Run> */
|
||||
public function findRunning(?int $targetId = null): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')->from($this->getTableName())->where($qb->expr()->eq('status', $qb->createNamedParameter(Run::RUNNING)));
|
||||
if ($targetId !== null) {
|
||||
$qb->andWhere($qb->expr()->eq('target_id', $qb->createNamedParameter($targetId, IQueryBuilder::PARAM_INT)));
|
||||
}
|
||||
return $this->findEntities($qb->orderBy('id'));
|
||||
}
|
||||
|
||||
/** @return list<Run> */
|
||||
public function findRecent(int $limit = 20): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')->from($this->getTableName())->orderBy('id', 'DESC')->setMaxResults($limit);
|
||||
return $this->findEntities($qb);
|
||||
}
|
||||
|
||||
public function lastFinished(int $targetId, string $kind): ?Run {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')->from($this->getTableName())
|
||||
->where($qb->expr()->eq('target_id', $qb->createNamedParameter($targetId, IQueryBuilder::PARAM_INT)))
|
||||
->andWhere($qb->expr()->eq('kind', $qb->createNamedParameter($kind)))
|
||||
->andWhere($qb->expr()->eq('status', $qb->createNamedParameter(Run::DONE)))
|
||||
->orderBy('id', 'DESC')->setMaxResults(1);
|
||||
$r = $this->findEntities($qb);
|
||||
return $r[0] ?? null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\NgBackup\Db;
|
||||
|
||||
use OCP\AppFramework\Db\Entity;
|
||||
|
||||
/**
|
||||
* @method int getTargetId()
|
||||
* @method void setTargetId(int $v)
|
||||
* @method string getSnapshotId()
|
||||
* @method void setSnapshotId(string $v)
|
||||
* @method string getKind()
|
||||
* @method void setKind(string $v)
|
||||
* @method string|null getLabel()
|
||||
* @method void setLabel(?string $v)
|
||||
* @method int getCreatedAt()
|
||||
* @method void setCreatedAt(int $v)
|
||||
* @method int|null getFiles()
|
||||
* @method void setFiles(?int $v)
|
||||
* @method int|null getBytes()
|
||||
* @method void setBytes(?int $v)
|
||||
*/
|
||||
class Snapshot extends Entity {
|
||||
protected int $targetId = 0;
|
||||
protected string $snapshotId = '';
|
||||
protected string $kind = '';
|
||||
protected ?string $label = null;
|
||||
protected int $createdAt = 0;
|
||||
protected ?int $files = null;
|
||||
protected ?int $bytes = null;
|
||||
|
||||
public function __construct() {
|
||||
$this->addType('targetId', 'integer');
|
||||
$this->addType('createdAt', 'integer');
|
||||
$this->addType('files', 'integer');
|
||||
$this->addType('bytes', 'integer');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\NgBackup\Db;
|
||||
|
||||
use OCP\AppFramework\Db\QBMapper;
|
||||
use OCP\DB\QueryBuilder\IQueryBuilder;
|
||||
use OCP\IDBConnection;
|
||||
|
||||
/** @template-extends QBMapper<Snapshot> */
|
||||
class SnapshotMapper extends QBMapper {
|
||||
public function __construct(IDBConnection $db) {
|
||||
parent::__construct($db, 'ngb_snapshots', Snapshot::class);
|
||||
}
|
||||
|
||||
/** @return list<Snapshot> newest first */
|
||||
public function findForTarget(int $targetId): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')->from($this->getTableName())
|
||||
->where($qb->expr()->eq('target_id', $qb->createNamedParameter($targetId, IQueryBuilder::PARAM_INT)))
|
||||
->orderBy('created_at', 'DESC');
|
||||
return $this->findEntities($qb);
|
||||
}
|
||||
|
||||
public function findOne(int $targetId, string $snapshotId): Snapshot {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')->from($this->getTableName())
|
||||
->where($qb->expr()->eq('target_id', $qb->createNamedParameter($targetId, IQueryBuilder::PARAM_INT)))
|
||||
->andWhere($qb->expr()->eq('snapshot_id', $qb->createNamedParameter($snapshotId)));
|
||||
return $this->findEntity($qb);
|
||||
}
|
||||
|
||||
public function latest(int $targetId, string $kind): ?Snapshot {
|
||||
foreach ($this->findForTarget($targetId) as $s) {
|
||||
if ($s->getKind() === $kind) {
|
||||
return $s;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+18
-13
@@ -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 . '/');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\NgBackup\Service;
|
||||
|
||||
use OCA\NgBackup\Db\DbDumper;
|
||||
use OCA\NgBackup\Db\Run;
|
||||
use OCA\NgBackup\Db\RunMapper;
|
||||
use OCA\NgBackup\Db\Snapshot;
|
||||
use OCA\NgBackup\Db\SnapshotMapper;
|
||||
use OCA\NgBackup\Db\Target;
|
||||
use OCA\NgBackup\Job\BackupRun;
|
||||
use OCP\App\IAppManager;
|
||||
use OCP\IConfig;
|
||||
use OCP\IDBConnection;
|
||||
use OCP\Lock\ILockingProvider;
|
||||
use OCP\Lock\LockedException;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Full backups of this installation: database (one consistent transaction), config and the
|
||||
* data directory, as a resumable run whose state is stored in ngb_runs after every step.
|
||||
*
|
||||
* Excluded by default: previews, log files, the updater's backups and each user's cache and
|
||||
* temporary uploads (all regenerable or temporary).
|
||||
*/
|
||||
class BackupService {
|
||||
public const KIND_FULL = 'full';
|
||||
|
||||
public function __construct(
|
||||
private TargetService $targets,
|
||||
private KeyService $keys,
|
||||
private RunMapper $runs,
|
||||
private SnapshotMapper $snapshots,
|
||||
private IConfig $config,
|
||||
private IDBConnection $db,
|
||||
private IAppManager $appManager,
|
||||
private ILockingProvider $locking,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
public function start(Target $target, string $label = ''): Run {
|
||||
if ($this->keys->recoveryKitConfirmation() === null) {
|
||||
throw new \RuntimeException('Confirm the recovery kit first (occ backup:key:confirm)');
|
||||
}
|
||||
if ($this->config->getSystemValue('objectstore', null) !== null) {
|
||||
throw new \RuntimeException('Object storage as primary storage is not supported yet');
|
||||
}
|
||||
$running = $this->runs->findRunning($target->getId());
|
||||
if ($running !== []) {
|
||||
return $running[0];
|
||||
}
|
||||
$dataDir = rtrim($this->config->getSystemValueString('datadirectory', \OC::$SERVERROOT . '/data'), '/');
|
||||
$parent = $this->snapshots->latest($target->getId(), self::KIND_FULL);
|
||||
|
||||
$files = BackupRun::start(
|
||||
['config' => rtrim(\OC::$configDir, '/'), 'data' => $dataDir],
|
||||
$parent?->getSnapshotId(),
|
||||
$label,
|
||||
$this->excludes($dataDir),
|
||||
['kind' => self::KIND_FULL, 'nextcloud' => $this->config->getSystemValueString('version'),
|
||||
'instanceid' => $this->config->getSystemValueString('instanceid'),
|
||||
'dbtype' => $this->config->getSystemValueString('dbtype'),
|
||||
'apps' => $this->installedApps()],
|
||||
);
|
||||
|
||||
$run = new Run();
|
||||
$run->setTargetId($target->getId());
|
||||
$run->setKind(self::KIND_FULL);
|
||||
$run->setStatus(Run::RUNNING);
|
||||
$run->setPhase('db');
|
||||
$run->setState(json_encode(['files' => $files], JSON_THROW_ON_ERROR));
|
||||
$run->setSnapshotId($files['snapshot']);
|
||||
$run->setStartedAt(time());
|
||||
$run->setUpdatedAt(time());
|
||||
return $this->runs->insert($run);
|
||||
}
|
||||
|
||||
/**
|
||||
* Do one step of a run until $deadline. Safe to call concurrently: a second caller finds the
|
||||
* run locked and returns immediately.
|
||||
*/
|
||||
public function step(Run $run, float $deadline): Run {
|
||||
$lockKey = 'ng_backup/run/' . $run->getId();
|
||||
try {
|
||||
$this->locking->acquireLock($lockKey, ILockingProvider::LOCK_EXCLUSIVE);
|
||||
} catch (LockedException) {
|
||||
return $run;
|
||||
}
|
||||
try {
|
||||
$run = $this->runs->find($run->getId()); // fresh state after acquiring the lock
|
||||
if ($run->getStatus() !== Run::RUNNING) {
|
||||
return $run;
|
||||
}
|
||||
$target = $this->targets->get((string)$run->getTargetId());
|
||||
$repo = $this->targets->repository($target);
|
||||
$state = json_decode($run->getState(), true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
if ($run->getPhase() === 'db') {
|
||||
@set_time_limit(0); // the dump is one transaction; it cannot be split across requests yet
|
||||
$dbStats = [];
|
||||
$manifest = (new DbDumper($this->db->getInner(), $this->config->getSystemValueString('dbtableprefix', 'oc_')))->dump($repo, $dbStats);
|
||||
$repo->putObject('db/' . $state['files']['snapshot'], json_encode($manifest, JSON_THROW_ON_ERROR));
|
||||
$state['files']['meta']['db'] = 'db/' . $state['files']['snapshot'];
|
||||
$state['dbStats'] = $dbStats + ['tables' => count($manifest['tables']),
|
||||
'rows' => array_sum(array_column($manifest['tables'], 'rows'))];
|
||||
$run->setPhase('files');
|
||||
} else {
|
||||
$state['files'] = BackupRun::step($repo, $state['files'], $deadline);
|
||||
if ($state['files']['phase'] === 'done') {
|
||||
$this->recordSnapshot($run, $state['files']);
|
||||
$run->setStatus(Run::DONE);
|
||||
$run->setPhase('done');
|
||||
$run->setFinishedAt(time());
|
||||
}
|
||||
}
|
||||
$run->setState(json_encode($state, JSON_THROW_ON_ERROR));
|
||||
$run->setStats(json_encode(['db' => $state['dbStats'] ?? null, 'files' => $state['files']['stats']], JSON_THROW_ON_ERROR));
|
||||
$run->setUpdatedAt(time());
|
||||
return $this->runs->update($run);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('NG Backup run ' . $run->getId() . ' failed: ' . $e->getMessage(), ['exception' => $e]);
|
||||
$run->setStatus(Run::FAILED);
|
||||
$run->setError($e->getMessage());
|
||||
$run->setFinishedAt(time());
|
||||
$run->setUpdatedAt(time());
|
||||
return $this->runs->update($run);
|
||||
} finally {
|
||||
$this->locking->releaseLock($lockKey, ILockingProvider::LOCK_EXCLUSIVE);
|
||||
}
|
||||
}
|
||||
|
||||
/** Run to completion in this process (occ, system cron), reporting progress. */
|
||||
public function runToCompletion(Run $run, ?callable $progress = null, float $stepSeconds = 30): Run {
|
||||
while ($run->getStatus() === Run::RUNNING) {
|
||||
$run = $this->step($run, microtime(true) + $stepSeconds);
|
||||
if ($progress !== null) {
|
||||
$progress($run);
|
||||
}
|
||||
}
|
||||
return $run;
|
||||
}
|
||||
|
||||
public function cancel(Run $run): Run {
|
||||
$run->setStatus(Run::CANCELLED);
|
||||
$run->setFinishedAt(time());
|
||||
$run->setUpdatedAt(time());
|
||||
return $this->runs->update($run);
|
||||
}
|
||||
|
||||
private function recordSnapshot(Run $run, array $files): void {
|
||||
$s = new Snapshot();
|
||||
$s->setTargetId($run->getTargetId());
|
||||
$s->setSnapshotId($files['snapshot']);
|
||||
$s->setKind($run->getKind());
|
||||
$s->setLabel($files['label'] !== '' ? $files['label'] : null);
|
||||
$s->setCreatedAt(time());
|
||||
$s->setFiles($files['stats']['files']);
|
||||
$s->setBytes($files['stats']['bytes']);
|
||||
$this->snapshots->insert($s);
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private function excludes(string $dataDir): array {
|
||||
$instanceId = $this->config->getSystemValueString('instanceid');
|
||||
$exclude = [
|
||||
"data/appdata_$instanceId/preview",
|
||||
"data/updater-$instanceId",
|
||||
'data/nextcloud.log',
|
||||
'data/nextcloud.log.*',
|
||||
'data/audit.log',
|
||||
'data/*/cache',
|
||||
'data/*/uploads',
|
||||
];
|
||||
$logFile = $this->config->getSystemValueString('logfile', '');
|
||||
if ($logFile !== '' && str_starts_with($logFile, $dataDir . '/')) {
|
||||
$exclude[] = 'data/' . substr($logFile, strlen($dataDir) + 1);
|
||||
}
|
||||
// Never back up a local backup location into itself.
|
||||
foreach ($this->targets->list() as $t) {
|
||||
$opts = $this->targets->options($t);
|
||||
$dir = rtrim((string)($opts['datadir'] ?? ''), '/');
|
||||
if ($t->getBackend() === 'local' && $dir !== '' && str_starts_with($dir . '/', $dataDir . '/')) {
|
||||
$exclude[] = trim('data/' . substr($dir, strlen($dataDir) + 1) . '/' . $t->getBasePath(), '/');
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($exclude));
|
||||
}
|
||||
|
||||
/** @return array<string, string> app id => version */
|
||||
private function installedApps(): array {
|
||||
$apps = [];
|
||||
foreach ($this->appManager->getEnabledApps() as $app) {
|
||||
$apps[$app] = $this->appManager->getAppVersion($app);
|
||||
}
|
||||
ksort($apps);
|
||||
return $apps;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user