433 lines
16 KiB
PHP
433 lines
16 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Commands\State;
|
|
|
|
use App\Backends\Common\Cache as BackendCache;
|
|
use App\Command;
|
|
use App\Libs\Attributes\Route\Cli;
|
|
use App\Libs\Config;
|
|
use App\Libs\ConfigFile;
|
|
use App\Libs\Container;
|
|
use App\Libs\Mappers\ExtendedImportInterface as iEImport;
|
|
use App\Libs\Mappers\Import\DirectMapper;
|
|
use App\Libs\Options;
|
|
use App\Libs\Stream;
|
|
use Psr\Http\Message\StreamInterface as iStream;
|
|
use Psr\Log\LoggerInterface as iLogger;
|
|
use Symfony\Component\Console\Input\InputInterface as iInput;
|
|
use Symfony\Component\Console\Input\InputOption;
|
|
use Symfony\Component\Console\Output\OutputInterface as iOutput;
|
|
use Symfony\Contracts\HttpClient\ResponseInterface;
|
|
use Throwable;
|
|
|
|
/**
|
|
* Class BackupCommand
|
|
*
|
|
* Generate portable backup of backends play state.
|
|
*/
|
|
#[Cli(command: self::ROUTE)]
|
|
class BackupCommand extends Command
|
|
{
|
|
public const string ROUTE = 'state:backup';
|
|
|
|
public const string TASK_NAME = 'backup';
|
|
|
|
/**
|
|
* Constructs a new instance of the class.
|
|
*
|
|
* @param DirectMapper $mapper The direct mapper instance.
|
|
* @param iLogger $logger The logger instance.
|
|
*/
|
|
public function __construct(private DirectMapper $mapper, private iLogger $logger)
|
|
{
|
|
set_time_limit(0);
|
|
ini_set('memory_limit', '-1');
|
|
|
|
parent::__construct();
|
|
}
|
|
|
|
/**
|
|
* Configures the command.
|
|
*/
|
|
protected function configure(): void
|
|
{
|
|
$this->setName(self::ROUTE)
|
|
->setDescription('Backup backends play state.')
|
|
->addOption(
|
|
'keep',
|
|
'k',
|
|
InputOption::VALUE_NONE,
|
|
'If this flag is used, backups will not be removed by system:purge task.'
|
|
)
|
|
->addOption('dry-run', null, InputOption::VALUE_NONE, 'No actions will be committed.')
|
|
->addOption('timeout', null, InputOption::VALUE_REQUIRED, 'Set request timeout in seconds.')
|
|
->addOption(
|
|
'select-backend',
|
|
's',
|
|
InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED,
|
|
'Select backend.',
|
|
)
|
|
->addOption('exclude', null, InputOption::VALUE_NONE, 'Inverse --select-backend logic.')
|
|
->addOption(
|
|
'no-enhance',
|
|
null,
|
|
InputOption::VALUE_NONE,
|
|
'Do not enhance the backup data using local db info.'
|
|
)
|
|
->addOption(
|
|
'file',
|
|
null,
|
|
InputOption::VALUE_REQUIRED,
|
|
'Full path backup file. Will only be used if backup list is 1'
|
|
)
|
|
->addOption('only-main-user', 'M', InputOption::VALUE_NONE, 'Only backup main user data.')
|
|
->addOption('no-compress', 'N', InputOption::VALUE_NONE, 'Do not compress the backup file.')
|
|
->setHelp(
|
|
r(
|
|
<<<HELP
|
|
|
|
Generate <notice>portable</notice> backup of your backends play state that can be used to restore any supported backend type.
|
|
|
|
------------------
|
|
<notice>[ Important info ]</notice>
|
|
------------------
|
|
|
|
The command will only work on backends that has import enabled.
|
|
|
|
Backups generated without [<flag>-k</flag>, <flag>--keep</flag>] flag are subject to be <notice>REMOVED</notice> during system:prune run.
|
|
To keep permanent copy of your backups you can use the [<flag>-k</flag>, </flag>--keep</info>] flag. For example:
|
|
|
|
{cmd} <cmd>{route}</cmd> <info>--keep</info> [<flag>--select-backend</flag> <value>backend_name</value>]
|
|
|
|
Backups generated with [<flag>-k</flag>, <flag>--keep</flag>] flag will not contain a date and will be named [<value>backend_name.json</value>]
|
|
where automated backups will be named [<value>backend_name.00000000{date}.json</value>]
|
|
|
|
<notice>If filename already exists, it will be overwritten.</notice>
|
|
|
|
-------
|
|
<notice>[ FAQ ]</notice>
|
|
-------
|
|
|
|
<question># Where are my backups stored?</question>
|
|
|
|
By default, we store backups at [<value>{backupDir}</value>].
|
|
|
|
<question># Why the external ids are not exactly the same from backend?</question>
|
|
|
|
By default we enhance the data from the backend to allow the backup to be usable by all if your backends,
|
|
The expanded external ids make the data more portable, However, if you do not wish to have this enabled. You can
|
|
disable it via [<flag>--no-enhance</flag>] flag. <notice>We recommend to keep this option enabled</notice>.
|
|
|
|
<question># I want different file name for my backup?</question>
|
|
|
|
Backup names are something tricky, however it's possible to choose the backup filename if the total number
|
|
of backed up backends are 1. So, in essence you have to combine two flags [<flag>-s</flag>, <flag>--select-backend</flag>] and [<flag>--file</flag>].
|
|
|
|
For example, to back up [<value>backend_name</value>] backend data to [<value>/tmp/backend_name.json</value>] do the following:
|
|
|
|
{cmd} <cmd>{route}</cmd> <flag>--select-backend</flag> <value>backend_name</value> <flag>--file</flag> <value>/tmp/my_backend.json</value>
|
|
|
|
HELP,
|
|
[
|
|
'cmd' => trim(commandContext()),
|
|
'route' => self::ROUTE,
|
|
'backupDir' => after(Config::get('path') . '/backup', ROOT_PATH),
|
|
|
|
]
|
|
)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Make sure the command is not running in parallel.
|
|
*
|
|
* @param iInput $input The input interface instance.
|
|
* @param iOutput $output The output interface instance.
|
|
*
|
|
* @return int The exit code of the command.
|
|
*/
|
|
protected function runCommand(iInput $input, iOutput $output): int
|
|
{
|
|
return $this->single(fn(): int => $this->process($input), $output);
|
|
}
|
|
|
|
/**
|
|
* Execute the command.
|
|
*
|
|
* @param iInput $input The input interface.
|
|
*
|
|
* @return int The integer result.
|
|
*/
|
|
protected function process(iInput $input): int
|
|
{
|
|
$mapperOpts = [];
|
|
|
|
if ($input->getOption('dry-run')) {
|
|
$this->logger->notice('SYSTEM: Dry run mode. No changes will be committed.');
|
|
$mapperOpts[Options::DRY_RUN] = true;
|
|
}
|
|
|
|
if ($input->getOption('trace')) {
|
|
$mapperOpts[Options::DEBUG_TRACE] = true;
|
|
}
|
|
|
|
if (!empty($mapperOpts)) {
|
|
$this->mapper->setOptions(options: $mapperOpts);
|
|
}
|
|
|
|
$opts = [];
|
|
if (true === (bool)$input->getOption('only-main-user')) {
|
|
$opts = ['main_user_only' => true];
|
|
}
|
|
|
|
$this->logger->notice("Using WatchState version - '{version}'.", ['version' => getAppVersion()]);
|
|
foreach ($this->getUserData($this->mapper, $this->logger, $opts) as $user => $opt) {
|
|
try {
|
|
$this->process_backup($input, $user, $opt);
|
|
} finally {
|
|
ag($opt, 'mapper')->reset();
|
|
}
|
|
}
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
private function process_backup(iInput $input, string $user, array $opt): void
|
|
{
|
|
$list = [];
|
|
|
|
$selected = $input->getOption('select-backend');
|
|
$isCustom = !empty($selected) && count($selected) > 0;
|
|
$supported = Config::get('supported', []);
|
|
|
|
$noCompression = $input->getOption('no-compress');
|
|
|
|
$config = ag($opt, 'config');
|
|
assert($config instanceof ConfigFile);
|
|
|
|
foreach ($config->getAll() as $backendName => $backend) {
|
|
$type = strtolower(ag($backend, 'type', 'unknown'));
|
|
|
|
if ($isCustom && $input->getOption('exclude') === $this->in_array($selected, $backendName)) {
|
|
$this->logger->info("SYSTEM: Ignoring '{user}@{backend}' as requested by [-s, --select-backend].", [
|
|
'user' => $user,
|
|
'backend' => $backendName
|
|
]);
|
|
continue;
|
|
}
|
|
|
|
if (true !== (bool)ag($backend, 'import.enabled')) {
|
|
$this->logger->info("SYSTEM: Ignoring '{user}@{backend}' as the backend has import disabled.", [
|
|
'user' => $user,
|
|
'backend' => $backendName
|
|
]);
|
|
continue;
|
|
}
|
|
|
|
if (!isset($supported[$type])) {
|
|
$this->logger->error(
|
|
"SYSTEM: Ignoring '{user}@{backend}' due to unexpected type '{type}'. Expecting '{types}'.",
|
|
[
|
|
'user' => $user,
|
|
'type' => $type,
|
|
'backend' => $backendName,
|
|
'types' => implode(', ', array_keys($supported)),
|
|
]
|
|
);
|
|
continue;
|
|
}
|
|
|
|
if (null === ($url = ag($backend, 'url')) || false === isValidURL($url)) {
|
|
$this->logger->error("SYSTEM: Ignoring '{user}@{backend}' due to invalid URL. '{url}'.", [
|
|
'user' => $user,
|
|
'url' => $url ?? 'None',
|
|
'backend' => $backendName,
|
|
]);
|
|
continue;
|
|
}
|
|
|
|
$backend['name'] = $backendName;
|
|
$list[$backendName] = $backend;
|
|
}
|
|
|
|
if (empty($list)) {
|
|
$this->logger->warning(
|
|
$isCustom ? '[-s, --select-backend] flag did not match any backend.' : 'No backends were found.'
|
|
);
|
|
return;
|
|
}
|
|
|
|
$mapper = ag($opt, 'mapper');
|
|
assert($mapper instanceof iEImport);
|
|
|
|
if (true !== $input->getOption('no-enhance')) {
|
|
$this->logger->notice("SYSTEM: Preloading '{user}@{mapper}' data.", [
|
|
'user' => $user,
|
|
'mapper' => afterLast($mapper::class, '\\'),
|
|
'memory' => [
|
|
'now' => getMemoryUsage(),
|
|
'peak' => getPeakMemoryUsage(),
|
|
],
|
|
]);
|
|
|
|
$start = microtime(true);
|
|
$this->mapper->loadData();
|
|
|
|
$this->logger->notice("SYSTEM: Preloading '{user}@{mapper}' data completed in '{duration}s'.", [
|
|
'user' => $user,
|
|
'mapper' => afterLast($this->mapper::class, '\\'),
|
|
'duration' => round(microtime(true) - $start, 2),
|
|
'memory' => [
|
|
'now' => getMemoryUsage(),
|
|
'peak' => getPeakMemoryUsage(),
|
|
],
|
|
]);
|
|
}
|
|
|
|
/** @var array<array-key,ResponseInterface> $queue */
|
|
$queue = [];
|
|
|
|
foreach ($list as $name => &$backend) {
|
|
$opts = ag($backend, 'options', []);
|
|
|
|
if ($input->getOption('trace')) {
|
|
$opts[Options::DEBUG_TRACE] = true;
|
|
}
|
|
|
|
if ($input->getOption('dry-run')) {
|
|
$opts[Options::DRY_RUN] = true;
|
|
}
|
|
|
|
if ($input->getOption('timeout')) {
|
|
$opts['client']['timeout'] = (float)$input->getOption('timeout');
|
|
}
|
|
|
|
$backend['options'] = $opts;
|
|
|
|
$backendOpts = [];
|
|
|
|
if (null !== ag($opt, 'cache')) {
|
|
$backendOpts = [
|
|
BackendCache::class => Container::get(BackendCache::class)->with(adapter: ag($opt, 'cache')),
|
|
];
|
|
}
|
|
|
|
$backend['class'] = makeBackend($backend, $name, $backendOpts)->setLogger($this->logger);
|
|
|
|
$this->logger->notice("SYSTEM: Backing up '{user}@{backend}' play state.", [
|
|
'user' => $user,
|
|
'backend' => $name,
|
|
]);
|
|
|
|
if (null === ($fileName = $input->getOption('file')) || empty($fileName)) {
|
|
$fileName = Config::get('path') . '/backup/{backend}.{date}.json';
|
|
}
|
|
|
|
if ($input->getOption('keep')) {
|
|
$fileName = Config::get('path') . '/backup/{backend}.json';
|
|
}
|
|
|
|
if (count($list) <= 1 && null !== ($file = $input->getOption('file'))) {
|
|
$fileName = str_starts_with($file, '/') ? $file : Config::get('path') . '/backup' . '/' . $file;
|
|
}
|
|
|
|
if (false === $input->getOption('dry-run')) {
|
|
$fileName = r($fileName ?? Config::get('path') . '/backup/{backend}.{date}.json', [
|
|
'backend' => ag($backend, 'name', 'Unknown??'),
|
|
'date' => makeDate()->format('Ymd'),
|
|
]);
|
|
|
|
if (!file_exists($fileName)) {
|
|
touch($fileName);
|
|
}
|
|
|
|
$this->logger->notice("SYSTEM: '{user}@{backend}' is using '{file}' as backup target.", [
|
|
'user' => $user,
|
|
'file' => realpath($fileName),
|
|
'backend' => $name,
|
|
]);
|
|
|
|
$backend['fp'] = new Stream($fileName, 'wb+');
|
|
$backend['fp']->write('[');
|
|
}
|
|
|
|
array_push($queue, ...$backend['class']->backup($mapper, $backend['fp'] ?? null, [
|
|
'no_enhance' => true === $input->getOption('no-enhance'),
|
|
Options::DRY_RUN => (bool)$input->getOption('dry-run'),
|
|
]));
|
|
}
|
|
|
|
unset($backend);
|
|
|
|
$start = microtime(true);
|
|
$this->logger->notice("SYSTEM: Waiting on '{total}' requests for '{user}: {backends}' backends.", [
|
|
'user' => $user,
|
|
'total' => number_format(count($queue)),
|
|
'backends' => implode(', ', array_keys($list)),
|
|
'memory' => [
|
|
'now' => getMemoryUsage(),
|
|
'peak' => getPeakMemoryUsage(),
|
|
],
|
|
]);
|
|
|
|
foreach ($queue as $_key => $response) {
|
|
$requestData = $response->getInfo('user_data');
|
|
|
|
try {
|
|
$requestData['ok']($response);
|
|
} catch (Throwable $e) {
|
|
$requestData['error']($e);
|
|
}
|
|
|
|
$queue[$_key] = null;
|
|
|
|
gc_collect_cycles();
|
|
}
|
|
|
|
foreach ($list as $b => $backend) {
|
|
if (null === ($backend['fp'] ?? null)) {
|
|
continue;
|
|
}
|
|
|
|
assert($backend['fp'] instanceof iStream);
|
|
|
|
if (false === $input->getOption('dry-run')) {
|
|
$backend['fp']->seek(-1, SEEK_END);
|
|
$backend['fp']->write(PHP_EOL . ']');
|
|
|
|
if (false === $noCompression) {
|
|
$file = $backend['fp']->getMetadata('uri');
|
|
$this->logger->notice("SYSTEM: Compressing '{user}@{name}' backup file '{file}'.", [
|
|
'name' => $b,
|
|
'user' => $user,
|
|
'file' => $file
|
|
]);
|
|
$status = compress_files($file, [$file], ['affix' => 'zip']);
|
|
if (true === $status) {
|
|
unlink($file);
|
|
}
|
|
}
|
|
|
|
$backend['fp']->close();
|
|
}
|
|
}
|
|
|
|
$this->logger->notice("SYSTEM: Backup operation for '{user}: {backends}' backends finished in '{duration}s'.", [
|
|
'user' => $user,
|
|
'backends' => implode(', ', array_keys($list)),
|
|
'duration' => round(microtime(true) - $start, 2),
|
|
'memory' => [
|
|
'now' => getMemoryUsage(),
|
|
'peak' => getPeakMemoryUsage(),
|
|
],
|
|
]);
|
|
}
|
|
|
|
private function in_array(array $haystack, string $needle): bool
|
|
{
|
|
return array_any($haystack, fn($item) => str_starts_with($item, $needle));
|
|
}
|
|
}
|