b625fe7666
The DR test of 1.0.2 over SMB is done: result in PLAN, empty folders as a known issue (#27).
185 lines
11 KiB
PHP
185 lines
11 KiB
PHP
<?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\RestoreFullService;
|
|
use OCA\NgBackup\Service\TargetService;
|
|
use OCP\IUserManager;
|
|
use Symfony\Component\Console\Command\Command;
|
|
use Symfony\Component\Console\Helper\QuestionHelper;
|
|
use Symfony\Component\Console\Input\InputOption;
|
|
use Symfony\Component\Console\Question\ConfirmationQuestion;
|
|
use Symfony\Component\Console\Input\InputArgument;
|
|
use Symfony\Component\Console\Input\InputInterface;
|
|
use Symfony\Component\Console\Output\OutputInterface;
|
|
|
|
final class RestoreFull extends Command {
|
|
public function __construct(
|
|
private TargetService $targets,
|
|
private RestoreFullService $restoreFull,
|
|
private IUserManager $userManager,
|
|
private \OCP\App\IAppManager $appManager,
|
|
) {
|
|
parent::__construct();
|
|
}
|
|
|
|
protected function configure(): void {
|
|
$this->setName('backup:restore:full')
|
|
->setDescription('Disaster recovery: restore the database, data directory and config on a fresh installation')
|
|
->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')
|
|
->addOption('salvage', null, InputOption::VALUE_NONE, 'Restore what can be restored: leave out files that cannot be read, and when the database cannot be restored, put the files in a folder of their own')
|
|
->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.
|
|
|
|
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
|
|
connection and data directory settings, which stay as occ maintenance:install configured them
|
|
here. Other files under config/ (if any) are not restored.
|
|
|
|
Afterwards: check trusted_domains and overwritehost/overwriteprotocol match this server, and
|
|
occ maintenance:repair.
|
|
When the backup is damaged: --salvage restores what can be restored. Files whose data cannot be
|
|
read or decrypted are left out and listed. When the database cannot be restored (its dump is
|
|
damaged, or it does not fit this installation), the database stays as occ maintenance:install
|
|
made it, and the data directory and config of the backup go to ngb-salvage-<snapshot> in the
|
|
data directory instead: from there, the files can be put back by hand. The command then exits
|
|
with code 2.
|
|
|
|
Example:
|
|
occ backup:restore:full offsite 537bf7e9c1a4...
|
|
HELP);
|
|
}
|
|
|
|
protected function execute(InputInterface $input, OutputInterface $output): int {
|
|
$force = (bool)$input->getOption('force');
|
|
// A whole-server restore replaces the database and data directory of THIS instance. On
|
|
// anything but a fresh install (only the admin from occ maintenance:install) that is
|
|
// almost certainly a mistake, so it needs --force.
|
|
$users = $this->userManager->countUsersTotal(2);
|
|
if (!$force && ($users === false || $users > 1)) {
|
|
$output->writeln('<error>This installation already has more than one user: backup:restore:full would replace its whole database and data directory. It is meant for a fresh installation. Use --force if this really is what you want.</error>');
|
|
return 1;
|
|
}
|
|
if (!$force && $input->isInteractive()) {
|
|
$q = new ConfirmationQuestion('This replaces the database, the data directory and config.php of this installation. Continue? [y/N] ', false);
|
|
if (!(new QuestionHelper())->ask($input, $output, $q)) {
|
|
$output->writeln('Cancelled.');
|
|
return 1;
|
|
}
|
|
}
|
|
try {
|
|
$target = $this->targets->get($input->getArgument('target'));
|
|
$snapshot = (string)$input->getArgument('snapshot');
|
|
$skipMissingTables = (bool)$input->getOption('skip-missing-tables');
|
|
$salvage = (bool)$input->getOption('salvage');
|
|
if ($input->getOption('install-apps')) {
|
|
$check = $this->restoreFull->checkCompatibility($target, $snapshot, $skipMissingTables, $salvage);
|
|
foreach ($check['missingApps'] as $app) {
|
|
$this->installApp($app, $output);
|
|
}
|
|
}
|
|
$output->writeln('Nextcloud is in maintenance mode while the restore runs; it is switched off when the restore completes.');
|
|
$result = $this->restoreFull->restoreFull($target, $snapshot, $skipMissingTables, $salvage);
|
|
} catch (\Throwable $e) {
|
|
$output->writeln('<error>' . $e->getMessage() . '</error>');
|
|
if (\OCP\Server::get(\OCP\IConfig::class)->getSystemValueBool('maintenance', false)) {
|
|
$output->writeln('The restore had started: the instance may be partly restored, and maintenance mode stays on so that nobody uses it like that.');
|
|
$output->writeln('Fix the cause, then switch maintenance mode off and run the same command again with --force, in one line:');
|
|
$again = 'occ backup:restore:full ' . escapeshellarg((string)$input->getArgument('target')) . ' ' . escapeshellarg((string)$input->getArgument('snapshot'))
|
|
. ($input->getOption('skip-missing-tables') ? ' --skip-missing-tables' : '') . ($input->getOption('salvage') ? ' --salvage' : '') . ' --force --no-interaction';
|
|
$output->writeln(' occ maintenance:mode --off && ' . $again);
|
|
$output->writeln('(Without --install-apps, so the instance is open only for the seconds until the restore starts again. Apps the first run could not install are listed at the end: install those afterwards.)');
|
|
} else {
|
|
$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.');
|
|
}
|
|
$output->writeln('If occ then says that Nextcloud needs an upgrade (the database is already the old server\'s), run occ upgrade first.');
|
|
if (!$input->getOption('salvage') && Restore::backupLooksDamaged($e)) {
|
|
$output->writeln('The backup itself looks damaged here. --salvage restores everything that can be read and lists the rest.');
|
|
}
|
|
return 1;
|
|
}
|
|
if ($result['dbError'] !== null) {
|
|
$output->writeln('<error>Salvage: ' . $result['dbError'] . '</error>');
|
|
$output->writeln(sprintf('The database stays as occ maintenance:install made it. The data directory and config of the backup (<info>%d</info> data files) are in:', $result['dataFiles']));
|
|
$output->writeln(' ' . $result['salvageDir']);
|
|
$output->writeln('Each user\'s files are in data/<user>/files there. Put back what is needed by hand, for example into the files of new users (then occ files:scan).');
|
|
Restore::reportSkipped($result['skipped'], $output);
|
|
return 2;
|
|
}
|
|
$output->writeln(sprintf(
|
|
'Database restored (<info>%d</info> tables, <info>%d</info> unchanged), config merged (<info>%d</info> keys), <info>%d</info> data files restored.',
|
|
count($result['dbReport']['restored']),
|
|
count($result['dbReport']['skipped']),
|
|
$result['configKeys'],
|
|
$result['dataFiles'],
|
|
));
|
|
if ($result['dbReport']['created'] !== []) {
|
|
$output->writeln(sprintf('Created <info>%d</info> tables that did not exist here (apps that are missing or were removed): %s',
|
|
count($result['dbReport']['created']), implode(', ', $result['dbReport']['created'])));
|
|
}
|
|
foreach ($result['dbReport']['foreignKeyErrors'] ?? [] as $error) {
|
|
$output->writeln('<comment>Could not add a foreign key to a created table (the data is restored): ' . $error . '</comment>');
|
|
}
|
|
if ($result['dbReport']['missing'] !== []) {
|
|
$output->writeln('<comment>Left out (structure unknown): ' . implode(', ', $result['dbReport']['missing']) . '</comment>');
|
|
}
|
|
if ($result['missingApps'] !== []) {
|
|
$output->writeln('<comment>These apps were enabled on the old server but are not installed here. Their data is restored; install them so they work again:</comment>');
|
|
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('<comment>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.</comment>');
|
|
}
|
|
if ($result['maintenanceLeftOn']) {
|
|
$output->writeln('<comment>Maintenance mode was on before the restore and stays on: switch it off with occ maintenance:mode --off when the instance is ready.</comment>');
|
|
}
|
|
$output->writeln('Next: check trusted_domains and overwritehost/overwriteprotocol match this server, then occ maintenance:repair.');
|
|
$code = 0;
|
|
if ($result['configError'] !== null) {
|
|
$output->writeln('<comment>Salvage: config.php of the backup was not merged (' . $result['configError'] . '). Set at least \'instanceid\', \'passwordsalt\' and \'secret\' to the old server\'s values if you have them, or data encrypted with them (server-side encryption, stored passwords) cannot be read.</comment>');
|
|
$code = 2;
|
|
}
|
|
return max($code, Restore::reportSkipped($result['skipped'], $output));
|
|
}
|
|
|
|
/** 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('<comment>' . $e->getMessage() . '</comment>');
|
|
}
|
|
if ($code !== 0) {
|
|
$output->writeln("<comment>Could not $command $app (not in the app store?). Copy it into custom_apps and enable it, or restore without it.</comment>");
|
|
}
|
|
}
|
|
}
|