diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ebfa4a2..7b7fb3e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -104,3 +104,5 @@ jobs: persist-credentials: false - name: Full restore round trip run: tests/integration/full-restore-roundtrip.sh 35-apache + - name: Full restore round trip on PostgreSQL, with Mail (foreign keys) and a disabled app + run: tests/integration/full-restore-roundtrip.sh 35-apache pgsql diff --git a/PLAN.md b/PLAN.md index 4e768c4..f69b0ba 100644 --- a/PLAN.md +++ b/PLAN.md @@ -430,6 +430,9 @@ in README "Known limitations": terwijl OpenSSH `sftp` 16,6 MiB/s haalt. De SFTP-stream van files_external (phpseclib) wacht na elk klein blok op antwoord. Oplossing: pack-ranges bij SFTP met phpseclib's eigen `get()` (veel verzoeken tegelijk) lezen in plaats van via de stream, zoals bij S3. 1 TB duurt nu ~2 weken. + Na meten: de grootste oorzaak was het netwerk (cubic, zie 14): met BBR gaat de restore van 0,75 + naar 2,3 MiB/s, een hele pack lezen 4-5 MiB/s. Meer verzoeken in één stream (DEPTH 16/32) gaf geen + duidelijke winst. Verder komen vraagt meerdere verbindingen tegelijk: naar **1.1.0**. 14. **Uploaden over een lange lijn**: backup naar OVH S3 Warschau vanaf Canada (RTT 114 ms) haalt ~1 MiB/s met één pack tegelijk; `aws s3 cp` (multipart, parallel) 6 MiB/s. Meerdere packs tegelijk uploaden of S3 multipart. Minder dringend dan 13 (restore). diff --git a/lib/Command/RestoreFull.php b/lib/Command/RestoreFull.php index 524fe63..777582d 100644 --- a/lib/Command/RestoreFull.php +++ b/lib/Command/RestoreFull.php @@ -23,6 +23,7 @@ final class RestoreFull extends Command { private TargetService $targets, private RestoreFullService $restoreFull, private IUserManager $userManager, + private \OCP\App\IAppManager $appManager, ) { parent::__construct(); } @@ -33,14 +34,19 @@ final class RestoreFull extends Command { ->addArgument('target', InputArgument::REQUIRED, 'Location name or id') ->addArgument('snapshot', InputArgument::REQUIRED, 'Snapshot id (see backup:list)') ->addOption('force', null, InputOption::VALUE_NONE, 'Also on an installation that already has more than one user, and without asking') + ->addOption('install-apps', null, InputOption::VALUE_NONE, 'First install (from the app store) or enable the apps that were enabled at backup time and are missing here') + ->addOption('skip-missing-tables', null, InputOption::VALUE_NONE, 'Older backups only: leave out tables that do not exist here and whose structure the backup does not record') ->setHelp(<<<'HELP' For a FRESH Nextcloud installation only, same major version as the backup, right after occ maintenance:install, occ backup:key:import and occ backup:target:add -- before anyone else uses this instance. -Every app that was enabled when the snapshot was taken must already be installed here (its -database tables must exist before the restore can write into them); the command lists what is -missing and refuses to continue rather than guess. +Apps: --install-apps first installs (from the app store) or enables every app that was enabled +when the snapshot was taken. Apps that are not in the app store (own builds) must be copied into +custom_apps by hand; the command lists what is still missing. Tables of apps that are missing or +were removed long ago are created from the structure recorded in the backup (backups made with +1.0.0-rc.2 or later), so they do not block the restore. Older backups do not record it: the +command then lists those tables per app, and --skip-missing-tables leaves them out. Steps: the database is replaced (consistent, in one transaction); the data directory is restored directly in place; config.php is merged in -- everything from the backup except the database @@ -75,7 +81,14 @@ HELP); try { $target = $this->targets->get($input->getArgument('target')); $snapshot = (string)$input->getArgument('snapshot'); - $result = $this->restoreFull->restoreFull($target, $snapshot); + $skipMissingTables = (bool)$input->getOption('skip-missing-tables'); + if ($input->getOption('install-apps')) { + $check = $this->restoreFull->checkCompatibility($target, $snapshot, $skipMissingTables); + foreach ($check['missingApps'] as $app) { + $this->installApp($app, $output); + } + } + $result = $this->restoreFull->restoreFull($target, $snapshot, $skipMissingTables); } catch (\Throwable $e) { $output->writeln('' . $e->getMessage() . ''); $output->writeln('If this happened after the restore had started, the instance may be partly restored: fix the cause and run the same command again with --force.'); @@ -88,10 +101,43 @@ HELP); $result['configKeys'], $result['dataFiles'], )); + if ($result['dbReport']['created'] !== []) { + $output->writeln(sprintf('Created %d tables that did not exist here (apps that are missing or were removed): %s', + count($result['dbReport']['created']), implode(', ', $result['dbReport']['created']))); + } + if ($result['dbReport']['missing'] !== []) { + $output->writeln('Left out (structure unknown): ' . implode(', ', $result['dbReport']['missing']) . ''); + } + if ($result['missingApps'] !== []) { + $output->writeln('These apps were enabled on the old server but are not installed here. Their data is restored; install them so they work again:'); + foreach ($result['missingApps'] as $app) { + $output->writeln(" occ app:install $app (not in the app store? copy it into custom_apps and run occ app:enable $app)"); + } + } if (!$result['dbConsistent']) { $output->writeln('Warning: this snapshot\'s database dump was made over several steps (web cron), so it is not one consistent point in time. Check the instance carefully; prefer a snapshot made with system cron.'); } $output->writeln('Next: check trusted_domains and overwritehost/overwriteprotocol match this server, then occ maintenance:repair.'); return 0; } + + /** Enable an app that is here but switched off, or install it from the app store. */ + private function installApp(string $app, OutputInterface $output): void { + try { + $this->appManager->getAppPath($app); + $command = 'app:enable'; // here, but switched off + } catch (\OCP\App\AppPathNotFoundException) { + $command = 'app:install'; + } + $output->writeln("Running occ $command $app ..."); + try { + $code = $this->getApplication()?->find($command)->run(new \Symfony\Component\Console\Input\ArrayInput(['app-id' => $command === 'app:enable' ? [$app] : $app]), $output); + } catch (\Throwable $e) { + $code = 1; + $output->writeln('' . $e->getMessage() . ''); + } + if ($code !== 0) { + $output->writeln("Could not $command $app (not in the app store?). Copy it into custom_apps and enable it, or restore without it."); + } + } } diff --git a/lib/Command/TargetAdd.php b/lib/Command/TargetAdd.php index 2dc9337..d91368c 100644 --- a/lib/Command/TargetAdd.php +++ b/lib/Command/TargetAdd.php @@ -44,7 +44,10 @@ HELP); protected function execute(InputInterface $input, OutputInterface $output): int { if (!$this->keys->isInitialized()) { - $output->writeln('No backup key yet: run occ backup:key:init first.'); + $output->writeln('No backup key yet.'); + $output->writeln('Restoring from a location that already has backups (disaster recovery)? Import the key from the recovery kit first:'); + $output->writeln(' occ backup:key:import /path/to/ng_backup-kit.json'); + $output->writeln('Only for a new location without backups: create a new key with occ backup:key:init. A new key cannot read existing backups.'); return 1; } $options = []; @@ -60,6 +63,9 @@ HELP); $output->writeln('--auth is required (see occ backup:target:backends)'); return 1; } + // Adopting an existing repository reads its whole index first, which takes a while on a + // large backup or a slow line; say so instead of looking stuck. + $output->writeln('Connecting to the location; with existing backups this reads their index first and can take a while ...'); try { $r = $this->targets->add($input->getArgument('name'), $input->getArgument('backend'), $input->getOption('auth'), $options, (string)$input->getOption('path')); } catch (\Throwable $e) { diff --git a/lib/Db/DbDumper.php b/lib/Db/DbDumper.php index 1010cdd..0f27e32 100644 --- a/lib/Db/DbDumper.php +++ b/lib/Db/DbDumper.php @@ -23,8 +23,12 @@ use OCA\NgBackup\Repository\Repository; * sets, so a single big SELECT would not stream). * - Each table becomes its own blob stream of JSON lines: a header line, then one JSON array per * row. A table that did not change produces the same blobs and costs nothing in the next run. - * - The schema itself is not dumped: it comes from Nextcloud's migrations on the instance that is - * restored into (same Nextcloud and app versions). The dump records those versions. + * - The structure of each table (columns, types, primary key, indexes) is recorded in the manifest, + * so a restore can create a table that does not exist on the new instance, for example one left + * behind by an app that was removed long ago. Tables that exist keep the structure the new + * instance's migrations gave them. + * - Foreign keys are not checked while rows are loaded (tables are restored in name order, not in + * dependency order), and are checked again before the restore commits. * * Phase 0 uses the Doctrine connection behind IDBConnection (private API) for schema * introspection; to be replaced by information_schema queries or a public API. @@ -108,7 +112,7 @@ final class DbDumper { break; } $state['result'][$table] = ['blobs' => array_merge(...$state['segments']), 'rows' => $state['rows'], - 'bytes' => $state['bytes'], 'sha256' => hash_final($hash)]; + 'bytes' => $state['bytes'], 'sha256' => hash_final($hash), 'schema' => self::structure($schema)]; $state = array_merge($state, ['i' => $state['i'] + 1, 'after' => null, 'segments' => [], 'rows' => 0, 'bytes' => 0, 'hash' => null]); if (microtime(true) >= $deadline && $state['i'] < count($state['tables'])) { break; @@ -137,18 +141,33 @@ final class DbDumper { * Replace the contents of every table in the dump. Tables that exist but are not in the dump * are left alone (e.g. a newer app); the caller decides whether that is acceptable. * - * @param array{tables: array, rows: int}>} $manifest + * @param array{tables: array, rows: int, sha256: string, schema?: array}>, sequences?: array} $manifest */ - public function restore(Repository $repo, array $manifest, ?array &$report = null, ?callable $heartbeat = null): int { - $sm = $this->db->createSchemaManager(); - $existing = array_flip($sm->listTableNames()); + public function restore(Repository $repo, array $manifest, ?array &$report = null, ?callable $heartbeat = null, bool $skipMissing = false): int { + $report = ['skipped' => [], 'restored' => [], 'created' => [], 'missing' => []]; + $missing = $this->missingTables($manifest); + $unknown = array_values(array_filter($missing, fn (string $t) => !isset($manifest['tables'][$t]['schema']))); + if ($unknown !== [] && !$skipMissing) { + throw new \RuntimeException(self::describeMissing($unknown, $this->prefix)); + } + // Tables the backup knows the structure of are created first (DDL is not transactional + // everywhere); tables it does not know are left out, as asked. + foreach ($missing as $table) { + if (isset($manifest['tables'][$table]['schema'])) { + $this->db->createSchemaManager()->createTable(self::tableFromStructure($table, $manifest['tables'][$table]['schema'])); + $report['created'][] = $table; + } else { + $report['missing'][] = $table; + } + } $total = 0; - $report = ['skipped' => [], 'restored' => []]; + $foreignKeys = []; $this->db->beginTransaction(); try { + $foreignKeys = $this->suspendForeignKeys(); foreach ($manifest['tables'] as $table => $info) { - if (!isset($existing[$table])) { - throw new \RuntimeException("Table $table from the backup does not exist here (different app versions?)"); + if (in_array($table, $report['missing'], true)) { + continue; } // Unchanged since the backup: leave it alone (much faster than delete + insert). $live = $this->checksum($table); @@ -193,14 +212,130 @@ final class DbDumper { $report['restored'][] = $table; } $this->restoreSequences($manifest['sequences'] ?? []); + $this->resumeForeignKeys($foreignKeys); $this->db->commit(); } catch (\Throwable $e) { $this->db->rollBack(); + // PostgreSQL rolls the dropped foreign keys back with the transaction; MySQL's session + // switch must be turned on again by hand. + if (in_array('SET FOREIGN_KEY_CHECKS = 1', $foreignKeys, true)) { + $this->db->executeStatement('SET FOREIGN_KEY_CHECKS = 1'); + } throw $e; } return $total; } + /** + * Tables in the dump that do not exist on this instance. + * + * @return list + */ + public function missingTables(array $manifest): array { + $existing = array_flip($this->db->createSchemaManager()->listTableNames()); + return array_values(array_filter(array_keys($manifest['tables']), fn (string $t) => !isset($existing[$t]))); + } + + /** One message for all missing tables of an older backup, grouped by the app they probably belong to. */ + public static function describeMissing(array $tables, string $prefix): string { + $groups = []; + foreach ($tables as $t) { + $name = substr($t, strlen($prefix)); + $groups[explode('_', $name, 2)[0]][] = $t; + } + $lines = []; + foreach ($groups as $app => $list) { + $lines[] = '- ' . $app . ': ' . implode(', ', $list); + } + return "These tables from the backup do not exist here, and this backup does not record their structure (made before 1.0.0-rc.2):\n" + . implode("\n", $lines) + . "\nInstall the apps they belong to (often apps that were removed or switched off long ago) and run the restore again, or leave these tables out with --skip-missing-tables."; + } + + /** + * Portable description of a table: enough to create it again, also on another database type. + * + * @return array{columns: list>, pk: list, indexes: list, unique: bool}>} + */ + private static function structure(\Doctrine\DBAL\Schema\Table $table): array { + $columns = []; + foreach ($table->getColumns() as $c) { + $columns[] = ['name' => $c->getName(), 'type' => \Doctrine\DBAL\Types\Type::lookupName($c->getType()), + 'length' => $c->getLength(), 'precision' => $c->getPrecision(), 'scale' => $c->getScale(), + 'unsigned' => $c->getUnsigned(), 'fixed' => $c->getFixed(), 'notnull' => $c->getNotnull(), + 'default' => $c->getDefault(), 'autoincrement' => $c->getAutoincrement()]; + } + $indexes = []; + foreach ($table->getIndexes() as $i) { + if (!$i->isPrimary()) { + $indexes[] = ['name' => $i->getName(), 'columns' => array_values($i->getColumns()), 'unique' => $i->isUnique()]; + } + } + return ['columns' => $columns, 'pk' => array_values($table->getPrimaryKey()?->getColumns() ?? []), 'indexes' => $indexes]; + } + + private static function tableFromStructure(string $name, array $structure): \Doctrine\DBAL\Schema\Table { + $table = new \Doctrine\DBAL\Schema\Table($name); + foreach ($structure['columns'] as $c) { + $table->addColumn($c['name'], $c['type'], array_filter([ + 'length' => $c['length'], 'precision' => $c['precision'], 'scale' => $c['scale'], + 'unsigned' => $c['unsigned'], 'fixed' => $c['fixed'], 'notnull' => $c['notnull'], + 'default' => $c['default'], 'autoincrement' => $c['autoincrement'], + ], fn ($v) => $v !== null)); + } + if ($structure['pk'] !== []) { + $table->setPrimaryKey($structure['pk']); + } + foreach ($structure['indexes'] as $i) { + $i['unique'] ? $table->addUniqueIndex($i['columns'], $i['name']) : $table->addIndex($i['columns'], $i['name']); + } + return $table; + } + + /** + * Stop checking foreign keys while rows are loaded in name order. PostgreSQL has no session + * switch for this without superuser rights, so its foreign keys of Nextcloud tables are dropped + * here and added back (and so checked) by resumeForeignKeys(), inside the same transaction. + * + * @return list statements that restore the foreign keys + */ + private function suspendForeignKeys(): array { + $platform = $this->db->getDatabasePlatform(); + if ($platform instanceof \Doctrine\DBAL\Platforms\PostgreSQLPlatform) { + $rows = $this->db->fetchAllAssociative( + "SELECT conrelid::regclass::text AS tbl, conname, pg_get_constraintdef(oid) AS def + FROM pg_constraint WHERE contype = 'f' AND connamespace = current_schema()::regnamespace"); + $restore = []; + foreach ($rows as $r) { + if (!str_starts_with(trim($r['tbl'], '"'), $this->prefix)) { + continue; + } + $this->db->executeStatement(sprintf('ALTER TABLE %s DROP CONSTRAINT %s', $r['tbl'], $this->db->quoteIdentifier($r['conname']))); + $restore[] = sprintf('ALTER TABLE %s ADD CONSTRAINT %s %s', $r['tbl'], $this->db->quoteIdentifier($r['conname']), $r['def']); + } + return $restore; + } + if ($platform instanceof \Doctrine\DBAL\Platforms\AbstractMySQLPlatform) { + $this->db->executeStatement('SET FOREIGN_KEY_CHECKS = 0'); + return ['SET FOREIGN_KEY_CHECKS = 1']; + } + if ($platform instanceof \Doctrine\DBAL\Platforms\SqlitePlatform) { + $this->db->executeStatement('PRAGMA defer_foreign_keys = ON'); // checked at commit + } + return []; + } + + /** Add the foreign keys back; a row that breaks one fails the restore here, before it commits. */ + private function resumeForeignKeys(array $statements): void { + foreach ($statements as $sql) { + try { + $this->db->executeStatement($sql); + } catch (\Throwable $e) { + throw new \RuntimeException('The restored data breaks a foreign key (' . $sql . '): ' . $e->getMessage(), 0, $e); + } + } + } + /** Same checksum as dump(): sha256 over the row lines in primary-key order. */ public function checksum(string $table): array { $hash = hash_init('sha256'); diff --git a/lib/Db/DbIndexCache.php b/lib/Db/DbIndexCache.php index e70ccbb..d7411e9 100644 --- a/lib/Db/DbIndexCache.php +++ b/lib/Db/DbIndexCache.php @@ -64,6 +64,13 @@ final class DbIndexCache implements IIndexCache { } } + /** Drop the cached index of a repository that no location uses any more. */ + public function forget(string $repositoryId): void { + $qb = $this->db->getQueryBuilder(); + $qb->delete('ngb_index')->where($qb->expr()->eq('repository_id', $qb->createNamedParameter($repositoryId))); + $qb->executeStatement(); + } + /** All or nothing: when it fails, the cache stays as it was. */ public function replaceAll(string $repositoryId, array $packs): void { $this->transaction(function () use ($repositoryId, $packs): void { diff --git a/lib/Migration/Version000006Date20261018000000.php b/lib/Migration/Version000006Date20261018000000.php new file mode 100644 index 0000000..8e90047 --- /dev/null +++ b/lib/Migration/Version000006Date20261018000000.php @@ -0,0 +1,39 @@ +db->getQueryBuilder(); + $used->select('repository_id')->from('ngb_targets')->where($used->expr()->isNotNull('repository_id')); + $keep = array_column($used->executeQuery()->fetchAll(), 'repository_id'); + $delete = $this->db->getQueryBuilder(); + $delete->delete('ngb_index'); + if ($keep !== []) { + $delete->where($delete->expr()->notIn('repository_id', $delete->createNamedParameter($keep, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_STR_ARRAY))); + } + $removed = $delete->executeStatement(); + if ($removed > 0) { + $output->info("Removed $removed cached index rows of locations that no longer exist"); + } + } +} diff --git a/lib/Service/RestoreFullService.php b/lib/Service/RestoreFullService.php index 1d429ed..467f595 100644 --- a/lib/Service/RestoreFullService.php +++ b/lib/Service/RestoreFullService.php @@ -43,34 +43,49 @@ final class RestoreFullService { ) { } + /** Apps that only exist in a particular setup and are never needed elsewhere. */ + private const ENVIRONMENT_APPS = ['nextcloud-aio']; + /** - * Whether this installation can receive $snapshotId: same Nextcloud major version, and every - * app that was enabled at backup time already installed here (its tables must exist before - * the database restore can write into them). + * Whether this installation can receive $snapshotId, all problems at once: + * - the same Nextcloud major version is required; + * - apps that were enabled at backup time but are not here (they work again once installed); + * - tables in the backup that do not exist here: a backup made from 1.0.0-rc.2 on records + * their structure and the restore creates them, an older one cannot (missingTables). * - * @return array{ok:bool, currentMajor:string, snapshotMajor:string, missingApps:list} + * @return array{ok:bool, currentMajor:string, snapshotMajor:string, missingApps:list, missingTables:list} */ - public function checkCompatibility(Target $target, string $snapshotId): array { - $meta = $this->targets->repository($target)->snapshotMeta($snapshotId); + public function checkCompatibility(Target $target, string $snapshotId, bool $skipMissingTables = false): array { + $repo = $this->targets->repository($target); + $meta = $repo->snapshotMeta($snapshotId); $currentMajor = explode('.', $this->config->getSystemValueString('version'))[0]; $snapshotMajor = explode('.', (string)($meta['nextcloud'] ?? ''))[0]; $enabled = array_flip($this->appManager->getEnabledApps()); - $missingApps = array_values(array_diff(array_keys($meta['apps'] ?? []), array_keys($enabled))); + $missingApps = array_values(array_diff(array_keys($meta['apps'] ?? []), array_keys($enabled), self::ENVIRONMENT_APPS)); + $missingTables = []; + if (isset($meta['db'])) { + $manifest = json_decode($repo->getObject($meta['db']), true, 512, JSON_THROW_ON_ERROR); + $prefix = $this->config->getSystemValueString('dbtableprefix', 'oc_'); + $dumper = new DbDumper($this->db->getInner(), $prefix); + $missingTables = array_values(array_filter($dumper->missingTables($manifest), + fn (string $t) => !isset($manifest['tables'][$t]['schema']) && !str_starts_with($t, $prefix . 'ngb_'))); + } return [ - 'ok' => $currentMajor === $snapshotMajor && $missingApps === [], + 'ok' => $currentMajor === $snapshotMajor && ($missingTables === [] || $skipMissingTables), 'currentMajor' => $currentMajor, 'snapshotMajor' => $snapshotMajor, 'missingApps' => $missingApps, + 'missingTables' => $missingTables, ]; } - /** @return array{dbReport:array, dataFiles:int, configKeys:int, dbConsistent:bool} */ - public function restoreFull(Target $target, string $snapshotId): array { - $check = $this->checkCompatibility($target, $snapshotId); + /** @return array{dbReport:array, dataFiles:int, configKeys:int, dbConsistent:bool, missingApps:list} */ + public function restoreFull(Target $target, string $snapshotId, bool $skipMissingTables = false): array { + $check = $this->checkCompatibility($target, $snapshotId, $skipMissingTables); if (!$check['ok']) { throw new \RuntimeException($this->describeBlockers($check)); } - return $this->withSharedLock($target, function (callable $refresh) use ($target, $snapshotId) { + return $this->withSharedLock($target, function (callable $refresh) use ($target, $snapshotId, $skipMissingTables, $check) { $beat = LeaseService::throttled($refresh); $repo = $this->targets->repository($target); $meta = $repo->snapshotMeta($snapshotId); @@ -102,7 +117,7 @@ final class RestoreFullService { $ownAppConfig = $this->preserveOwnAppConfig(); $dumper = new DbDumper($this->db->getInner(), $prefix); try { - $dumper->restore($repo, $manifest, $dbReport, $beat); + $dumper->restore($repo, $manifest, $dbReport, $beat, $skipMissingTables); } finally { $this->restoreOwnAppConfig($ownAppConfig); } @@ -125,8 +140,8 @@ final class RestoreFullService { ); } - return ['dbReport' => $dbReport ?? ['skipped' => [], 'restored' => []], 'dataFiles' => $dataFiles, 'configKeys' => count($backupConfig), - 'dbConsistent' => (bool)($manifest['consistent'] ?? true)]; + return ['dbReport' => $dbReport ?? ['skipped' => [], 'restored' => [], 'created' => [], 'missing' => []], 'dataFiles' => $dataFiles, 'configKeys' => count($backupConfig), + 'dbConsistent' => (bool)($manifest['consistent'] ?? true), 'missingApps' => $check['missingApps']]; }); } @@ -209,11 +224,11 @@ final class RestoreFullService { if ($check['currentMajor'] !== $check['snapshotMajor']) { $parts[] = "this installation is Nextcloud {$check['currentMajor']}, the backup is from {$check['snapshotMajor']} (same major version required)"; } - if ($check['missingApps'] !== []) { - $parts[] = 'install these apps first (their tables must exist before the database restore can use them): ' - . implode(', ', array_map(fn (string $a) => "occ app:install $a", $check['missingApps'])); + if ($check['missingTables'] !== []) { + $prefix = $this->config->getSystemValueString('dbtableprefix', 'oc_'); + $parts[] = DbDumper::describeMissing($check['missingTables'], $prefix); } - return implode('; ', $parts); + return implode("\n", $parts); } private function withSharedLock(Target $target, callable $fn): mixed { diff --git a/lib/Service/TargetService.php b/lib/Service/TargetService.php index b581301..80d602e 100644 --- a/lib/Service/TargetService.php +++ b/lib/Service/TargetService.php @@ -259,6 +259,11 @@ final class TargetService { /** Remove the target from NG Backup. The backups on the target itself are not touched. */ public function remove(Target $target): void { $this->mapper->delete($target); + // The cached index of its repository is only of use while a location points at it + $repositoryId = $target->getRepositoryId(); + if ($repositoryId !== null && !array_filter($this->list(), fn (Target $t) => $t->getRepositoryId() === $repositoryId)) { + $this->indexCache->forget($repositoryId); + } } public function backend(Target $target): IBackend { diff --git a/psalm-baseline.xml b/psalm-baseline.xml index b243ec9..546574d 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -153,10 +153,6 @@ - - - - $end[array_search($c, $columns, true)]]]> $q($c)]]> @@ -189,6 +185,10 @@ db]]> db]]> db]]> + db]]> + db]]> + db]]> + db]]> @@ -196,6 +196,10 @@ + + + + @@ -438,6 +442,7 @@ + diff --git a/tests/integration/full-restore-roundtrip.sh b/tests/integration/full-restore-roundtrip.sh index 9e98449..c29f6d0 100755 --- a/tests/integration/full-restore-roundtrip.sh +++ b/tests/integration/full-restore-roundtrip.sh @@ -2,10 +2,14 @@ # Disaster recovery: occ backup:key:import + backup:target:add + backup:restore:full on a # SECOND, fresh Nextcloud instance that never saw the first one, sharing only the backup # location (a local directory, bind-mounted into both containers) -- see PLAN.md 3.7. -# Usage: full-restore-roundtrip.sh -# e.g. full-restore-roundtrip.sh 35-apache +# Usage: full-restore-roundtrip.sh [sqlite|pgsql] +# e.g. full-restore-roundtrip.sh 35-apache pgsql +# With pgsql the origin also has what broke real disaster recoveries: the Mail app with a synced +# account (foreign keys between its tables) and Deck installed and then switched off (tables of +# an app that is not enabled). The restore instance has neither; --install-apps adds Mail. set -euo pipefail IMAGE="nextcloud:${1:-35-apache}" +DB="${2:-sqlite}" APP=$(cd "$(dirname "$0")/../.." && pwd) NET=ngb-frt-net SHARED=$(mktemp -d) @@ -14,7 +18,7 @@ fail() { echo "FAIL: $*"; exit 1; } ok() { echo "ok $*"; } cleanup() { - docker rm -fv ngb-frt-origin ngb-frt-restore >/dev/null 2>&1 || true + docker rm -fv ngb-frt-origin ngb-frt-restore ngb-frt-db-origin ngb-frt-db-restore ngb-frt-imap >/dev/null 2>&1 || true docker network rm "$NET" >/dev/null 2>&1 || true docker run --rm -v "$SHARED:/target" alpine sh -c 'rm -rf /target/*' >/dev/null 2>&1 || true rmdir "$SHARED" 2>/dev/null || true @@ -24,14 +28,28 @@ trap cleanup EXIT run_on() { docker exec -u www-data "$1" php occ "${@:2}"; } +# Database settings for an instance: SQLite inside the container, or its own PostgreSQL container +db_env() { + if [ "$DB" = pgsql ]; then + docker run -d --name "ngb-frt-db-$1" --network "$NET" -e POSTGRES_DB=nextcloud -e POSTGRES_USER=nextcloud \ + -e POSTGRES_PASSWORD=nextcloud postgres:16-alpine >/dev/null + for i in $(seq 1 30); do docker exec "ngb-frt-db-$1" pg_isready -q 2>/dev/null && break; sleep 2; done + echo "-e POSTGRES_HOST=ngb-frt-db-$1 -e POSTGRES_DB=nextcloud -e POSTGRES_USER=nextcloud -e POSTGRES_PASSWORD=nextcloud" + else + echo "-e SQLITE_DATABASE=nextcloud" + fi +} + docker network create "$NET" >/dev/null -docker run -d --name ngb-frt-origin --network "$NET" -e SQLITE_DATABASE=nextcloud \ +ORIGIN_DB=$(db_env origin) +# shellcheck disable=SC2086 +docker run -d --name ngb-frt-origin --network "$NET" $ORIGIN_DB \ -e NEXTCLOUD_ADMIN_USER=admin -e NEXTCLOUD_ADMIN_PASSWORD=admin \ -v "$APP:/var/www/html/custom_apps/ng_backup:ro" -v "$SHARED:/mnt/shared" "$IMAGE" >/dev/null for i in $(seq 1 90); do run_on ngb-frt-origin status 2>/dev/null | grep -q "installed: true" && break; sleep 3; done docker exec ngb-frt-origin chown www-data:www-data /var/www/html/custom_apps run_on ngb-frt-origin app:enable ng_backup >/dev/null -ok "origin instance up" +ok "origin instance up ($DB)" docker exec ngb-frt-origin sh -c "printf 'full restore roundtrip passphrase 2026\n' > /tmp/pass" run_on ngb-frt-origin backup:key:init --passphrase-file=/tmp/pass --label=CI >/dev/null || fail "key init" @@ -45,6 +63,26 @@ NC_PASS=$(openssl rand -hex 16) docker exec -u www-data -e OC_PASS="$NC_PASS" ngb-frt-origin php occ user:add --password-from-env frtest >/dev/null docker exec ngb-frt-origin sh -c "mkdir -p /var/www/html/data/frtest/files/Docs && head -c 200000 /dev/urandom > /var/www/html/data/frtest/files/Docs/report.bin && echo 'hello disaster recovery' > /var/www/html/data/frtest/files/Docs/note.txt && chown -R www-data:www-data /var/www/html/data/frtest" run_on ngb-frt-origin files:scan frtest >/dev/null + +if [ "$DB" = pgsql ]; then + # Mail with a synced account: oc_mail_accounts points at oc_mail_mailboxes (foreign keys), + # while the restore loads tables in name order, accounts before mailboxes. + docker run -d --name ngb-frt-imap --network "$NET" -e MAILNAME=mail.domain.tld -e MAIL_ADDRESS=user@domain.tld \ + -e MAIL_PASS=mypassword ghcr.io/christophwurst/docker-imap-devel:latest >/dev/null + run_on ngb-frt-origin app:install mail >/dev/null || fail "install mail on the origin" + run_on ngb-frt-origin config:system:set app.mail.verify-tls-peer --type=boolean --value=false >/dev/null + sleep 10 + run_on ngb-frt-origin mail:account:create frtest Test user@domain.tld ngb-frt-imap 993 ssl user@domain.tld mypassword \ + ngb-frt-imap 25 tls user@domain.tld mypassword >/dev/null || fail "mail account" + run_on ngb-frt-origin mail:account:sync -f 1 >/dev/null || fail "mail sync" + [ "$(docker exec ngb-frt-db-origin psql -U nextcloud -d nextcloud -Atc 'select count(*) from oc_mail_accounts where drafts_mailbox_id is not null')" = 1 ] \ + || fail "the mail account does not point at its mailboxes" + # Deck installed and then switched off: its tables stay in the database and in the backup + run_on ngb-frt-origin app:install deck >/dev/null || fail "install deck on the origin" + run_on ngb-frt-origin app:disable deck >/dev/null + ok "origin has a synced Mail account and the tables of a disabled app" +fi + RUN_OUT=$(run_on ngb-frt-origin backup:run offsite -l disaster-test) SNAP=$(printf '%s\n' "$RUN_OUT" | head -1 | awk -F'snapshot ' '{print $2}' | tr -d ',') [ -n "$SNAP" ] || fail "no snapshot id from backup:run" @@ -52,7 +90,9 @@ ok "test data backed up, snapshot $SNAP" docker cp ngb-frt-origin:/tmp/kit.json /tmp/ngb-frt-kit.json docker exec ngb-frt-origin sh -c "cat /tmp/pass" > /tmp/ngb-frt-pass -docker run -d --name ngb-frt-restore --network "$NET" -e SQLITE_DATABASE=nextcloud \ +RESTORE_DB=$(db_env restore) +# shellcheck disable=SC2086 +docker run -d --name ngb-frt-restore --network "$NET" $RESTORE_DB \ -e NEXTCLOUD_ADMIN_USER=admin -e NEXTCLOUD_ADMIN_PASSWORD=admin \ -v "$APP:/var/www/html/custom_apps/ng_backup:ro" -v "$SHARED:/mnt/shared" "$IMAGE" >/dev/null for i in $(seq 1 90); do run_on ngb-frt-restore status 2>/dev/null | grep -q "installed: true" && break; sleep 3; done @@ -68,7 +108,7 @@ run_on ngb-frt-restore backup:target:add offsite local -a null::null -o datadir= run_on ngb-frt-restore backup:list offsite | grep -q "$SNAP" || fail "the origin's snapshot is not listed after connecting the location" ok "recovery kit imported, existing repository adopted, snapshot visible" -OUT=$(run_on ngb-frt-restore backup:restore:full offsite "$SNAP" --no-interaction 2>&1) || { +OUT=$(run_on ngb-frt-restore backup:restore:full offsite "$SNAP" --no-interaction --install-apps 2>&1) || { printf '%s\n' "$OUT" | tail -40 docker exec ngb-frt-restore sh -c 'tail -c 6000 /var/www/html/data/nextcloud.log' || true fail "restore:full" @@ -84,7 +124,17 @@ docker exec ngb-frt-restore test -f /var/www/html/data/frtest/files/Docs/note.tx run_on ngb-frt-restore files:scan frtest >/dev/null || fail "files:scan fails after restore" ok "database, files and ng_backup's own state are all intact and usable after the restore" +if [ "$DB" = pgsql ]; then + Q() { docker exec ngb-frt-db-restore psql -U nextcloud -d nextcloud -Atc "$1"; } + [ "$(Q "select count(*) from pg_tables where tablename = 'oc_deck_boards'")" = 1 ] || fail "the tables of the disabled app were not created" + [ "$(Q "select count(*) from oc_mail_accounts where drafts_mailbox_id is not null")" = 1 ] || fail "the mail account was not restored" + [ "$(Q "select count(*) from pg_constraint where contype = 'f' and conrelid::regclass::text like 'oc_mail_%'")" -gt 0 ] \ + || fail "the foreign keys of Mail are missing after the restore" + printf '%s\n' "$OUT" | grep -q "Created .* tables" || fail "the restore did not report the tables it created" + ok "tables of a disabled app created, Mail restored with its foreign keys" +fi + run_on ngb-frt-restore backup:run offsite -l post-restore-check >/dev/null || fail "a new backup after the restore fails" ok "the restored instance can run a new backup of its own" -echo "FULL RESTORE ROUNDTRIP PASS" +echo "FULL RESTORE ROUNDTRIP PASS ($DB)"