Pin a restore point and keep a note on it
Both live in the catalog on the location, so a new server sees them; the database holds a local copy for the lists. The retention policy leaves a pinned restore point alone and does not count it. Available on the restore page and as occ backup:pin and backup:note.
This commit is contained in:
@@ -13,6 +13,13 @@ shown under 1.0.0. The git tag of a release candidate goes after the date.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Pin a restore point, on the restore page or with `occ backup:pin`: the retention policy leaves it
|
||||
alone, for example the one from before an upgrade. Unpinning notifies all administrators.
|
||||
- A note on a restore point that you can change afterwards, on the restore page or with
|
||||
`occ backup:note`. Pins and notes are stored encrypted on the location, so a new server sees them.
|
||||
An older version of NG Backup can still read the location, but drops them when it writes to it.
|
||||
|
||||
### Changed
|
||||
- A file or folder that is left out because of its name now gets a notification of its own, in the
|
||||
log and in `occ backup:run`. It says the item is missing from the backup, gives example paths and
|
||||
|
||||
@@ -333,6 +333,7 @@ On the backlog; know them before you rely on NG Backup:
|
||||
| `backup:cancel <run>` | stop a running backup |
|
||||
| `backup:browse`, `backup:restore` | browse and restore |
|
||||
| `backup:retention`, `backup:prune`, `backup:trash`, `backup:trash:restore` | retention, cleanup, trash |
|
||||
| `backup:pin`, `backup:note` | keep a restore point, a note you can change later |
|
||||
| `backup:verify` | checksum audit of a restore point (`--deep` downloads and decrypts everything) |
|
||||
| `backup:restore:full` | whole-server restore onto a fresh installation (after `backup:key:import`) |
|
||||
| `backup:key:import` | import a recovery kit on a fresh installation |
|
||||
|
||||
@@ -83,6 +83,8 @@ you need help with restoring a backup after a crash.]]></description>
|
||||
<command>OCA\NgBackup\Command\ListSnapshots</command>
|
||||
<command>OCA\NgBackup\Command\Browse</command>
|
||||
<command>OCA\NgBackup\Command\Restore</command>
|
||||
<command>OCA\NgBackup\Command\Pin</command>
|
||||
<command>OCA\NgBackup\Command\Note</command>
|
||||
<command>OCA\NgBackup\Command\Prune</command>
|
||||
<command>OCA\NgBackup\Command\Retention</command>
|
||||
<command>OCA\NgBackup\Command\TargetAppendOnly</command>
|
||||
|
||||
@@ -27,6 +27,8 @@ return [
|
||||
['name' => 'AdminApi#tick', 'url' => '/api/tick', 'verb' => 'POST'],
|
||||
['name' => 'AdminApi#setSchedule', 'url' => '/api/schedule', 'verb' => 'PUT'],
|
||||
['name' => 'AdminApi#snapshots', 'url' => '/api/targets/{targetId}/snapshots', 'verb' => 'GET'],
|
||||
['name' => 'AdminApi#setSnapshotNote', 'url' => '/api/targets/{targetId}/snapshots/{snapshotId}/note', 'verb' => 'PUT'],
|
||||
['name' => 'AdminApi#setSnapshotPinned', 'url' => '/api/targets/{targetId}/snapshots/{snapshotId}/pin', 'verb' => 'PUT'],
|
||||
['name' => 'AdminApi#browse', 'url' => '/api/targets/{targetId}/snapshots/{snapshotId}/browse', 'verb' => 'GET'],
|
||||
['name' => 'AdminApi#startRestore', 'url' => '/api/restores', 'verb' => 'POST'],
|
||||
],
|
||||
|
||||
@@ -173,8 +173,13 @@ occ backup:prune offsite --dry-run # show first
|
||||
occ backup:prune offsite # apply the policy; forgotten snapshots go to the trash first
|
||||
occ backup:trash # list the trash
|
||||
occ backup:trash:restore offsite <id>
|
||||
occ backup:pin offsite <id> # keep this restore point; --unpin to undo
|
||||
occ backup:note offsite <id> "text" # a note you can change later; without text it is removed
|
||||
```
|
||||
|
||||
The retention policy leaves a pinned restore point alone, and a pinned one does not take the place of
|
||||
a daily, weekly or monthly one. Pin and note are stored on the location, so a new server sees them too.
|
||||
|
||||
Snapshots are kept according to the policy, and their data is deleted only after the deletion delay
|
||||
(set with `backup:key:init --delete-delay-days`). `occ backup:prune offsite --dry-run` shows what would be
|
||||
removed without changing anything.
|
||||
|
||||
@@ -40,14 +40,14 @@ final class ListSnapshots extends Command {
|
||||
foreach ($targets as $t) {
|
||||
foreach ($this->snapshots->findForTarget($t->getId()) as $s) {
|
||||
$rows[] = [$t->getName(), $s->getSnapshotId(), Format::time($s->getCreatedAt(), $zone), $s->getKind(),
|
||||
$s->getFiles(), sprintf('%.1f GiB', ($s->getBytes() ?? 0) / 1073741824), $s->getLabel() ?? ''];
|
||||
$s->getFiles(), sprintf('%.1f GiB', ($s->getBytes() ?? 0) / 1073741824), $s->getPinned() ? 'yes' : '', $s->getLabel() ?? '', $s->getNote() ?? ''];
|
||||
}
|
||||
}
|
||||
if ($rows === []) {
|
||||
$output->writeln('No snapshots yet (occ backup:run).');
|
||||
return 0;
|
||||
}
|
||||
(new Table($output))->setHeaders(['location', 'snapshot', "date ($zone)", 'kind', 'files', 'size', 'label'])->setRows($rows)->render();
|
||||
(new Table($output))->setHeaders(['location', 'snapshot', "date ($zone)", 'kind', 'files', 'size', 'pinned', 'label', 'note'])->setRows($rows)->render();
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<?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\Service\PruneService;
|
||||
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\Output\OutputInterface;
|
||||
|
||||
final class Note extends Command {
|
||||
public function __construct(
|
||||
private TargetService $targets,
|
||||
private PruneService $prune,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void {
|
||||
$this->setName('backup:note')
|
||||
->setDescription('Set or remove the note on a snapshot')
|
||||
->addArgument('target', InputArgument::REQUIRED, 'Location name or id')
|
||||
->addArgument('snapshot', InputArgument::REQUIRED, 'Snapshot id (see backup:list)')
|
||||
->addArgument('note', InputArgument::OPTIONAL, 'The note, at most ' . PruneService::NOTE_MAX . ' characters; without it the note is removed', '');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$note = (string)$input->getArgument('note');
|
||||
try {
|
||||
$this->prune->setNote($this->targets->get($input->getArgument('target')), $input->getArgument('snapshot'), $note);
|
||||
} catch (\Throwable $e) {
|
||||
$output->writeln('<error>' . $e->getMessage() . '</error>');
|
||||
return 1;
|
||||
}
|
||||
$output->writeln(trim($note) === '' ? 'Note removed.' : 'Note saved.');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -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\Command;
|
||||
|
||||
use OCA\NgBackup\Service\PruneService;
|
||||
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;
|
||||
|
||||
final class Pin extends Command {
|
||||
public function __construct(
|
||||
private TargetService $targets,
|
||||
private PruneService $prune,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void {
|
||||
$this->setName('backup:pin')
|
||||
->setDescription('Keep a snapshot: the retention policy does not remove a pinned one')
|
||||
->addArgument('target', InputArgument::REQUIRED, 'Location name or id')
|
||||
->addArgument('snapshot', InputArgument::REQUIRED, 'Snapshot id (see backup:list)')
|
||||
->addOption('unpin', null, InputOption::VALUE_NONE, 'Leave the snapshot to the retention policy again');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$unpin = (bool)$input->getOption('unpin');
|
||||
try {
|
||||
$this->prune->setPinned($this->targets->get($input->getArgument('target')), $input->getArgument('snapshot'), !$unpin);
|
||||
} catch (\Throwable $e) {
|
||||
$output->writeln('<error>' . $e->getMessage() . '</error>');
|
||||
return 1;
|
||||
}
|
||||
$output->writeln($unpin ? 'Snapshot unpinned; the retention policy applies to it again.' : 'Snapshot pinned; the retention policy leaves it alone.');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -292,11 +292,29 @@ final class AdminApiController extends Controller {
|
||||
$out = [];
|
||||
foreach ($this->snapshots->findForTarget($targetId) as $s) {
|
||||
$out[] = ['id' => $s->getSnapshotId(), 'kind' => $s->getKind(), 'label' => $s->getLabel(), 'createdAt' => $s->getCreatedAt(),
|
||||
'files' => $s->getFiles(), 'bytes' => $s->getBytes()];
|
||||
'files' => $s->getFiles(), 'bytes' => $s->getBytes(), 'note' => $s->getNote(), 'pinned' => (bool)$s->getPinned()];
|
||||
}
|
||||
return new JSONResponse($out);
|
||||
}
|
||||
|
||||
public function setSnapshotNote(int $targetId, string $snapshotId, string $note = ''): JSONResponse {
|
||||
try {
|
||||
$this->prune->setNote($this->targets->get((string)$targetId), $snapshotId, $note);
|
||||
} catch (\Throwable $e) {
|
||||
return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
return new JSONResponse(['ok' => true]);
|
||||
}
|
||||
|
||||
public function setSnapshotPinned(int $targetId, string $snapshotId, bool $pinned = true): JSONResponse {
|
||||
try {
|
||||
$this->prune->setPinned($this->targets->get((string)$targetId), $snapshotId, $pinned);
|
||||
} catch (\Throwable $e) {
|
||||
return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
return new JSONResponse(['ok' => true]);
|
||||
}
|
||||
|
||||
public function browse(int $targetId, string $snapshotId, string $path = ''): JSONResponse {
|
||||
try {
|
||||
return new JSONResponse($this->restore->browse($this->targets->get((string)$targetId), $snapshotId, $path));
|
||||
@@ -341,7 +359,7 @@ final class AdminApiController extends Controller {
|
||||
if ($r->getSnapshotId() !== null) {
|
||||
try {
|
||||
$s = $this->snapshots->findOne($r->getTargetId(), $r->getSnapshotId());
|
||||
$snapshot = ['createdAt' => $s->getCreatedAt(), 'label' => $s->getLabel()];
|
||||
$snapshot = ['createdAt' => $s->getCreatedAt(), 'label' => $s->getLabel(), 'note' => $s->getNote(), 'pinned' => (bool)$s->getPinned()];
|
||||
} catch (\Throwable) {
|
||||
// Snapshot already pruned or trashed: the run keeps its id, just not the date.
|
||||
}
|
||||
|
||||
@@ -24,6 +24,10 @@ use OCP\AppFramework\Db\Entity;
|
||||
* @method void setFiles(?int $v)
|
||||
* @method int|null getBytes()
|
||||
* @method void setBytes(?int $v)
|
||||
* @method string|null getNote()
|
||||
* @method void setNote(?string $v)
|
||||
* @method bool|null getPinned()
|
||||
* @method void setPinned(?bool $v)
|
||||
*/
|
||||
final class Snapshot extends Entity {
|
||||
protected int $targetId = 0;
|
||||
@@ -33,8 +37,11 @@ final class Snapshot extends Entity {
|
||||
protected int $createdAt = 0;
|
||||
protected ?int $files = null;
|
||||
protected ?int $bytes = null;
|
||||
protected ?string $note = null;
|
||||
protected ?bool $pinned = null;
|
||||
|
||||
public function __construct() {
|
||||
$this->addType('pinned', 'boolean');
|
||||
$this->addType('targetId', 'integer');
|
||||
$this->addType('createdAt', 'integer');
|
||||
$this->addType('files', 'integer');
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\NgBackup\Migration;
|
||||
|
||||
use Closure;
|
||||
use OCP\DB\ISchemaWrapper;
|
||||
use OCP\DB\Types;
|
||||
use OCP\Migration\IOutput;
|
||||
use OCP\Migration\SimpleMigrationStep;
|
||||
|
||||
/** Local copy of what the catalog on the location says about a snapshot: its note and pin. */
|
||||
final class Version000007Date20261107000000 extends SimpleMigrationStep {
|
||||
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
|
||||
/** @var ISchemaWrapper $schema */
|
||||
$schema = $schemaClosure();
|
||||
$t = $schema->getTable('ngb_snapshots');
|
||||
$changed = false;
|
||||
if (!$t->hasColumn('note')) {
|
||||
$t->addColumn('note', Types::STRING, ['notnull' => false, 'length' => 1000]);
|
||||
$changed = true;
|
||||
}
|
||||
if (!$t->hasColumn('pinned')) {
|
||||
$t->addColumn('pinned', Types::BOOLEAN, ['notnull' => false, 'default' => false]);
|
||||
$changed = true;
|
||||
}
|
||||
return $changed ? $schema : null;
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,10 @@ final class Catalog {
|
||||
public array $trash = [];
|
||||
/** @var array<string, true> manifest path => true, for a per-user export (users/<uid>/<exportId>) */
|
||||
public array $userExports = [];
|
||||
/** @var array<string, true> snapshots the retention policy leaves alone */
|
||||
public array $pinned = [];
|
||||
/** @var array<string, string> snapshot id => note, also while the snapshot is in the trash */
|
||||
public array $notes = [];
|
||||
public string $hash = '';
|
||||
|
||||
public function __construct(
|
||||
@@ -82,6 +86,8 @@ final class Catalog {
|
||||
// Catalogs written before user exports existed have no such key: default to empty rather
|
||||
// than adopting the location's current users/ listing (that would skip verification).
|
||||
$this->userExports = array_fill_keys($data['userExports'] ?? [], true);
|
||||
$this->pinned = array_fill_keys($data['pinned'] ?? [], true);
|
||||
$this->notes = $data['notes'] ?? [];
|
||||
$this->anchor?->set($this->repositoryId, $this->gen, $this->hash);
|
||||
}
|
||||
|
||||
@@ -93,9 +99,12 @@ final class Catalog {
|
||||
if ($this->backend->exists($this->path($next))) {
|
||||
throw new RepositoryException("Catalog generation $next was written by another process at the same time; try again");
|
||||
}
|
||||
// A pin ends when its snapshot leaves the list, a note when the snapshot is gone for good.
|
||||
$this->pinned = array_intersect_key($this->pinned, $this->snapshots);
|
||||
$this->notes = array_intersect_key($this->notes, $this->snapshots + $this->trash);
|
||||
$plain = json_encode(['repository' => $this->repositoryId, 'gen' => $next, 'time' => gmdate('c'), 'prev' => $this->hash,
|
||||
'snapshots' => array_keys($this->snapshots), 'trash' => array_keys($this->trash),
|
||||
'userExports' => array_keys($this->userExports)], JSON_THROW_ON_ERROR);
|
||||
'userExports' => array_keys($this->userExports), 'pinned' => array_keys($this->pinned), 'notes' => $this->notes], JSON_THROW_ON_ERROR);
|
||||
$encrypted = $this->cipher->encryptString($plain, $this->ad($next));
|
||||
$stream = fopen('php://memory', 'w+b');
|
||||
fwrite($stream, $encrypted);
|
||||
|
||||
@@ -134,6 +134,49 @@ final class Repository {
|
||||
return $this->catalog->gen;
|
||||
}
|
||||
|
||||
/** @return list<string> snapshots the retention policy leaves alone */
|
||||
public function pinnedSnapshots(): array {
|
||||
return array_map('strval', array_keys($this->catalog->pinned));
|
||||
}
|
||||
|
||||
/** @return array<string, string> snapshot id => note */
|
||||
public function snapshotNotes(): array {
|
||||
return $this->catalog->notes;
|
||||
}
|
||||
|
||||
public function setPinned(string $snapshotId, bool $pinned): void {
|
||||
$this->requireSnapshot($snapshotId);
|
||||
if (isset($this->catalog->pinned[$snapshotId]) === $pinned) {
|
||||
return;
|
||||
}
|
||||
if ($pinned) {
|
||||
$this->catalog->pinned[$snapshotId] = true;
|
||||
} else {
|
||||
unset($this->catalog->pinned[$snapshotId]);
|
||||
}
|
||||
$this->catalog->write();
|
||||
}
|
||||
|
||||
/** A note kept in the catalog: unlike the label inside the snapshot, it can change afterwards. */
|
||||
public function setSnapshotNote(string $snapshotId, string $note): void {
|
||||
$this->requireSnapshot($snapshotId);
|
||||
if (($this->catalog->notes[$snapshotId] ?? '') === $note) {
|
||||
return;
|
||||
}
|
||||
if ($note === '') {
|
||||
unset($this->catalog->notes[$snapshotId]);
|
||||
} else {
|
||||
$this->catalog->notes[$snapshotId] = $note;
|
||||
}
|
||||
$this->catalog->write();
|
||||
}
|
||||
|
||||
private function requireSnapshot(string $snapshotId): void {
|
||||
if (!isset($this->catalog->snapshots[$snapshotId])) {
|
||||
throw new \InvalidArgumentException('There is no snapshot ' . $snapshotId . ' on this location');
|
||||
}
|
||||
}
|
||||
|
||||
/** Index files downloaded when this repository was opened (0 when the local cache was complete). */
|
||||
public function indexDownloads(): int {
|
||||
return $this->index->downloaded;
|
||||
|
||||
@@ -11,6 +11,7 @@ use OCA\NgBackup\AppInfo\Application;
|
||||
use OCA\NgBackup\Db\RunMapper;
|
||||
use OCA\NgBackup\Db\SnapshotMapper;
|
||||
use OCA\NgBackup\Db\Target;
|
||||
use OCA\NgBackup\Repository\Repository;
|
||||
use OCP\IAppConfig;
|
||||
use OCP\Lock\ILockingProvider;
|
||||
use OCP\Lock\LockedException;
|
||||
@@ -33,6 +34,54 @@ final class PruneService {
|
||||
) {
|
||||
}
|
||||
|
||||
/** Characters of a note on a snapshot (the column holds 1000) */
|
||||
public const NOTE_MAX = 500;
|
||||
|
||||
/** Pin or unpin a snapshot: the retention policy leaves a pinned one alone. */
|
||||
public function setPinned(Target $target, string $snapshotId, bool $pinned): void {
|
||||
$changed = $this->changeCatalog($target, function (Repository $repo) use ($snapshotId, $pinned): bool {
|
||||
$was = in_array($snapshotId, $repo->pinnedSnapshots(), true);
|
||||
$repo->setPinned($snapshotId, $pinned);
|
||||
return $was !== $pinned;
|
||||
});
|
||||
$this->targets->syncSnapshot($target, $snapshotId, pinned: $pinned);
|
||||
if ($changed) {
|
||||
// Unpinning opens the way to removing it, so every administrator hears about that.
|
||||
$this->alerts->securityEvent($pinned ? 'snapshot_pinned' : 'snapshot_unpinned', ['target' => $target->getName(), 'snapshot' => $snapshotId], !$pinned);
|
||||
}
|
||||
}
|
||||
|
||||
public function setNote(Target $target, string $snapshotId, string $note): void {
|
||||
$note = trim((string)preg_replace('/[\x00-\x1f\x7f]+/', ' ', $note));
|
||||
if (!mb_check_encoding($note, 'UTF-8')) {
|
||||
throw new \InvalidArgumentException('A note must be valid UTF-8');
|
||||
}
|
||||
if (mb_strlen($note) > self::NOTE_MAX) {
|
||||
throw new \InvalidArgumentException('A note has at most ' . self::NOTE_MAX . ' characters');
|
||||
}
|
||||
$this->changeCatalog($target, function (Repository $repo) use ($snapshotId, $note): bool {
|
||||
$repo->setSnapshotNote($snapshotId, $note);
|
||||
return true;
|
||||
});
|
||||
$this->targets->syncSnapshot($target, $snapshotId, note: $note);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write to the catalog next to a running backup or restore, which only takes it for a moment to
|
||||
* commit. Not during a cleanup, which holds a repository opened earlier and writes from that.
|
||||
*
|
||||
* @param callable(Repository): bool $change
|
||||
*/
|
||||
private function changeCatalog(Target $target, callable $change): bool {
|
||||
try {
|
||||
return $this->leases->with(self::lockKey($target), LeaseService::SHARED, 300,
|
||||
fn (): bool => $this->leases->with(self::catalogLockKey($target), LeaseService::EXCLUSIVE, 300,
|
||||
fn (): bool => $change($this->targets->repository($target))));
|
||||
} catch (LockedException) {
|
||||
throw new \RuntimeException('A cleanup of ' . $target->getName() . ' or another change to its restore points is in progress; try again later');
|
||||
}
|
||||
}
|
||||
|
||||
public function untrash(Target $target, string $snapshotId): void {
|
||||
$lease = $this->lockExclusive($target, 300);
|
||||
try {
|
||||
@@ -87,6 +136,7 @@ final class PruneService {
|
||||
$s->setCreatedAt((int)strtotime((string)($meta['time'] ?? 'now')));
|
||||
$s->setFiles((int)($meta['stats']['files'] ?? 0));
|
||||
$s->setBytes((int)($meta['stats']['bytes'] ?? 0));
|
||||
$s->setNote($repo->snapshotNotes()[$snapshotId] ?? null);
|
||||
try {
|
||||
$this->snapshots->insert($s);
|
||||
} catch (\Throwable) {
|
||||
@@ -208,7 +258,7 @@ final class PruneService {
|
||||
$meta = $repo->snapshotMeta($id);
|
||||
$times[$id] = (int)strtotime((string)($meta['time'] ?? '@0'));
|
||||
}
|
||||
$keep = $this->policy()->keep($times);
|
||||
$keep = $this->policy()->keepWithPinned($times, $repo->pinnedSnapshots());
|
||||
$forget = array_values(array_diff(array_keys($times), array_keys($keep)));
|
||||
$actor = $scheduled ? 'retention policy' : $this->alerts->actor();
|
||||
$purged = [];
|
||||
|
||||
@@ -28,6 +28,19 @@ final class RetentionPolicy {
|
||||
return ['last' => $this->last, 'daily' => $this->daily, 'weekly' => $this->weekly, 'monthly' => $this->monthly];
|
||||
}
|
||||
|
||||
/**
|
||||
* keep() for the snapshots that are not pinned, plus the pinned ones. A pinned snapshot does not
|
||||
* take the place of a daily, weekly or monthly one.
|
||||
*
|
||||
* @param array<string, int> $snapshots id => unix time
|
||||
* @param list<string> $pinned
|
||||
* @return array<string, list<string>>
|
||||
*/
|
||||
public function keepWithPinned(array $snapshots, array $pinned): array {
|
||||
$pins = array_intersect_key($snapshots, array_flip($pinned));
|
||||
return $this->keep(array_diff_key($snapshots, $pins)) + array_fill_keys(array_keys($pins), ['pinned']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int> $snapshots id => unix time
|
||||
* @return array<string, list<string>> id => reasons it is kept (ids not in the result are removed)
|
||||
|
||||
@@ -208,7 +208,25 @@ final class TargetService {
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** Update the local copy of a snapshot's note or pin, if the snapshot is listed here at all. */
|
||||
public function syncSnapshot(Target $target, string $snapshotId, ?string $note = null, ?bool $pinned = null): void {
|
||||
try {
|
||||
$s = $this->snapshots->findOne($target->getId(), $snapshotId);
|
||||
} catch (DoesNotExistException) {
|
||||
return;
|
||||
}
|
||||
if ($note !== null) {
|
||||
$s->setNote($note !== '' ? $note : null);
|
||||
}
|
||||
if ($pinned !== null) {
|
||||
$s->setPinned($pinned);
|
||||
}
|
||||
$this->snapshots->update($s);
|
||||
}
|
||||
|
||||
private function adoptSnapshots(Target $target, Repository $repo): void {
|
||||
$notes = $repo->snapshotNotes();
|
||||
$pinned = array_flip($repo->pinnedSnapshots());
|
||||
foreach ($repo->listSnapshots() as $snapshotId) {
|
||||
try {
|
||||
$this->snapshots->findOne($target->getId(), $snapshotId);
|
||||
@@ -227,6 +245,8 @@ final class TargetService {
|
||||
$s->setCreatedAt((int)strtotime((string)($meta['time'] ?? 'now')));
|
||||
$s->setFiles((int)($meta['stats']['files'] ?? 0));
|
||||
$s->setBytes((int)($meta['stats']['bytes'] ?? 0));
|
||||
$s->setNote($notes[$snapshotId] ?? null);
|
||||
$s->setPinned(isset($pinned[$snapshotId]));
|
||||
$this->snapshots->insert($s);
|
||||
} catch (\Throwable $e) {
|
||||
Server::get(\Psr\Log\LoggerInterface::class)->warning('NG Backup: snapshot ' . $snapshotId . ' on ' . $target->getName() . ' not listed: ' . $e->getMessage(), ['exception' => $e]);
|
||||
|
||||
@@ -300,6 +300,14 @@
|
||||
<code><![CDATA[$t]]></code>
|
||||
</UndefinedDocblockClass>
|
||||
</file>
|
||||
<file src="lib/Migration/Version000007Date20261107000000.php">
|
||||
<UndefinedDocblockClass>
|
||||
<code><![CDATA[$t]]></code>
|
||||
<code><![CDATA[$t]]></code>
|
||||
<code><![CDATA[$t]]></code>
|
||||
<code><![CDATA[$t]]></code>
|
||||
</UndefinedDocblockClass>
|
||||
</file>
|
||||
<file src="lib/Migration/Version000005Date20261002040000.php">
|
||||
<UndefinedDocblockClass>
|
||||
<code><![CDATA[$t]]></code>
|
||||
|
||||
@@ -36,6 +36,8 @@ export default {
|
||||
tick: () => call('post', '/tick'),
|
||||
setSchedule: (time, timezone) => call('put', '/schedule', { time, timezone }),
|
||||
snapshots: (targetId) => call('get', `/targets/${targetId}/snapshots`),
|
||||
setSnapshotNote: (targetId, snapshotId, note) => call('put', `/targets/${targetId}/snapshots/${snapshotId}/note`, { note }),
|
||||
setSnapshotPinned: (targetId, snapshotId, pinned) => call('put', `/targets/${targetId}/snapshots/${snapshotId}/pin`, { pinned }),
|
||||
browse: (targetId, snapshotId, path) => call('get', `/targets/${targetId}/snapshots/${snapshotId}/browse`, { path }),
|
||||
startRestore: (targetId, snapshotId, path, mode) => call('post', '/restores', { targetId, snapshotId, path, mode }),
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<td>{{ formatDateTime(run.startedAt) }}</td>
|
||||
<td>{{ targetName(run.targetId) }}</td>
|
||||
<td>{{ run.kind }}</td>
|
||||
<td>{{ run.snapshot ? formatDateTime(run.snapshot.createdAt) + (run.snapshot.label ? ' – ' + run.snapshot.label : '') : '' }}</td>
|
||||
<td>{{ run.snapshot ? (run.snapshot.pinned ? '📌 ' : '') + formatDateTime(run.snapshot.createdAt) + (run.snapshot.label ? ' – ' + run.snapshot.label : '') + (run.snapshot.note ? ' – ' + run.snapshot.note : '') : '' }}</td>
|
||||
<td :class="'ngb-' + run.status">{{ runStatus(run) }}{{ run.error ? ': ' + run.error : '' }}</td>
|
||||
<td>{{ run.stats?.files?.files ?? run.stats?.restored ?? '' }}</td>
|
||||
<td>{{ run.stats?.files ? formatSize(run.stats.files.bytes) : '' }}</td>
|
||||
|
||||
@@ -11,6 +11,13 @@
|
||||
</div>
|
||||
|
||||
<template v-if="snapshot">
|
||||
<div class="ngb-row ngb-marks">
|
||||
<NcTextField v-model="note" class="ngb-note" :maxlength="500" :label="t('ng_backup', 'Note (can be changed later)')" />
|
||||
<NcButton :disabled="marking || note.trim() === (snapshot.note ?? '')" @click="saveNote">{{ t('ng_backup', 'Save note') }}</NcButton>
|
||||
<NcCheckboxRadioSwitch :model-value="snapshot.pinned" :disabled="marking" @update:model-value="savePinned">
|
||||
{{ t('ng_backup', 'Pin: the retention policy does not remove this restore point') }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
</div>
|
||||
<div class="ngb-crumbs">
|
||||
<NcButton variant="tertiary" @click="open('data')">data</NcButton>
|
||||
<template v-for="(crumb, i) in crumbs" :key="crumb.path">
|
||||
@@ -49,6 +56,7 @@ import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
||||
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
|
||||
import NcSelect from '@nextcloud/vue/components/NcSelect'
|
||||
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
|
||||
import NcTextField from '@nextcloud/vue/components/NcTextField'
|
||||
import { confirmPassword } from '@nextcloud/password-confirmation'
|
||||
import '@nextcloud/password-confirmation/style.css'
|
||||
import api from '../api.js'
|
||||
@@ -74,7 +82,10 @@ const modes = [
|
||||
{ id: 'replace', label: t('ng_backup', 'Into the original place, overwrite and move files that are not in the backup to the trash') },
|
||||
]
|
||||
|
||||
const snapshotLabel = (s) => formatDateTime(s.createdAt) + (s.label ? ' – ' + s.label : '')
|
||||
const note = ref('')
|
||||
const marking = ref(false)
|
||||
|
||||
const snapshotLabel = (s) => (s.pinned ? '📌 ' : '') + formatDateTime(s.createdAt) + (s.label ? ' – ' + s.label : '') + (s.note ? ' – ' + s.note : '')
|
||||
const join = (name) => path.value + '/' + name
|
||||
const restorable = (p) => /^data\/[^/]+\/files(\/.*)?$/.test(p)
|
||||
const crumbs = computed(() => {
|
||||
@@ -88,7 +99,29 @@ watch(target, async (tg) => {
|
||||
snapshots.value = list.map(s => ({ ...s, displayLabel: snapshotLabel(s) }))
|
||||
snapshot.value = snapshots.value[0] ?? null
|
||||
}, { immediate: true })
|
||||
watch(snapshot, () => { selected.value = ''; open('data') })
|
||||
watch(snapshot, (s) => { selected.value = ''; note.value = s?.note ?? ''; open('data') })
|
||||
|
||||
// Changes the snapshot in place: choosing another one, which opens its folders again, is not needed
|
||||
async function mark(call, change) {
|
||||
const s = snapshot.value
|
||||
marking.value = true
|
||||
error.value = message.value = ''
|
||||
try {
|
||||
await call(target.value.id, s.id)
|
||||
change(s)
|
||||
s.displayLabel = snapshotLabel(s)
|
||||
emit('changed')
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
marking.value = false
|
||||
}
|
||||
}
|
||||
const saveNote = () => {
|
||||
const text = note.value.trim()
|
||||
return mark((targetId, id) => api.setSnapshotNote(targetId, id, text), (s) => { s.note = text })
|
||||
}
|
||||
const savePinned = (pinned) => mark((targetId, id) => api.setSnapshotPinned(targetId, id, pinned), (s) => { s.pinned = pinned })
|
||||
|
||||
async function open(p) {
|
||||
if (!snapshot.value) {
|
||||
@@ -125,6 +158,8 @@ async function restore() {
|
||||
<style scoped>
|
||||
.ngb-row { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 12px; }
|
||||
.ngb-row :deep(.ngb-snapshot) { min-width: min(360px, 100%); }
|
||||
.ngb-marks { align-items: flex-end; }
|
||||
.ngb-note { max-width: 360px; }
|
||||
.ngb-crumbs { display: flex; align-items: center; flex-wrap: wrap; }
|
||||
.ngb-entries li { display: flex; align-items: center; gap: 12px; min-height: 36px; }
|
||||
.ngb-entries li.selected { background: var(--color-primary-element-light); border-radius: var(--border-radius); }
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
<?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;
|
||||
|
||||
/** A pin and a note on a snapshot live in the catalog on the location, so a new server sees them too. */
|
||||
class SnapshotMarksTest extends TestCase {
|
||||
use TempDirTrait;
|
||||
|
||||
private LocalBackend $backend;
|
||||
private KeyRing $keys;
|
||||
private string $snapshot;
|
||||
|
||||
protected function setUp(): void {
|
||||
$this->backend = new LocalBackend($this->tempDir());
|
||||
$this->keys = KeyRing::generate();
|
||||
$src = $this->tempDir();
|
||||
file_put_contents("$src/a", 'x');
|
||||
$this->snapshot = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'))->backupDirectory($src)['snapshot'];
|
||||
}
|
||||
|
||||
private function repo(): Repository {
|
||||
return Repository::openWithKey($this->backend, $this->keys);
|
||||
}
|
||||
|
||||
public function testTheyAreThereAfterOpeningAgain(): void {
|
||||
$this->repo()->setPinned($this->snapshot, true);
|
||||
$this->repo()->setSnapshotNote($this->snapshot, 'before the upgrade');
|
||||
$this->assertSame([$this->snapshot], $this->repo()->pinnedSnapshots());
|
||||
$this->assertSame([$this->snapshot => 'before the upgrade'], $this->repo()->snapshotNotes());
|
||||
}
|
||||
|
||||
public function testTheyCanBeTakenOffAgain(): void {
|
||||
$this->repo()->setPinned($this->snapshot, true);
|
||||
$this->repo()->setSnapshotNote($this->snapshot, 'x');
|
||||
$this->repo()->setPinned($this->snapshot, false);
|
||||
$this->repo()->setSnapshotNote($this->snapshot, '');
|
||||
$this->assertSame([], $this->repo()->pinnedSnapshots());
|
||||
$this->assertSame([], $this->repo()->snapshotNotes());
|
||||
}
|
||||
|
||||
public function testNoChangeWritesNoCatalog(): void {
|
||||
$repo = $this->repo();
|
||||
$repo->setPinned($this->snapshot, true);
|
||||
$generation = $repo->catalogGeneration();
|
||||
$repo->setPinned($this->snapshot, true);
|
||||
$repo->setSnapshotNote($this->snapshot, '');
|
||||
$this->assertSame($generation, $repo->catalogGeneration());
|
||||
}
|
||||
|
||||
public function testASnapshotThatIsNotThereIsRefused(): void {
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->repo()->setPinned('0123456789abcdef0123456789abcdef', true);
|
||||
}
|
||||
|
||||
public function testATrashedSnapshotLosesItsPinAndKeepsItsNote(): void {
|
||||
$repo = $this->repo();
|
||||
$repo->setPinned($this->snapshot, true);
|
||||
$repo->setSnapshotNote($this->snapshot, 'kept');
|
||||
$repo->forget($this->snapshot);
|
||||
$this->assertSame([], $this->repo()->pinnedSnapshots());
|
||||
$this->assertSame([$this->snapshot => 'kept'], $this->repo()->snapshotNotes());
|
||||
|
||||
$repo = $this->repo();
|
||||
$repo->untrash($this->snapshot);
|
||||
$this->assertSame([], $this->repo()->pinnedSnapshots(), 'back in the list, as a normal one');
|
||||
$this->assertSame([$this->snapshot => 'kept'], $this->repo()->snapshotNotes());
|
||||
}
|
||||
|
||||
public function testThePurgeTakesTheNoteAlong(): void {
|
||||
$repo = $this->repo();
|
||||
$repo->setSnapshotNote($this->snapshot, 'kept');
|
||||
$repo->forget($this->snapshot);
|
||||
$repo->purgeTrash(0);
|
||||
$this->assertSame([], $this->repo()->snapshotNotes());
|
||||
}
|
||||
|
||||
public function testACatalogFromBeforeThisVersionReadsAsNone(): void {
|
||||
$this->assertSame([], $this->repo()->pinnedSnapshots());
|
||||
$this->assertSame([], $this->repo()->snapshotNotes());
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,17 @@ class RetentionPolicyTest extends TestCase {
|
||||
$this->assertCount(3, $months);
|
||||
}
|
||||
|
||||
public function testAPinnedSnapshotStaysAndDoesNotCount(): void {
|
||||
$policy = new RetentionPolicy(1, 0, 0, 0);
|
||||
$snaps = ['old' => 100, 'pinned' => 300, 'new' => 200];
|
||||
$this->assertSame(['pinned'], array_keys($policy->keep($snaps)), 'without the pin it would take the place of "last"');
|
||||
$keep = $policy->keepWithPinned($snaps, ['pinned', 'gone-already']);
|
||||
$this->assertSame(['pinned'], $keep['pinned']);
|
||||
$this->assertSame(['last'], $keep['new'], 'the newest one that is not pinned');
|
||||
$this->assertArrayNotHasKey('old', $keep);
|
||||
$this->assertArrayNotHasKey('gone-already', $keep);
|
||||
}
|
||||
|
||||
public function testKeepsEverythingWhenFewSnapshots(): void {
|
||||
$keep = (new RetentionPolicy())->keep(['a' => 100, 'b' => 200]);
|
||||
$this->assertSame(['a', 'b'], array_values(array_intersect(['a', 'b'], array_keys($keep))));
|
||||
|
||||
@@ -120,6 +120,17 @@ ok "database dump and restore"
|
||||
|
||||
# Retention, trash and catalog.
|
||||
occ backup:retention --last=1 --daily=0 --weekly=0 --monthly=0 >/dev/null
|
||||
# A pinned restore point stays, and a note can change afterwards.
|
||||
FIRST=$(occ backup:list ci | awk -F'|' '!f && / first /{gsub(/ /,"",$3); print $3; f=1}')
|
||||
occ backup:pin ci "$FIRST" >/dev/null || fail "pin"
|
||||
occ backup:note ci "$FIRST" "a note made later" >/dev/null || fail "note"
|
||||
occ backup:list ci | grep " first " | grep " yes " | grep "a note made later" >/dev/null || fail "pin and note not listed"
|
||||
occ backup:prune ci --dry-run | grep "would forget 1 " >/dev/null || fail "a pinned snapshot would be removed"
|
||||
occ backup:pin ci "$FIRST" --unpin >/dev/null || fail "unpin"
|
||||
occ backup:prune ci --dry-run | grep "would forget 2 " >/dev/null || fail "an unpinned snapshot would stay"
|
||||
occ backup:note ci "$FIRST" >/dev/null || fail "remove note"
|
||||
occ backup:list ci | grep -q "a note made later" && fail "note not removed"
|
||||
ok "pin and note on a restore point"
|
||||
occ backup:prune ci | grep "forgot 2" >/dev/null || fail "prune" # first and second; "page" (above) is the last
|
||||
occ backup:trash ci | grep " first " >/dev/null || fail "trash"
|
||||
TID=$(occ backup:trash ci | awk -F'|' '!f && / first /{gsub(/ /,"",$3); print $3; f=1}')
|
||||
|
||||
Reference in New Issue
Block a user