Compare commits
2 Commits
v1.0.0
..
95e3dfcf6d
| Author | SHA1 | Date | |
|---|---|---|---|
| 95e3dfcf6d | |||
| 7f4cae0e42 |
+12
-4
@@ -7,11 +7,19 @@ the app store accepts a `-beta` suffix, but Nextcloud servers then only offer th
|
|||||||
beta/daily update channel, which would hide the app from most admins. The stability stage is
|
beta/daily update channel, which would hide the app from most admins. The stability stage is
|
||||||
instead carried by the app description, the git tag, the release, and this file.
|
instead carried by the app description, the git tag, the release, and this file.
|
||||||
|
|
||||||
## [1.0.0] - 2026-10-29
|
## [Unreleased]
|
||||||
|
|
||||||
First stable release: the same code as 1.0.0-rc.6, which had no blocking problems reported in its
|
### Added
|
||||||
week out (about 220 downloads). Only the version number and the README changed. The fixes for
|
- `occ backup:user:list [location] [user]` shows the user exports on a location, with the manifest
|
||||||
what was reported (#22, #24) follow in 1.0.1.
|
path `backup:user:restore` expects. Before, that path was only printed once by
|
||||||
|
`backup:user:backup` (#22).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- `occ backup:user:restore` also takes a user id instead of the manifest path, when that user has
|
||||||
|
exactly one export on the location. With more, and for a manifest path that is not on the
|
||||||
|
location, the error lists that user's exports; for a user without exports it names the
|
||||||
|
expected form (#22). Before, `backup:user:restore <location> <uid>` only said "is not a
|
||||||
|
recorded user export for this location".
|
||||||
|
|
||||||
## [1.0.0-rc.6] - 2026-10-24
|
## [1.0.0-rc.6] - 2026-10-24
|
||||||
|
|
||||||
|
|||||||
@@ -434,6 +434,20 @@ in README "Known limitations":
|
|||||||
Een restore ruimt `*.ngb-restore-*` in de mappen die hij terugzet op.
|
Een restore ruimt `*.ngb-restore-*` in de mappen die hij terugzet op.
|
||||||
7. **Eén bestand terugzetten met `backup:restore`**: een pad naar een bestand zet 0 bestanden
|
7. **Eén bestand terugzetten met `backup:restore`**: een pad naar een bestand zet 0 bestanden
|
||||||
terug (er komt een `/` achter); nu werkt alleen een map.
|
terug (er komt een `/` achter); nu werkt alleen een map.
|
||||||
|
8. **Uitleg (i) bij de prullenbak van herstelpunten** (besluit 2026-10-25): bij "removed by
|
||||||
|
retention policy" een infoknop met wat het betekent: het bewaarbeleid houdt voor oudere dagen
|
||||||
|
één herstelpunt per dag (het laatste), weken en maanden; de rest gaat naar de prullenbak en
|
||||||
|
wordt pas na de ingestelde wachttijd (`delete_delay_days`, standaard 30) echt verwijderd, en is
|
||||||
|
tot die datum terug te zetten. Aanleiding: de beheerder zag "19-10 13:12 – after rc4 upgrade
|
||||||
|
removed by retention policy" en dacht dat er iets was weggegooid.
|
||||||
|
9. **Tijdzone in `occ backup:status`**: de tabel toont de servertijd (UTC), de webinterface de
|
||||||
|
tijd van de beheerder; dat leest als een verkeerde starttijd. Zet de zone in de kolomkop
|
||||||
|
("started (UTC)") of toon de tijden in de zone van het schema.
|
||||||
|
10. **User-restore op gebruikersnaam** (issue #22, 2026-10-27): `backup:user:restore <locatie>
|
||||||
|
New_User` gaf alleen "is not a recorded user export"; het argument moet het manifestpad
|
||||||
|
zijn, dat alleen `backup:user:backup` eenmalig toonde. Nu: `backup:user:list` toont de
|
||||||
|
exports, een kale uid werkt bij precies één export, anders een lijst om uit te kiezen —
|
||||||
|
**klaar** (2026-10-27).
|
||||||
- **DR-tests over databasegrenzen** (besluit 2026-10-20; productie nc01 draait PostgreSQL): na
|
- **DR-tests over databasegrenzen** (besluit 2026-10-20; productie nc01 draait PostgreSQL): na
|
||||||
de MariaDB→PostgreSQL-fout (tellers, rc5) elke overstap in de integratietest (`to-pgsql`,
|
de MariaDB→PostgreSQL-fout (tellers, rc5) elke overstap in de integratietest (`to-pgsql`,
|
||||||
`to-mysql`, `to-sqlite`, ook in CI). Volle DR-tests met echte data: vóór 1.0.0 de S3-simulatie
|
`to-mysql`, `to-sqlite`, ook in CI). Volle DR-tests met echte data: vóór 1.0.0 de S3-simulatie
|
||||||
@@ -470,6 +484,10 @@ in README "Known limitations":
|
|||||||
6. **Notitie achteraf bij een restorepunt**, te wijzigen in de lijst. Het label in de snapshot
|
6. **Notitie achteraf bij een restorepunt**, te wijzigen in de lijst. Het label in de snapshot
|
||||||
is versleuteld en ligt vast; de notitie apart op de locatie bewaren (bijv. in de catalogus),
|
is versleuteld en ligt vast; de notitie apart op de locatie bewaren (bijv. in de catalogus),
|
||||||
zodat een nieuwe server na een DR hem ook ziet.
|
zodat een nieuwe server na een DR hem ook ziet.
|
||||||
|
8. **Herstelpunt vastpinnen** (besluit 2026-10-25): een herstelpunt dat het bewaarbeleid
|
||||||
|
nooit opruimt (bijv. "vóór de upgrade naar NC 35"), in de lijst en met
|
||||||
|
`occ backup:pin <snapshot>` / `--unpin`. Op de locatie bewaren (catalogus), zodat de
|
||||||
|
opschoning het ziet, ook na een DR; vastgepinde punten tellen niet mee in last/daily/weekly.
|
||||||
- **Bevindingen DR-test op PVE02 (2026-10-17, snapshot van nc01, 1 TB, AIO → gewone NC 34 met
|
- **Bevindingen DR-test op PVE02 (2026-10-17, snapshot van nc01, 1 TB, AIO → gewone NC 34 met
|
||||||
PostgreSQL 18)** — voor **1.0.0-rc.2**:
|
PostgreSQL 18)** — voor **1.0.0-rc.2**:
|
||||||
1. Tabellen van apps die bij de backup **niet aanstonden** (restanten: Deck, ONLYOFFICE,
|
1. Tabellen van apps die bij de backup **niet aanstonden** (restanten: Deck, ONLYOFFICE,
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ installations and hosted Nextcloud. (Choosing SMB as a storage backend is the on
|
|||||||
see "Storage backend experiences" below — that requirement comes from files_external, not from
|
see "Storage backend experiences" below — that requirement comes from files_external, not from
|
||||||
ng_backup, and applies to any Nextcloud app that writes to an SMB share.)
|
ng_backup, and applies to any Nextcloud app that writes to an SMB share.)
|
||||||
|
|
||||||
**Status: 1.0.0, stable.** Disaster recovery has been tested on real installations (see "Tested
|
**Status: release candidate for 1.0.0 (1.0.0-rc.1, app version 0.9.7).** Tested thoroughly, usable
|
||||||
disaster recoveries" below). See "Known limitations" below, `PLAN.md` for the design and
|
alongside your current backup, but not yet a drop-in replacement for it. See "Known limitations" below, `PLAN.md` for the design and
|
||||||
`docs/HOWTO.md` for a step-by-step guide from setup to a tested restore. Server lost? See
|
`docs/HOWTO.md` for a step-by-step guide from setup to a tested restore. Server lost? See
|
||||||
`docs/DISASTER-RECOVERY.md`.
|
`docs/DISASTER-RECOVERY.md`.
|
||||||
|
|
||||||
@@ -265,6 +265,8 @@ a 👍 on its issue helps decide what comes first.
|
|||||||
system cron, with how to set one up. Without cron, a backup started with "Back up now" moves on
|
system cron, with how to set one up. Without cron, a backup started with "Back up now" moves on
|
||||||
while the NG Backup page is open ([#13](https://github.com/dillardblom/ng_backup/issues/13)).
|
while the NG Backup page is open ([#13](https://github.com/dillardblom/ng_backup/issues/13)).
|
||||||
- An optional label for a backup started with "Back up now".
|
- An optional label for a backup started with "Back up now".
|
||||||
|
- An explanation in the trash of restore points of what "removed by retention policy" means and
|
||||||
|
until when a restore point can be brought back.
|
||||||
- Faster full restores from S3 and SMB, reading ahead as restores over SFTP do since 1.0.0-rc.4.
|
- Faster full restores from S3 and SMB, reading ahead as restores over SFTP do since 1.0.0-rc.4.
|
||||||
|
|
||||||
**1.1.0**
|
**1.1.0**
|
||||||
@@ -273,6 +275,8 @@ a 👍 on its issue helps decide what comes first.
|
|||||||
- A schedule per location, also "every N hours".
|
- A schedule per location, also "every N hours".
|
||||||
- Locations backed up one after the other instead of at the same time.
|
- Locations backed up one after the other instead of at the same time.
|
||||||
- A note on a restore point that you can change afterwards.
|
- A note on a restore point that you can change afterwards.
|
||||||
|
- Pinning a restore point, so the retention policy never removes it (for example the one from
|
||||||
|
before an upgrade).
|
||||||
- An option to pause heavy background jobs (such as Memories, Recognize or the Preview
|
- An option to pause heavy background jobs (such as Memories, Recognize or the Preview
|
||||||
Generator) while a backup runs.
|
Generator) while a backup runs.
|
||||||
- Faster backups: reading and uploading at the same time.
|
- Faster backups: reading and uploading at the same time.
|
||||||
@@ -287,7 +291,7 @@ a 👍 on its issue helps decide what comes first.
|
|||||||
|
|
||||||
## Known limitations
|
## Known limitations
|
||||||
|
|
||||||
On the backlog; know them before you rely on NG Backup:
|
On the backlog; none of these block the beta or 1.0.0, but know them before you rely on it:
|
||||||
|
|
||||||
- **Only the data directory is backed up.** Object storage as primary storage (`objectstore` in
|
- **Only the data directory is backed up.** Object storage as primary storage (`objectstore` in
|
||||||
`config.php`) is not supported: a backup refuses to start there. Folders mounted through the
|
`config.php`) is not supported: a backup refuses to start there. Folders mounted through the
|
||||||
@@ -330,4 +334,4 @@ On the backlog; know them before you rely on NG Backup:
|
|||||||
| `backup:verify` | checksum audit of a restore point (`--deep` downloads and decrypts everything) |
|
| `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:restore:full` | whole-server restore onto a fresh installation (after `backup:key:import`) |
|
||||||
| `backup:key:import` | import a recovery kit on a fresh installation |
|
| `backup:key:import` | import a recovery kit on a fresh installation |
|
||||||
| `backup:user:backup`, `backup:user:restore` | one user via user_migration (asks before installing it) |
|
| `backup:user:backup`, `backup:user:list`, `backup:user:restore` | one user via user_migration (asks before installing it); list the exports |
|
||||||
|
|||||||
+5
-4
@@ -42,7 +42,7 @@ welcome: use the "Share your experience" link on the admin page.
|
|||||||
|
|
||||||
Developed and sponsored by [Ensembia](https://www.ensembia.com). We can offer (paid) assistance if
|
Developed and sponsored by [Ensembia](https://www.ensembia.com). We can offer (paid) assistance if
|
||||||
you need help with restoring a backup after a crash.]]></description>
|
you need help with restoring a backup after a crash.]]></description>
|
||||||
<version>1.0.0</version>
|
<version>0.9.12</version>
|
||||||
<licence>agpl</licence>
|
<licence>agpl</licence>
|
||||||
<author mail="dillard.blom@ensembia.com">Dillard Blom</author>
|
<author mail="dillard.blom@ensembia.com">Dillard Blom</author>
|
||||||
<namespace>NgBackup</namespace>
|
<namespace>NgBackup</namespace>
|
||||||
@@ -53,9 +53,9 @@ you need help with restoring a backup after a crash.]]></description>
|
|||||||
<website>https://github.com/dillardblom/ng_backup</website>
|
<website>https://github.com/dillardblom/ng_backup</website>
|
||||||
<bugs>https://github.com/dillardblom/ng_backup/issues</bugs>
|
<bugs>https://github.com/dillardblom/ng_backup/issues</bugs>
|
||||||
<repository type="git">https://github.com/dillardblom/ng_backup</repository>
|
<repository type="git">https://github.com/dillardblom/ng_backup</repository>
|
||||||
<screenshot>https://raw.githubusercontent.com/dillardblom/ng_backup/v1.0.0/screenshots/1-backups.png</screenshot>
|
<screenshot>https://raw.githubusercontent.com/dillardblom/ng_backup/v1.0.0-rc.6/screenshots/1-backups.png</screenshot>
|
||||||
<screenshot>https://raw.githubusercontent.com/dillardblom/ng_backup/v1.0.0/screenshots/2-restore.png</screenshot>
|
<screenshot>https://raw.githubusercontent.com/dillardblom/ng_backup/v1.0.0-rc.6/screenshots/2-restore.png</screenshot>
|
||||||
<screenshot>https://raw.githubusercontent.com/dillardblom/ng_backup/v1.0.0/screenshots/3-settings.png</screenshot>
|
<screenshot>https://raw.githubusercontent.com/dillardblom/ng_backup/v1.0.0-rc.6/screenshots/3-settings.png</screenshot>
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<php min-version="8.1"/>
|
<php min-version="8.1"/>
|
||||||
<lib>sodium</lib>
|
<lib>sodium</lib>
|
||||||
@@ -92,6 +92,7 @@ you need help with restoring a backup after a crash.]]></description>
|
|||||||
<command>OCA\NgBackup\Command\KeySlotRemove</command>
|
<command>OCA\NgBackup\Command\KeySlotRemove</command>
|
||||||
<command>OCA\NgBackup\Command\BackupUser</command>
|
<command>OCA\NgBackup\Command\BackupUser</command>
|
||||||
<command>OCA\NgBackup\Command\RestoreUser</command>
|
<command>OCA\NgBackup\Command\RestoreUser</command>
|
||||||
|
<command>OCA\NgBackup\Command\ListUserExports</command>
|
||||||
<command>OCA\NgBackup\Command\KeyImport</command>
|
<command>OCA\NgBackup\Command\KeyImport</command>
|
||||||
<command>OCA\NgBackup\Command\RestoreFull</command>
|
<command>OCA\NgBackup\Command\RestoreFull</command>
|
||||||
<command>OCA\NgBackup\Command\Verify</command>
|
<command>OCA\NgBackup\Command\Verify</command>
|
||||||
|
|||||||
@@ -114,10 +114,16 @@ Needs the `user_migration` app. NG Backup asks before installing it:
|
|||||||
|
|
||||||
```
|
```
|
||||||
occ backup:user:backup offsite <uid>
|
occ backup:user:backup offsite <uid>
|
||||||
|
occ backup:user:list offsite [<uid>] # the exports and their manifest
|
||||||
occ backup:user:restore offsite users/<uid>/<exportId> # replace (default)
|
occ backup:user:restore offsite users/<uid>/<exportId> # replace (default)
|
||||||
occ backup:user:restore offsite users/<uid>/<exportId> --mode=as-backup
|
occ backup:user:restore offsite users/<uid>/<exportId> --mode=as-backup
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- `backup:user:restore` needs the manifest path that `backup:user:backup` prints at the end
|
||||||
|
(`users/<uid>/<exportId>`); `backup:user:list` shows it again. A user id alone also works when
|
||||||
|
that user has exactly one export on the location. With more (a `replace` restore adds a safety
|
||||||
|
export), the command lists them and you pass one.
|
||||||
|
|
||||||
- If `user_migration` is missing, the command asks to install and enable it. For non-interactive
|
- If `user_migration` is missing, the command asks to install and enable it. For non-interactive
|
||||||
use, add `--install-user-migration` to approve that explicitly. The command stops after the
|
use, add `--install-user-migration` to approve that explicitly. The command stops after the
|
||||||
installation; run it once more.
|
installation; run it once more.
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<?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\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;
|
||||||
|
|
||||||
|
final class ListUserExports extends Command {
|
||||||
|
public function __construct(
|
||||||
|
private TargetService $targets,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void {
|
||||||
|
$this->setName('backup:user:list')
|
||||||
|
->setDescription('List the user exports (backup:user:backup) on a location')
|
||||||
|
->addArgument('target', InputArgument::OPTIONAL, 'Location name or id (default: all)')
|
||||||
|
->addArgument('user', InputArgument::OPTIONAL, 'Only the exports of this user id')
|
||||||
|
->setHelp(<<<'HELP'
|
||||||
|
The manifest column is what occ backup:user:restore expects. A safety export made by a restore
|
||||||
|
with --mode=replace is listed here as well.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
occ backup:user:list offsite alice
|
||||||
|
HELP);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||||
|
$name = $input->getArgument('target');
|
||||||
|
$uid = $input->getArgument('user');
|
||||||
|
$rows = [];
|
||||||
|
try {
|
||||||
|
$targets = $name === null ? $this->targets->list() : [$this->targets->get($name)];
|
||||||
|
foreach ($targets as $t) {
|
||||||
|
foreach ($this->targets->repository($t)->listUserExports($uid) as $path) {
|
||||||
|
$parts = explode('/', $path, 3);
|
||||||
|
$user = $parts[1] ?? '';
|
||||||
|
$exportId = $parts[2] ?? '';
|
||||||
|
$time = \DateTimeImmutable::createFromFormat('Ymd\THis\Z', substr($exportId, 0, 16), new \DateTimeZone('UTC'));
|
||||||
|
$rows[] = [$t->getName(), $user, $time !== false ? $time->format('Y-m-d H:i') : '', $path];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$output->writeln('<error>' . $e->getMessage() . '</error>');
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if ($rows === []) {
|
||||||
|
$output->writeln('No user exports yet (occ backup:user:backup).');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
(new Table($output))->setHeaders(['location', 'user', 'date (UTC)', 'manifest'])->setRows($rows)->render();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,7 +33,7 @@ final class RestoreUser extends Command {
|
|||||||
$this->setName('backup:user:restore')
|
$this->setName('backup:user:restore')
|
||||||
->setDescription('Restore a user from a user_migration export (see backup:user:backup)')
|
->setDescription('Restore a user from a user_migration export (see backup:user:backup)')
|
||||||
->addArgument('target', InputArgument::REQUIRED, 'Location name or id')
|
->addArgument('target', InputArgument::REQUIRED, 'Location name or id')
|
||||||
->addArgument('manifest', InputArgument::REQUIRED, 'Manifest path printed by backup:user:backup (users/<uid>/<exportId>)')
|
->addArgument('manifest', InputArgument::REQUIRED, 'Manifest path printed by backup:user:backup (users/<uid>/<exportId>), or a user id with exactly one export')
|
||||||
->addOption('mode', 'm', InputOption::VALUE_REQUIRED, 'replace or as-backup', UserRestoreService::MODE_REPLACE)
|
->addOption('mode', 'm', InputOption::VALUE_REQUIRED, 'replace or as-backup', UserRestoreService::MODE_REPLACE)
|
||||||
->addOption('force', null, InputOption::VALUE_NONE, 'Replace an existing account without asking (required when not interactive)')
|
->addOption('force', null, InputOption::VALUE_NONE, 'Replace an existing account without asking (required when not interactive)')
|
||||||
->addOption('install-user-migration', null, InputOption::VALUE_NONE, 'Install and enable user_migration if it is missing (non-interactive consent)')
|
->addOption('install-user-migration', null, InputOption::VALUE_NONE, 'Install and enable user_migration if it is missing (non-interactive consent)')
|
||||||
@@ -45,16 +45,31 @@ Never imports over a live account. Modes:
|
|||||||
compare and transfer by hand; Nextcloud cannot rename a user id, so the backup gets
|
compare and transfer by hand; Nextcloud cannot rename a user id, so the backup gets
|
||||||
the new name, never the existing account
|
the new name, never the existing account
|
||||||
|
|
||||||
|
occ backup:user:list shows the exports on a location. A user id alone works when that user
|
||||||
|
has exactly one export there; with more, the command lists them and you pass one.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
occ backup:user:restore offsite users/alice/20261005T120000Z-ab12cd34
|
occ backup:user:restore offsite users/alice/20261005T120000Z-ab12cd34
|
||||||
|
occ backup:user:restore offsite alice
|
||||||
occ backup:user:restore offsite users/alice/20261005T120000Z-ab12cd34 --mode=as-backup
|
occ backup:user:restore offsite users/alice/20261005T120000Z-ab12cd34 --mode=as-backup
|
||||||
HELP);
|
HELP);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||||
$mode = (string)$input->getOption('mode');
|
$mode = (string)$input->getOption('mode');
|
||||||
$uid = explode('/', (string)$input->getArgument('manifest'))[1] ?? '';
|
try {
|
||||||
if ($mode === UserRestoreService::MODE_REPLACE && $uid !== '' && $this->userManager->userExists($uid) && !$input->getOption('force')) {
|
$target = $this->targets->get($input->getArgument('target'));
|
||||||
|
$ref = (string)$input->getArgument('manifest');
|
||||||
|
$manifest = $this->targets->repository($target)->resolveUserExport($ref);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$output->writeln('<error>' . $e->getMessage() . '</error>');
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if ($manifest !== $ref) {
|
||||||
|
$output->writeln("Using $manifest.");
|
||||||
|
}
|
||||||
|
$uid = explode('/', $manifest)[1];
|
||||||
|
if ($mode === UserRestoreService::MODE_REPLACE && $this->userManager->userExists($uid) && !$input->getOption('force')) {
|
||||||
// Replace deletes the live account (after a safety export). Shares to others and data of
|
// Replace deletes the live account (after a safety export). Shares to others and data of
|
||||||
// apps without a user_migration migrator do not come back, so never do it unasked.
|
// apps without a user_migration migrator do not come back, so never do it unasked.
|
||||||
if (!$input->isInteractive()) {
|
if (!$input->isInteractive()) {
|
||||||
@@ -69,9 +84,8 @@ HELP);
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
$output->writeln('<comment>Note: on restore, user_migration only imports the app settings on its own allowlist (e.g. calendar view and reminder settings); other app settings of this user are skipped. Reason: NG Backup uses user_migration\'s importer; widening the list would mean building a settings importer of our own.</comment>');
|
$output->writeln('<comment>Note: on restore, user_migration only imports the app settings on its own allowlist (e.g. calendar view and reminder settings); other app settings of this user are skipped. Reason: NG Backup uses user_migration\'s importer; widening the list would mean building a settings importer of our own.</comment>');
|
||||||
$target = $this->targets->get($input->getArgument('target'));
|
|
||||||
$this->installer->ensureEnabled($input, $output);
|
$this->installer->ensureEnabled($input, $output);
|
||||||
$user = $this->userRestore->restoreUser($target, (string)$input->getArgument('manifest'), $mode);
|
$user = $this->userRestore->restoreUser($target, $manifest, $mode);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$output->writeln('<error>' . $e->getMessage() . '</error>');
|
$output->writeln('<error>' . $e->getMessage() . '</error>');
|
||||||
return 1;
|
return 1;
|
||||||
|
|||||||
@@ -561,6 +561,31 @@ final class Repository {
|
|||||||
return isset($this->catalog->userExports[$manifestPath]);
|
return isset($this->catalog->userExports[$manifestPath]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The recorded export $ref names: a manifest path (users/<uid>/<exportId>) as is, or a bare
|
||||||
|
* user id (also "users/<uid>") when that user has exactly one export here. Anything else
|
||||||
|
* throws, with the exports to choose from in the message.
|
||||||
|
*/
|
||||||
|
public function resolveUserExport(string $ref): string {
|
||||||
|
if ($this->isRecordedUserExport($ref)) {
|
||||||
|
return $ref;
|
||||||
|
}
|
||||||
|
$rest = trim($ref, '/');
|
||||||
|
if (str_starts_with($rest, 'users/')) {
|
||||||
|
$rest = substr($rest, strlen('users/'));
|
||||||
|
}
|
||||||
|
$uid = explode('/', $rest)[0];
|
||||||
|
$exports = $uid !== '' ? $this->listUserExports($uid) : [];
|
||||||
|
if (!str_contains($rest, '/') && count($exports) === 1) {
|
||||||
|
return $exports[0];
|
||||||
|
}
|
||||||
|
if ($exports === []) {
|
||||||
|
throw new RepositoryException("$ref is not a recorded user export for this location, and $uid has no exports here. Expected users/<uid>/<exportId>; occ backup:user:list shows the exports");
|
||||||
|
}
|
||||||
|
$which = str_contains($rest, '/') ? "$ref is not a recorded user export for this location" : "$uid has " . count($exports) . ' exports here';
|
||||||
|
throw new RepositoryException("$which; pass one of these (newest first):\n " . implode("\n ", $exports));
|
||||||
|
}
|
||||||
|
|
||||||
/** Permanently delete a user export. Its blobs are freed by the next prune() like any other. */
|
/** Permanently delete a user export. Its blobs are freed by the next prune() like any other. */
|
||||||
public function forgetUserExport(string $manifestPath): void {
|
public function forgetUserExport(string $manifestPath): void {
|
||||||
if (!isset($this->catalog->userExports[$manifestPath])) {
|
if (!isset($this->catalog->userExports[$manifestPath])) {
|
||||||
|
|||||||
@@ -106,4 +106,51 @@ class UserExportTest extends TestCase {
|
|||||||
$this->expectExceptionMessage('missing on the location');
|
$this->expectExceptionMessage('missing on the location');
|
||||||
$repo->listUserExports();
|
$repo->listUserExports();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testResolveTakesARecordedPathAsIs(): void {
|
||||||
|
$repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'), $this->anchor);
|
||||||
|
$path = 'users/alice/20261005T120000Z-aaaa0001';
|
||||||
|
$this->putManifest($repo, $path);
|
||||||
|
|
||||||
|
$this->assertSame($path, $repo->resolveUserExport($path));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testResolveTakesAUserIdWithOneExport(): void {
|
||||||
|
// Issue #22: "backup:user:restore <location> New_User" was rejected as not a recorded export.
|
||||||
|
$repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'), $this->anchor);
|
||||||
|
$path = 'users/alice/20261005T120000Z-aaaa0001';
|
||||||
|
$this->putManifest($repo, $path);
|
||||||
|
$this->putManifest($repo, 'users/alice2/20261005T120000Z-aaaa0002');
|
||||||
|
|
||||||
|
$this->assertSame($path, $repo->resolveUserExport('alice'));
|
||||||
|
$this->assertSame($path, $repo->resolveUserExport('users/alice/'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testResolveListsTheExportsOfAUserWithMore(): void {
|
||||||
|
$repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'), $this->anchor);
|
||||||
|
$this->putManifest($repo, 'users/alice/20261005T120000Z-aaaa0001');
|
||||||
|
$this->putManifest($repo, 'users/alice/20261006T120000Z-aaaa0002');
|
||||||
|
|
||||||
|
$this->expectException(RepositoryException::class);
|
||||||
|
$this->expectExceptionMessage("alice has 2 exports here; pass one of these (newest first):\n users/alice/20261006T120000Z-aaaa0002\n users/alice/20261005T120000Z-aaaa0001");
|
||||||
|
$repo->resolveUserExport('alice');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testResolveListsTheExportsOfTheUserOfAWrongPath(): void {
|
||||||
|
$repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'), $this->anchor);
|
||||||
|
$this->putManifest($repo, 'users/alice/20261005T120000Z-aaaa0001');
|
||||||
|
|
||||||
|
$this->expectException(RepositoryException::class);
|
||||||
|
$this->expectExceptionMessage("users/alice/typo is not a recorded user export for this location; pass one of these (newest first):\n users/alice/20261005T120000Z-aaaa0001");
|
||||||
|
$repo->resolveUserExport('users/alice/typo');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testResolveNamesTheExpectedFormForAUserWithoutExports(): void {
|
||||||
|
$repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'), $this->anchor);
|
||||||
|
$this->putManifest($repo, 'users/alice/20261005T120000Z-aaaa0001');
|
||||||
|
|
||||||
|
$this->expectException(RepositoryException::class);
|
||||||
|
$this->expectExceptionMessage('bob has no exports here. Expected users/<uid>/<exportId>; occ backup:user:list shows the exports');
|
||||||
|
$repo->resolveUserExport('bob');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,8 +36,13 @@ MANIFEST=$(occ backup:user:backup ci "$U" | sed -n 's/.* to \(.*\)\.$/\1/p')
|
|||||||
[ -n "$MANIFEST" ] || fail "backup:user:backup printed no manifest path"
|
[ -n "$MANIFEST" ] || fail "backup:user:backup printed no manifest path"
|
||||||
ok "backed up $U to $MANIFEST"
|
ok "backed up $U to $MANIFEST"
|
||||||
|
|
||||||
# as-backup: restore into <uid>-bak; the original account must stay untouched.
|
occ backup:user:list ci "$U" | grep -qF "$MANIFEST" || fail "backup:user:list does not show $MANIFEST"
|
||||||
occ backup:user:restore ci "$MANIFEST" --mode=as-backup >/dev/null || fail "restore as-backup"
|
ok "backup:user:list shows the export"
|
||||||
|
|
||||||
|
# as-backup: restore into <uid>-bak; the original account must stay untouched. $U has one export,
|
||||||
|
# so the bare user id names it (#22).
|
||||||
|
OUT=$(occ backup:user:restore ci "$U" --mode=as-backup) || fail "restore as-backup by user id"
|
||||||
|
echo "$OUT" | grep -qF "Using $MANIFEST." || fail "restore by user id did not say which export it used"
|
||||||
occ files:scan "$U-bak" >/dev/null
|
occ files:scan "$U-bak" >/dev/null
|
||||||
SUM_BAK=$($X sh -c "cd $DATA/$U-bak/files && find . -type f -print0 | sort -z | xargs -0 md5sum | md5sum")
|
SUM_BAK=$($X sh -c "cd $DATA/$U-bak/files && find . -type f -print0 | sort -z | xargs -0 md5sum | md5sum")
|
||||||
[ "$SUM_BEFORE" = "$SUM_BAK" ] || fail "the <uid>-bak account's files differ from the original"
|
[ "$SUM_BEFORE" = "$SUM_BAK" ] || fail "the <uid>-bak account's files differ from the original"
|
||||||
@@ -55,6 +60,11 @@ SUM_AFTER=$($X sh -c "cd $DATA/$U/files && find . -type f -print0 | sort -z | xa
|
|||||||
[ "$SUM_BEFORE" = "$SUM_AFTER" ] || fail "files differ after a replace restore"
|
[ "$SUM_BEFORE" = "$SUM_AFTER" ] || fail "files differ after a replace restore"
|
||||||
ok "replace restores the original content under the same uid, byte-identical"
|
ok "replace restores the original content under the same uid, byte-identical"
|
||||||
|
|
||||||
|
# The replace above added a safety export: a bare user id is now ambiguous and must list both.
|
||||||
|
if OUT=$(occ backup:user:restore ci "$U" --mode=as-backup 2>&1); then fail "restore by an ambiguous user id ran"; fi
|
||||||
|
echo "$OUT" | grep -qF "$U has 2 exports here" && echo "$OUT" | grep -qF "$MANIFEST" || fail "an ambiguous user id did not list the exports"
|
||||||
|
ok "an ambiguous user id lists the exports instead of picking one"
|
||||||
|
|
||||||
# Retention: $U now has the manual export above plus the automatic safety-export the replace
|
# Retention: $U now has the manual export above plus the automatic safety-export the replace
|
||||||
# restore made of it just before deleting it (4 total after these two more), then --user-last=2
|
# restore made of it just before deleting it (4 total after these two more), then --user-last=2
|
||||||
# must forget exactly the 2 oldest.
|
# must forget exactly the 2 oldest.
|
||||||
|
|||||||
Reference in New Issue
Block a user