Files
nextcloud-ng_backup/lib/Command/TargetAdd.php
T
dillard d8ef50732f Full restore creates missing tables and suspends foreign keys; --install-apps; clearer messages
Backups now record each table's structure in the dump manifest. The full
restore creates tables that do not exist on the new instance (left behind
by apps that were removed or are switched off), lists all problems at
once, and no longer stops on apps that are missing. Foreign keys are not
checked while rows are loaded in name order, and checked again before the
restore commits (PostgreSQL: dropped and added back in the transaction).

--install-apps installs or enables the apps that were enabled at backup
time; --skip-missing-tables leaves out tables of older backups whose
structure is unknown. nextcloud-aio is never required.

backup:target:add without a key points to backup:key:import, and says that
connecting can take a while. Removing a location drops its cached index;
a migration removes the cache of locations that are gone.

The full-restore test now also runs on PostgreSQL with Mail (foreign keys)
and a disabled app.
2026-10-18 01:45:01 +02:00

80 lines
3.8 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\KeyService;
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 TargetAdd extends Command {
public function __construct(
private TargetService $targets,
private KeyService $keys,
) {
parent::__construct();
}
protected function configure(): void {
$this->setName('backup:target:add')
->setDescription('Add a backup location (S3, SFTP, WebDAV, ...) via files_external, without mounting it')
->addArgument('name', InputArgument::REQUIRED, 'Name of the location')
->addArgument('backend', InputArgument::REQUIRED, 'files_external backend id (see backup:target:backends)')
->addOption('auth', 'a', InputOption::VALUE_REQUIRED, 'Auth mechanism id (see backup:target:backends)')
->addOption('option', 'o', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Backend/auth option key=value (repeatable)')
->addOption('path', 'p', InputOption::VALUE_REQUIRED, 'Folder on the location for this repository', 'ng_backup')
->setHelp(<<<'HELP'
The location is reached with files_external's own connection code, but it is never added as an
external storage: it does not appear in Files for anyone. Credentials are stored encrypted.
Examples:
occ backup:target:add offsite amazons3 -a amazons3::accesskey -o bucket=backups -o hostname=s3.example.com -o region=eu-central-1 -o use_ssl=true -o use_path_style=true -o key=AKIA... -o secret=... --path=nextcloud
occ backup:target:add nas sftp -a password::password -o host=nas.example.lan -o root=/backups -o user=nextcloud -o password=... --path=ng_backup
HELP);
}
protected function execute(InputInterface $input, OutputInterface $output): int {
if (!$this->keys->isInitialized()) {
$output->writeln('<error>No backup key yet.</error>');
$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 = [];
foreach ($input->getOption('option') as $pair) {
if (!str_contains($pair, '=')) {
$output->writeln("<error>Option '$pair' must be key=value</error>");
return 1;
}
[$k, $v] = explode('=', $pair, 2);
$options[$k] = match (strtolower($v)) { 'true' => true, 'false' => false, default => $v };
}
if ($input->getOption('auth') === null) {
$output->writeln('<error>--auth is required (see occ backup:target:backends)</error>');
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) {
$output->writeln('<error>' . $e->getMessage() . '</error>');
return 1;
}
$output->writeln(sprintf('Location <info>%s</info> added (id %d), repository %s %s.', $r['target']->getName(), $r['target']->getId(),
$r['target']->getRepositoryId(), $r['created'] ? 'created' : 'found and adopted'));
return 0;
}
}