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( <<portable backup of your backends play state that can be used to restore any supported backend type. ------------------ [ Important info ] ------------------ The command will only work on backends that has import enabled. Backups generated without [-k, --keep] flag are subject to be REMOVED during system:prune run. To keep permanent copy of your backups you can use the [-k, --keep] flag. For example: {cmd} {route} --keep [--select-backend backend_name] Backups generated with [-k, --keep] flag will not contain a date and will be named [backend_name.json] where automated backups will be named [backend_name.00000000{date}.json] If filename already exists, it will be overwritten. ------- [ FAQ ] ------- # Where are my backups stored? By default, we store backups at [{backupDir}]. # Why the external ids are not exactly the same from backend? 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 [--no-enhance] flag. We recommend to keep this option enabled. # I want different file name for my backup? 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 [-s, --select-backend] and [--file]. For example, to back up [backend_name] backend data to [/tmp/backend_name.json] do the following: {cmd} {route} --select-backend backend_name --file /tmp/my_backend.json 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 $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)); } }