TikiWiki/tiki-manager

View on GitHub
src/Command/CloneInstanceCommand.php

Summary

Maintainability
F
1 wk
Test Coverage
<?php

/**
 * @copyright (c) Copyright by authors of the Tiki Manager Project. All Rights Reserved.
 *     See copyright.txt for details and a complete list of authors.
 * @licence Licensed under the GNU LESSER GENERAL PUBLIC LICENSE. See LICENSE for details.
 */

namespace TikiManager\Command;

use Exception;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Input\InputArgument;
use TikiManager\Application\Instance;
use TikiManager\Application\Tiki\Versions\Fetcher\YamlFetcher;
use TikiManager\Application\Tiki\Versions\TikiRequirementsHelper;
use TikiManager\Application\Version;
use TikiManager\Command\Helper\CommandHelper;
use TikiManager\Command\Traits\InstanceConfigure;
use TikiManager\Command\Traits\InstanceUpgrade;
use TikiManager\Config\Environment;
use TikiManager\Libs\Database\Database;
use TikiManager\Libs\Helpers\VersionControl;

class CloneInstanceCommand extends TikiManagerCommand
{
    use InstanceConfigure;
    use InstanceUpgrade;

    protected function configure()
    {
        parent::configure();

        $this
            ->setName('instance:clone')
            ->setDescription('Clone instance')
            ->setHelp('This command allows you make another identical copy of Tiki')
            ->addArgument('mode', InputArgument::IS_ARRAY | InputArgument::OPTIONAL)
            ->addOption(
                'check',
                null,
                InputOption::VALUE_NONE,
                'Check files checksum after operation has been performed. (Only in upgrade mode)'
            )
            ->addOption(
                'source',
                's',
                InputOption::VALUE_REQUIRED,
                'Source instance.'
            )
            ->addOption(
                'target',
                't',
                InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED,
                'Destination instance(s).'
            )
            ->addOption(
                'branch',
                'b',
                InputOption::VALUE_REQUIRED,
                'Select Branch.'
            )
            ->addOption(
                'skip-reindex',
                null,
                InputOption::VALUE_NONE,
                'Skip rebuilding index step. (Only in upgrade mode).'
            )
            ->addOption(
                'skip-cache-warmup',
                null,
                InputOption::VALUE_NONE,
                'Skip generating cache step. (Only in upgrade mode).'
            )
            ->addOption(
                'live-reindex',
                null,
                InputOption::VALUE_OPTIONAL,
                'Live reindex, set instance maintenance off and after perform index rebuild. (Only in upgrade mode)',
                true
            )
            ->addOption(
                'direct',
                'd',
                InputOption::VALUE_NONE,
                'Prevent using the backup step and rsync source to target.'
            )
            ->addOption(
                'keep-backup',
                null,
                InputOption::VALUE_NONE,
                'Source instance backup is not deleted before the process finished.'
            )
            ->addOption(
                'use-last-backup',
                null,
                InputOption::VALUE_NONE,
                'Use source instance last created backup.'
            )->addOption(
                'db-host',
                'dh',
                InputOption::VALUE_REQUIRED,
                'Target instance database host'
            )
            ->addOption(
                'db-user',
                'du',
                InputOption::VALUE_REQUIRED,
                'Target instance database user'
            )
            ->addOption(
                'db-pass',
                'dp',
                InputOption::VALUE_REQUIRED,
                'Target instance database password'
            )
            ->addOption(
                'db-prefix',
                'dpx',
                InputOption::VALUE_REQUIRED,
                'Target instance database prefix'
            )
            ->addOption(
                'db-name',
                'dn',
                InputOption::VALUE_REQUIRED,
                'Target instance database name'
            )
            ->addOption(
                'stash',
                null,
                InputOption::VALUE_NONE,
                'Saves your local modifications, and try to apply after update/upgrade'
            )
            ->addOption(
                'timeout',
                null,
                InputOption::VALUE_OPTIONAL,
                'Modify the default command execution timeout from 3600 seconds to a custom value'
            )
            ->addOption(
                'ignore-requirements',
                null,
                InputOption::VALUE_NONE,
                'Ignore version requirements. Allows to select non-supported branches, useful for testing.'
            )
            ->addOption(
                'only-data',
                null,
                InputOption::VALUE_NONE,
                'Clone only database and data files. Skip cloning code.'
            )
            ->addOption(
                'only-code',
                null,
                InputOption::VALUE_NONE,
                'Clone only code files. Skip cloning database.'
            );
    }

    protected function execute(InputInterface $input, OutputInterface $output)
    {
        $instances = CommandHelper::getInstances('all', true);
        $instancesInfo = CommandHelper::getInstancesInfo($instances);

        if (empty($instancesInfo)) {
            $output->writeln('<comment>No instances available to clone/clone and upgrade.</comment>');
            return 0;
        }

        $helper = $this->getHelper('question');

        $clone = false;
        $cloneUpgrade = false;
        $offset = 0;

        $checksumCheck = $input->getOption('check');
        $skipReindex = $input->getOption('skip-reindex');
        $skipCache = $input->getOption('skip-cache-warmup');
        $liveReindex = is_null($input->getOption('live-reindex')) ? true : filter_var($input->getOption('live-reindex'), FILTER_VALIDATE_BOOLEAN);
        $direct = $input->getOption('direct');
        $keepBackup = $input->getOption('keep-backup');
        $useLastBackup = $input->getOption('use-last-backup');
        $argument = $input->getArgument('mode');
        $onlyData = $input->getOption('only-data');
        $onlyCode = $input->getOption('only-code');
        $vcsOptions = [
            'allow_stash' => $input->getOption('stash')
        ];
        $timeout = $input->getOption('timeout') ?: Environment::get('COMMAND_EXECUTION_TIMEOUT');
        putenv("COMMAND_EXECUTION_TIMEOUT=$timeout");

        $setupTargetDatabase = (bool) ($input->getOption('db-prefix') || $input->getOption('db-name'));

        if ($direct && ($keepBackup || $useLastBackup)) {
            $this->io->error('The options --direct and --keep-backup or --use-last-backup could not be used in conjunction, instance filesystem is not in the backup file.');
            return 1;
        }

        if (isset($argument) && !empty($argument)) {
            if (is_array($argument)) {
                $clone = $input->getArgument('mode')[0] == 'clone';
                $cloneUpgrade = $input->getArgument('mode')[0] == 'upgrade';
            } else {
                $cloneUpgrade = $input->getArgument('mode') == 'upgrade';
            }
        }

        if ($cloneUpgrade && ($onlyData || $onlyCode)) {
            $this->io->error('The options --only-code and --only-data cannot be used when cloning and upgrading an instance.');
            return 1;
        }

        if ($clone != false || $cloneUpgrade != false) {
            $offset = 1;
        }

        $arguments = array_slice($input->getArgument('mode'), $offset);
        $sourceOption = $input->getOption("source");
        if (! empty($arguments[0])) {
            $sourceInstances = getEntries($instances, $arguments[0]);
            if (empty($sourceInstances)) {
                throw new Exception("Invalid sourceInstanceId. Usage : php tiki-manager instance:clone [clone | upgrade] [sourceInstanceId targetInstanceId [upgradeBranch]]");
            }
        } elseif ($sourceOption) {
            $sourceInstances = CommandHelper::validateInstanceSelection($sourceOption, $instances);
        } else {
            $this->io->newLine();
            $output->writeln('<comment>NOTE: Clone operations are only available on Local and SSH instances.</comment>');

            $this->io->newLine();
            CommandHelper::renderInstancesTable($output, $instancesInfo);

            $question = CommandHelper::getQuestion('Select the source instance', null);
            $question->setValidator(function ($answer) use ($instances) {
                return CommandHelper::validateInstanceSelection($answer, $instances);
            });

            $sourceInstances = $helper->ask($input, $output, $question);
        }

        $sourceInstance = $sourceInstances[0];
        if ($cloneUpgrade) {
            $instances = CommandHelper::getInstances('upgrade');
        } else {
            $instances = CommandHelper::getInstances('all');
        }

        $instances = array_filter($instances, function ($instance) use ($sourceInstance) {
            return $instance->getId() != $sourceInstance->getId();
        });

        $instancesInfo = CommandHelper::getInstancesInfo($instances);

        if (empty($instancesInfo)) {
            $output->writeln('<comment>No instances available as destination.</comment>');
            return 0;
        }
        $targetOption = implode(',', $input->getOption('target'));
        if (! empty($arguments[1])) {
            $targetInstances = getEntries($instances, $arguments[1]);
            if (empty($targetInstances)) {
                throw new Exception("Invalid targetInstanceId.\n Usage : php tiki-manager instance:clone [clone | upgrade] [sourceInstanceId targetInstanceId [upgradeBranch]]");
            }
        } elseif ($targetOption) {
            $targetInstances = CommandHelper::validateInstanceSelection($targetOption, $instances);
        } else {
            $this->io->newLine();
            CommandHelper::renderInstancesTable($output, $instancesInfo);

            $question = CommandHelper::getQuestion('Select the destination instance(s)', null);
            $question->setValidator(function ($answer) use ($instances) {
                return CommandHelper::validateInstanceSelection($answer, $instances);
            });

            $targetInstances = $helper->ask($input, $output, $question);
        }

        if ($setupTargetDatabase && count($targetInstances) > 1) {
            $this->logger->error('Database setup options can only be used when a single target instance is passed.');
            return 1;
        }

        if ($cloneUpgrade) {
            $branch = $arguments[2] ?? $input->getOption('branch');
            $ignoreReq = $input->getOption('ignore-requirements') ?? false;

            // Get current version from Source
            $curVersion = $sourceInstance->getLatestVersion();

            $upVersion = null;

            foreach ($targetInstances as $key => $targetInstance) {
                $targetInstance->detectPHP();
                if (!$upVersion) {
                    $upVersion = $this->getUpgradeVersion($targetInstance, !$ignoreReq, $branch, $curVersion);
                    continue;
                }

                if (!$this->validateUpgradeVersion($targetInstance, !$ignoreReq, $upVersion->branch, $curVersion)) {
                    $this->io->writeln('Cannot clone&upgrade to %s, as version is not supported by server requirements.');
                    unset($targetInstances[$key]);
                }
            }

            $input->setOption('branch', $upVersion->branch);
        }

        // PRE-CHECK
        $this->io->newLine();
        $this->io->section('Pre-check');
        $this->io->writeln('Executing pre-check operations...');

        $directWarnMessage = 'Direct backup cannot be used, instance {instance_name} is ftp.';
        // Check if direct flag can be used
        if ($direct && $sourceInstance->type == 'ftp') {
            $direct = false;
            $this->logger->warning($directWarnMessage, ['instance_name' => $sourceInstance->name]);
        }

        if ($direct) {
            foreach ($targetInstances as $destinationInstance) {
                if ($destinationInstance->type == 'ssh' && $sourceInstance->type == 'ssh') {
                    $directWarnMessage = 'Direct backup cannot be used, instance {source_name} and instance {target_name} are both ssh.';
                    $this->logger->warning(
                        $directWarnMessage,
                        ['target_name' => $destinationInstance->name, 'source_name' => $sourceInstance->name]
                    );
                    $direct = false;
                    break;
                }

                if ($destinationInstance->type == 'ftp') {
                    $this->logger->warning($directWarnMessage, ['instance_name' => $destinationInstance->name]);
                    $direct = false;
                    break;
                }
            }
        }

        if (!$onlyCode) {
            $dbConfigErrorMessage = 'Unable to load/set database configuration for instance {instance_name} (id: {instance_id}). {exception_message}';

            try {
                // The source instance needs to be well configured by default
                if (!$this->testExistingDbConnection($sourceInstance)) {
                    throw new Exception('Existing database configuration failed to connect.');
                }
            } catch (Exception $e) {
                $this->logger->error($dbConfigErrorMessage, [
                    'instance_name' => $sourceInstance->name,
                    'instance_id' => $sourceInstance->getId(),
                    'exception_message' => $e->getMessage(),
                ]);
                return 1;
            }

            foreach ($targetInstances as $key => $destinationInstance) {
                try {
                    $destinationInstance->app = $sourceInstance->app; // Required to setup database connection

                    if (! $setupTargetDatabase && ! $this->input->isInteractive() && ! $this->testExistingDbConnection($destinationInstance)) {
                        throw new Exception('Existing database configuration failed to connect.');
                    }

                    $this->setupDatabase($destinationInstance, $setupTargetDatabase);
                    $destinationInstance->database()->setupConnection();
                } catch (Exception $e) {
                    $this->logger->error($dbConfigErrorMessage, [
                        'instance_name' => $destinationInstance->name,
                        'instance_id' => $destinationInstance->getId(),
                        'exception_message' => $e->getMessage(),
                    ]);
                    unset($targetInstances[$key]);
                    continue;
                }

                if ($this->isSameDatabase($sourceInstance, $destinationInstance)) {
                    $this->logger->error('Database host and name are the same in the source ({source_instance_name}) and destination ({target_instance_id}).', [
                        'source_instance_name' => $sourceInstance->name,
                        'target_instance_id' => $destinationInstance->name
                    ]);
                    unset($targetInstances[$key]);
                    continue;
                }
            }
        }

        if (empty($targetInstances)) {
            $this->logger->error('No valid instances to continue the clone process.');
            return 1;
        }

        $hookName = $this->getCommandHook();
        $hookName->registerPostHookVars(['source' => $sourceInstance]);

        $archive = '';
        $standardProcess = true;
        if ($useLastBackup) {
            $standardProcess = false;
            $archiveDir = rtrim(getenv('ARCHIVE_FOLDER'), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
            $archiveDir .= sprintf('%s-%s', $sourceInstance->id, $sourceInstance->name);

            if (file_exists($archiveDir)) {
                $archiveFiles = array_diff(scandir($archiveDir, SCANDIR_SORT_DESCENDING), ['.', '..']);
                if (!empty($archiveFiles[0])) {
                    $archive = $archiveDir . DIRECTORY_SEPARATOR . $archiveFiles[0];
                    $this->logger->notice('Using last created backup ({backup_name}) of {instance_name}', [
                        'instance_name' => $sourceInstance->name,
                        'backup_name' => $archiveFiles[0]
                    ]);
                    $keepBackup = true;
                } else {
                    $this->logger->error('Backups not found for instance {instance_name}', ['instance_name' => $sourceInstance->name]);
                    $standardProcess = $this->io->confirm('Continue with standard process?', true);

                    if (!$standardProcess) {
                        $this->io->writeln('Clone process aborted.');
                        return 1;
                    }
                }
            }
        }

        // SNAPSHOT SOURCE INSTANCE
        if ($standardProcess) {
            $this->io->newLine();
            $this->io->section('Creating snapshot of: ' . $sourceInstance->name);
            try {
                $archive = $sourceInstance->backup($direct, true, $onlyCode);
            } catch (Exception $e) {
                $this->logger->error($e->getMessage());
            }
        }

        if (empty($archive)) {
            $this->logger->error('Snapshot creation failed.');
            return 1;
        }

        $hookName->registerPostHookVars(['backup' => $archive]);

        $options = [
            'checksum-check' => $checksumCheck,
            'skip-reindex' => $skipReindex,
            'skip-cache-warmup' => $skipCache,
            'live-reindex' => $liveReindex,
            'timeout' => $timeout,
        ];

        /** @var Instance $destinationInstance */
        foreach ($targetInstances as $destinationInstance) {
            $this->io->newLine();
            $this->io->section('Initiating clone of ' . $sourceInstance->name . ' to ' . $destinationInstance->name);

            $instanceVCS = $destinationInstance->getVersionControlSystem();
            $instanceVCS->setLogger($this->logger);
            $instanceVCS->setVCSOptions($vcsOptions);

            $hookName->registerPostHookVars(['instance' => $destinationInstance]);

            $destinationInstance->lock();
            $errors = $destinationInstance->restore($sourceInstance, $archive, true, $checksumCheck, $direct, $onlyData, $onlyCode, $options);

            if (isset($errors)) {
                return 1;
            }

            if ($cloneUpgrade) {
                $branch = $input->getOption('branch');
                $branch = VersionControl::formatBranch($branch, $destinationInstance->vcs_type);
                $upgrade_version = Version::buildFake($destinationInstance->vcs_type, $branch);

                $output->writeln('<fg=cyan>Upgrading to version ' . $upgrade_version->branch . '</>');
                $app = $destinationInstance->getApplication();

                try {
                    $app->performUpgrade($destinationInstance, $upgrade_version, $options);
                } catch (Exception $e) {
                    CommandHelper::setInstanceSetupError($destinationInstance->id, $e);
                    continue;
                }
            }
            if ($destinationInstance->isLocked()) {
                $destinationInstance->unlock();
            }
        }

        if (!$keepBackup && !$direct) {
            $output->writeln('Deleting archive...');
            $access = $sourceInstance->getBestAccess('scripting');
            $access->shellExec("rm -f " . $archive);
        }

        $this->io->newLine();
        $this->logger->info('Finished');
        return 0;
    }

    /**
     * @param Instance $source
     * @param Instance $target
     * @return bool
     */
    public function isSameDatabase(Instance $source, Instance $target): bool
    {
        $sourceAccess = $source->getBestAccess();
        $targetAccess = $target->getBestAccess();
        $sourceDB = $source->getDatabaseConfig();
        $targetDB = $target->getDatabaseConfig();

        return (($sourceAccess->host == $targetAccess->host ||
            ($sourceAccess->host != $targetAccess->host && !in_array($targetDB->host, ['127.0.0.1', 'localhost'])))
            && Database::compareDatabase($sourceDB, $targetDB));
    }
}