diff --git a/src/Application.php b/src/Application.php index e73f3da74..4d0ba38d8 100644 --- a/src/Application.php +++ b/src/Application.php @@ -99,6 +99,8 @@ protected function getCommands() $commands[] = new Command\Activity\ActivityLogCommand(); $commands[] = new Command\App\AppConfigGetCommand(); $commands[] = new Command\App\AppListCommand(); + $commands[] = new Command\Archive\ArchiveExportCommand(); + $commands[] = new Command\Archive\ArchiveImportCommand(); $commands[] = new Command\Auth\AuthInfoCommand(); $commands[] = new Command\Auth\AuthTokenCommand(); $commands[] = new Command\Auth\LogoutCommand(); diff --git a/src/Command/Archive/ArchiveExportCommand.php b/src/Command/Archive/ArchiveExportCommand.php new file mode 100644 index 000000000..1e0eded94 --- /dev/null +++ b/src/Command/Archive/ArchiveExportCommand.php @@ -0,0 +1,473 @@ +setName('archive:export') + ->setDescription('Export an archive from an environment') + ->addOption('file', 'f', InputOption::VALUE_REQUIRED, 'The filename for the archive') + ->addOption('exclude-services', null, InputOption::VALUE_NONE, 'Exclude services') + ->addOption('exclude-mounts', null, InputOption::VALUE_NONE, 'Exclude mounts') + ->addOption('include-variables', null, InputOption::VALUE_NONE, 'Include variables') + ->addOption('include-sensitive-values', null, InputOption::VALUE_NONE, 'Include sensitive variable values'); + $this->addProjectOption(); + $this->addEnvironmentOption(); + } + + /** + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $this->validateInput($input); + + /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ + $questionHelper = $this->getService('question_helper'); + /** @var \Platformsh\Cli\Service\Filesystem $fs */ + $fs = $this->getService('fs'); + + $this->stdErr->writeln(sprintf( + 'Archiving data from the project %s, environment %s', + $this->api()->getProjectLabel($this->getSelectedProject()), + $this->api()->getEnvironmentLabel($this->getSelectedEnvironment()) + )); + $this->stdErr->writeln(''); + + $environment = $this->getSelectedEnvironment(); + $deployment = $this->api()->getCurrentDeployment($environment, true); + + $archiveId = sprintf('archive-%s--%s', $environment->machine_name, $environment->project); + $archiveFilename = (string) $input->getOption('file'); + if ($archiveFilename === '') { + $archiveFilename = $archiveId . '.tar.gz'; + } + if (file_exists($archiveFilename)) { + $this->stdErr->writeln(sprintf('The file already exists: %s', $archiveFilename)); + if (!$questionHelper->confirm('Overwrite?')) { + return 1; + } + } + if (!$fs->canWrite($archiveFilename)) { + $this->stdErr->writeln('File not writable: ' . $archiveFilename . ''); + + return 1; + } + + $excludeServices = (bool) $input->getOption('exclude-services'); + $serviceSupport = [ + 'mysql' => 'using "db:dump"', + 'postgresql' => 'using "db:dump"', + 'mongodb' => 'using "mongodump"', + 'network-storage' => 'via mounts', + ]; + $supportedServices = []; + $unsupportedServices = []; + $ignoredServices = []; + if (!$excludeServices) { + foreach ($deployment->services as $name => $service) { + list($type, ) = explode(':', $service->type, 2); + if (isset($serviceSupport[$type]) && !empty($service->disk)) { + $supportedServices[$name] = $type; + } elseif (empty($service->disk)) { + $ignoredServices[$name] = $type; + } else { + $unsupportedServices[$name] = $type; + } + } + } + + if (!empty($supportedServices)) { + $this->stdErr->writeln('Supported services:'); + foreach ($supportedServices as $name => $type) { + $this->stdErr->writeln(sprintf( + ' - %s (%s), %s', + $name, + $type, + $serviceSupport[$type] + )); + } + $this->stdErr->writeln(''); + } + + if (!empty($ignoredServices)) { + $this->stdErr->writeln('Ignored services, without disk storage:'); + foreach ($ignoredServices as $name => $type) { + $this->stdErr->writeln( + sprintf(' - %s (%s)', $name, $type) + ); + } + $this->stdErr->writeln(''); + } + + if (!empty($unsupportedServices)) { + $this->stdErr->writeln('Unsupported services:'); + foreach ($unsupportedServices as $name => $type) { + $this->stdErr->writeln( + sprintf(' - %s (%s)', $name, $type) + ); + } + $this->stdErr->writeln(''); + } + + $apps = []; + $hasMounts = false; + $excludeMounts = (bool) $input->getOption('exclude-mounts'); + $includeVariables = (bool) $input->getOption('include-variables'); + foreach ($deployment->webapps as $name => $webApp) { + $app = new App($webApp, $environment); + $apps[$name] = $app; + $hasMounts = !$excludeMounts && ($hasMounts || count($app->getMounts())); + } + + $nothingToDo = true; + + if ($includeVariables) { + $this->stdErr->writeln('Environment metadata (including variables) will be saved.'); + $nothingToDo = false; + } + + if ($hasMounts && !$excludeMounts) { + $this->stdErr->writeln('Files from mounts will be downloaded.'); + $nothingToDo = false; + } + + if (!$excludeServices && !empty($supportedServices)) { + $this->stdErr->writeln('Data from the above supported service(s) will be saved.'); + $nothingToDo = false; + } + + if ($nothingToDo) { + $this->stdErr->writeln('There is nothing to export.'); + return 1; + } + + $this->stdErr->writeln(''); + + $this->stdErr->writeln('Warning'); + $this->stdErr->writeln('Your site may be changing data during archiving, resulting in inconsistencies.'); + $this->stdErr->writeln('This tool is not suitable for making consistent backups (instead, see ' . $this->config()->get('application.executable') . ' snapshot:create).'); + + $this->stdErr->writeln(''); + + if (!$questionHelper->confirm('Are you sure you want to continue?')) { + return 1; + } + + $tmpDir = $fs->makeTempDir('archive-'); + $archiveDir = $tmpDir . '/' . $archiveId; + if (!mkdir($archiveDir)) { + $this->stdErr->writeln(sprintf('Failed to create archive directory: %s', $archiveDir)); + + return 1; + } + $this->debug('Using archive directory: ' . $archiveDir); + + $metadata = [ + 'time' => date('c'), + 'version' => self::ARCHIVE_VERSION, + 'cli_version' => $this->config()->getVersion(), + 'project' => $this->getSelectedProject()->getProperties(), + 'environment' => $environment->getProperties(), + 'deployment' => $deployment->getProperties(), + ]; + + if ($includeVariables) { + $includeSensitive = $input->getOption('include-sensitive-values'); + $this->stdErr->writeln(''); + $this->stdErr->writeln('Copying project-level variables'); + foreach ($this->getSelectedProject()->getVariables() as $var) { + $metadata['variables']['project'][$var->name] = $var->getProperties(); + if ($var->is_sensitive && !$var->hasProperty('value')) { + if (!$includeSensitive) { + $this->stdErr->writeln(sprintf(' Warning: cannot save value for sensitive project-level variable %s', $var->name)); + $this->stdErr->writeln(' Use --include-sensitive-values to try to fetch this via SSH'); + continue; + } + if (!$var->visible_runtime) { + $this->stdErr->writeln(sprintf(' Warning: cannot save value for sensitive project-level variable %s', $var->name)); + $this->stdErr->writeln(' It is not marked as visible at runtime.'); + continue; + } + $value = false; + foreach ($apps as $app) { + try { + $value = $this->fetchSensitiveValue($app->getSshUrl(), $var->name, $var->is_json); + } catch (\RuntimeException $e) { + $this->stdErr->writeln(sprintf(' Error: Failed to find value for sensitive project-level variable %s in app %s', $var->name, $app->getName())); + continue; + } + break; + } + if ($value !== false) { + $metadata['variables']['project'][$var->name]['value'] = $value; + } + } + } + + $this->stdErr->writeln(''); + $this->stdErr->writeln('Copying environment-level variables'); + foreach ($environment->getVariables() as $envVar) { + $metadata['variables']['environment'][$envVar->name] = $envVar->getProperties(); + if ($envVar->is_sensitive && !$envVar->hasProperty('value')) { + if ($includeSensitive) { + $value = false; + foreach ($apps as $app) { + try { + $value = $this->fetchSensitiveValue($app->getSshUrl(), $envVar->name, $envVar->is_json); + } catch (\RuntimeException $e) { + $this->stdErr->writeln(sprintf(' Error: Failed to find value for sensitive environment-level variable %s in app %s', $envVar->name, $app->getName())); + continue; + } + break; + } + if ($value !== false) { + $metadata['variables']['environment'][$envVar->name]['value'] = $value; + } + } else { + $this->stdErr->writeln(sprintf(' Warning: cannot save value for sensitive environment-level variable %s', $envVar->name)); + $this->stdErr->writeln(' Use --include-sensitive-values to try to fetch this via SSH'); + } + } + } + } + + foreach ($supportedServices as $serviceName => $type) { + $this->stdErr->writeln(''); + $this->stdErr->writeln('Archiving service ' . $serviceName . ''); + + // Find a relationship to the service. + $relationshipName = false; + $appName = false; + foreach ($apps as $app) { + $relationshipName = $this->getRelationshipNameForService($app, $serviceName); + if ($relationshipName !== false) { + $appName = $app->getName(); + break; + } + } + if ($relationshipName === false || $appName === false) { + $this->stdErr->writeln('No app defines a relationship to the service %s (%s)'); + + return 1; + } + if ($type === 'mysql' || $type === 'pgsql') { + // Get a list of schemas for this relationship. + $schemas = $this->getSchemas($deployment->getService($serviceName), $relationshipName); + + mkdir($archiveDir . '/services/' . $serviceName, 0755, true); + + if (count($schemas) === 0) { + $schemas = [null]; + } + + foreach ($schemas as $schema) { + $filename = $appName . '--' . $relationshipName; + $args = [ + '--directory' => $archiveDir . '/services/' . $serviceName, + '--project' => $this->getSelectedProject()->id, + '--environment' => $this->getSelectedEnvironment()->id, + '--app' => $appName, + '--relationship' => $relationshipName, + '--yes' => true, + // gzip is not enabled because the archive will be gzipped anyway + ]; + if ($schema !== null) { + $args['--schema'] = $schema; + $filename .= '--' . $schema; + } + $filename .= '.sql'; + $args['--file'] = $filename; + $exitCode = $this->runOtherCommand('db:dump', $args); + if ($exitCode !== 0) { + + return $exitCode; + } + $metadata['services'][$serviceName]['_type'] = $type; + $metadata['services'][$serviceName]['dumps'][] = [ + 'filename' => 'services/' . $serviceName . '/' . $filename, + 'app' => $appName, + 'schema' => $schema, + 'relationship' => $relationshipName, + ]; + } + } + if ($type === 'mongodb') { + $args = [ + '--directory' => $archiveDir . '/services/' . $serviceName, + '--project' => $this->getSelectedProject()->id, + '--environment' => $this->getSelectedEnvironment()->id, + '--app' => $appName, + '--relationship' => $relationshipName, + '--yes' => true, + '--stdout' => true, + // gzip is not enabled because the archive will be gzipped anyway + ]; + $filename = $appName . '--' . $relationshipName . '.bson'; + // @todo dump directly to a file without the buffer + $buffer = new BufferedOutput(); + $exitCode = $this->runOtherCommand('service:mongo:dump', $args, $buffer); + if ($exitCode !== 0) { + + return $exitCode; + } + (new Filesystem())->dumpFile($filename, $buffer->fetch()); + $metadata['services'][$serviceName]['_type'] = $type; + $metadata['services'][$serviceName]['dumps'][] = [ + 'filename' => 'services/' . $serviceName . '/' . $filename, + 'app' => $appName, + 'relationship' => $relationshipName, + ]; + } + } + + if ($hasMounts && !$excludeMounts) { + /** @var \Platformsh\Cli\Service\Mount $mountService */ + $mountService = $this->getService('mount'); + /** @var \Platformsh\Cli\Service\Rsync $rsync */ + $rsync = $this->getService('rsync'); + $rsyncOptions = [ + 'verbose' => $output->isVeryVerbose(), + 'quiet' => !$output->isVerbose(), + ]; + foreach ($apps as $app) { + $sourcePaths = []; + $mounts = $mountService->normalizeMounts($app->getMounts()); + foreach ($mounts as $path => $mount) { + if (isset($metadata['mounts'][$path])) { + continue; + } + if ($mount['source'] === 'local' && isset($mount['source_path'])) { + if (isset($sourcePaths[$mount['source_path']])) { + continue; + } + $sourcePaths[$mount['source_path']] = true; + } + $this->stdErr->writeln(''); + $this->stdErr->writeln('Copying from mount ' . $path . ''); + $destination = $archiveDir . '/mounts/' . trim($path, '/'); + mkdir($destination, 0755, true); + $rsync->syncDown($app->getSshUrl(), ltrim($path, '/'), $destination, $rsyncOptions); + $metadata['mounts'][$path] = [ + 'app' => $app->getName(), + 'path' => 'mounts/' . trim($path, '/'), + ]; + } + } + } + + $this->stdErr->writeln(''); + $this->stdErr->writeln('Writing metadata'); + (new Filesystem())->dumpFile($archiveDir . '/archive.json', json_encode($metadata, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE)); + + $this->stdErr->writeln(''); + $this->stdErr->writeln('Packing and compressing archive'); + $fs->archiveDir($tmpDir, $archiveFilename); + + $this->stdErr->writeln(''); + $this->stdErr->writeln('Archive: ' . $archiveFilename . ''); + + if (($absolute = realpath($archiveFilename)) && ($projectRoot = $this->getProjectRoot()) && strpos($absolute, $projectRoot) === 0) { + /** @var \Platformsh\Cli\Service\Git $git */ + $git = $this->getService('git'); + if (!$git->checkIgnore($absolute, $projectRoot)) { + $this->stdErr->writeln(''); + $this->stdErr->writeln('Warning: the archive file is not excluded by Git'); + if ($pos = strrpos($archiveFilename, '.tar.gz')) { + $extension = substr($archiveFilename, $pos); + $this->stdErr->writeln(' You should probably exclude these files using .gitignore:'); + $this->stdErr->writeln(' *' . $extension); + } + } + } + + return 0; + } + + /** + * @param \Platformsh\Cli\Model\RemoteContainer\App $app + * @param string $serviceName + * + * @return string|false + */ + private function getRelationshipNameForService(App $app, $serviceName) + { + $config = $app->getConfig()->getNormalized(); + $relationships = isset($config['relationships']) ? $config['relationships'] : []; + foreach ($relationships as $relationshipName => $relationshipTarget) { + list($targetService,) = explode(':', $relationshipTarget); + if ($targetService === $serviceName) { + return $relationshipName; + } + } + + return false; + } + + /** + * Get a list of schemas configured for the service. + * + * @param \Platformsh\Client\Model\Deployment\Service $service + * @param string $relationshipName + * + * @return array + */ + private function getSchemas(Service $service, $relationshipName) + { + if (empty($service->configuration['schemas'])) { + return []; + } + + $schemas = $service->configuration['schemas']; + + // Filter the list by the schemas accessible from the endpoint. + if (isset($service->configuration['endpoints'][$relationshipName]['privileges'])) { + $schemas = array_intersect( + $schemas, + array_keys($service->configuration['endpoints'][$relationshipName]['privileges']) + ); + } + + return $schemas; + } + + /** + * @param string $sshUrl + * @param string $varName + * @param bool $is_json + * + * @return mixed + */ + private function fetchSensitiveValue($sshUrl, $varName, $is_json) + { + /** @var \Platformsh\Cli\Service\RemoteEnvVars $remoteEnvVars */ + $remoteEnvVars = $this->getService('remote_env_vars'); + if (substr($varName, 0, 4) === 'env:') { + return $remoteEnvVars->getEnvVar(substr($varName, 4), $sshUrl, true); + } + + $variables = $remoteEnvVars->getArrayEnvVar('VARIABLES', $sshUrl); + if (array_key_exists($varName, $variables)) { + return $is_json ? json_encode($variables[$varName]) : $variables[$varName]; + } + + throw new \RuntimeException('Variable not found: ' . $varName); + } +} diff --git a/src/Command/Archive/ArchiveImportCommand.php b/src/Command/Archive/ArchiveImportCommand.php new file mode 100644 index 000000000..b9e9000b2 --- /dev/null +++ b/src/Command/Archive/ArchiveImportCommand.php @@ -0,0 +1,366 @@ +setName('archive:import') + ->setDescription('Import an archive') + ->addArgument('file', InputArgument::REQUIRED, 'The archive filename') + ->addOption('include-variables', null, InputOption::VALUE_NONE, 'Import environment-level variables') + ->addOption('include-project-variables', null, InputOption::VALUE_NONE, 'Import project-level variables'); + $this->addProjectOption(); + $this->addEnvironmentOption(); + } + + /** + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $this->validateInput($input); + $filename = (string) $input->getArgument('file'); + if (!file_exists($filename)) { + $this->stdErr->writeln(sprintf('File not found: %s', $filename)); + + return 1; + } + if (!is_readable($filename)) { + $this->stdErr->writeln(sprintf('Not readable: %s', $filename)); + + return 1; + } + if (substr($filename, -7) !== '.tar.gz') { + $this->stdErr->writeln(sprintf('Unexpected format: %s (expected: .tar.gz)', $filename)); + + return 1; + } + + /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ + $questionHelper = $this->getService('question_helper'); + /** @var \Platformsh\Cli\Service\Filesystem $fs */ + $fs = $this->getService('fs'); + + $environment = $this->getSelectedEnvironment(); + + $this->stdErr->writeln(sprintf( + 'Importing into environment %s on the project %s', + $this->api()->getEnvironmentLabel($environment), + $this->api()->getProjectLabel($this->getSelectedProject()) + )); + + $this->stdErr->writeln(''); + $this->stdErr->writeln('Warning:'); + $this->stdErr->writeln(sprintf('Any data on %s may be deleted. This action cannot be undone.', $this->api()->getEnvironmentLabel($environment, 'comment'))); + $this->stdErr->writeln(''); + $this->stdErr->writeln('also please note:'); + $this->stdErr->writeln('Currently, data is imported, but no existing data is deleted (so you may see inconsistent results).'); + $this->stdErr->writeln(''); + + if (!$questionHelper->confirm('Are you sure you want to continue?', false)) { + return 1; + } + + $tmpDir = $fs->makeTempDir('archive-'); + $fs->extractArchive($filename, $tmpDir); + + $this->debug('Extracted archive to: ' . $tmpDir); + + foreach ((array) scandir($tmpDir) as $filename) { + if (!empty($filename) && $filename[0] !== '.' && is_dir($tmpDir . '/' . $filename)) { + $archiveId = $filename; + break; + } + } + if (empty($archiveId)) { + $this->stdErr->writeln('Error: Failed to identify archive subdirectory'); + + return 1; + } + $archiveDir = $tmpDir . '/' . $archiveId; + + $metadata = file_get_contents($archiveDir . '/archive.json'); + if ($metadata === false || !($metadata = json_decode($metadata, true))) { + $this->stdErr->writeln('Error: Failed to read archive metadata'); + + return 1; + } + + if ($metadata['version'] < ArchiveExportCommand::ARCHIVE_VERSION) { + $this->stdErr->writeln(''); + $this->stdErr->writeln('Error: The archive is outdated so it cannot be imported.'); + $this->stdErr->writeln(sprintf(' Archive version: %s (from CLI version: %s)', $metadata['version'], $metadata['cli_version'])); + $this->stdErr->writeln(sprintf(' Current version: %s (CLI version: %s)', ArchiveExportCommand::ARCHIVE_VERSION, $this->config()->getVersion())); + + return 1; + } + + $activities = []; + + if (!empty($metadata['variables']['environment']) + && ($input->getOption('include-variables') || ($input->isInteractive() && $questionHelper->confirm("\nImport environment-level variables?", false)))) { + $this->stdErr->writeln(''); + $this->stdErr->writeln('Importing environment-level variables'); + + foreach ($metadata['variables']['environment'] as $name => $var) { + $this->stdErr->writeln(' Processing variable ' . $name . ''); + if (!array_key_exists('value', $var)) { + if ($var['is_sensitive']) { + $this->stdErr->writeln(' Skipping sensitive variable ' . $name . ''); + continue; + } + $this->stdErr->writeln(' Error: no variable value found.'); + continue; + } + if ($current = $environment->getVariable($name)) { + $this->stdErr->writeln(' The variable already exists.'); + if ($this->variablesAreEqual($current->getProperties(), $var)) { + $this->stdErr->writeln(' No change required.'); + continue; + } + + if ($questionHelper->confirm(' Do you want to update it?')) { + $result = $current->update($var); + } else { + continue; + } + } else { + $result = $this->createEnvironmentVariableFromProperties($var, $environment); + + } + $this->stdErr->writeln(' Done'); + $activities = array_merge($activities, $result->getActivities()); + } + } + + if (!empty($metadata['variables']['project']) + && ($input->getOption('include-project-variables') || ($input->isInteractive() && $questionHelper->confirm("\nImport project-level variables?", false)))) { + $project = $this->getSelectedProject(); + + $this->stdErr->writeln(''); + $this->stdErr->writeln('Importing project-level variables'); + + foreach ($metadata['variables']['project'] as $name => $var) { + $this->stdErr->writeln(' Processing variable ' . $name . ''); + if (!array_key_exists('value', $var)) { + if ($var['is_sensitive']) { + $this->stdErr->writeln(' Skipping sensitive variable ' . $name . ''); + continue; + } + $this->stdErr->writeln(' Error: no variable value found.'); + continue; + } + if ($current = $project->getVariable($name)) { + $this->stdErr->writeln(' The variable already exists.'); + if ($this->variablesAreEqual($current->getProperties(), $var)) { + $this->stdErr->writeln(' No change required.'); + continue; + } + + if (!$questionHelper->confirm(' Do you want to update it?')) { + return 1; + } + + $result = $current->update($var); + } else { + $result = $this->createProjectVariableFromProperties($var, $project); + } + $this->stdErr->writeln(' Done'); + $activities = array_merge($activities, $result->getActivities()); + } + } + + $success = true; + + if (!empty($metadata['services'])) { + foreach ($metadata['services'] as $serviceName => $serviceInfo) { + if (in_array($serviceInfo['_type'], ['mysql', 'pgsql'])) { + $this->stdErr->writeln(''); + $this->stdErr->writeln('Importing data for service ' . $serviceName . ''); + + foreach ($serviceInfo['dumps'] as $dumpInfo) { + if (!empty($dumpInfo['schema'])) { + $this->stdErr->writeln('Processing schema: ' . $dumpInfo['schema'] . ''); + } + $this->importDatabaseDump($archiveDir, $dumpInfo); + } + } elseif ($serviceInfo['_type'] === 'mongodb') { + $this->stdErr->writeln(''); + $this->stdErr->writeln('Importing data for service ' . $serviceName . ''); + + foreach ($serviceInfo['dumps'] as $dumpInfo) { + $this->importMongoDump($archiveDir, $dumpInfo); + } + } + } + } + + if (!empty($metadata['mounts'])) { + /** @var \Platformsh\Cli\Service\Rsync $rsync */ + $rsync = $this->getService('rsync'); + $rsyncOptions = [ + 'verbose' => $output->isVeryVerbose(), + 'quiet' => !$output->isVerbose(), + ]; + $deployment = $environment->getCurrentDeployment(); + foreach ($metadata['mounts'] as $path => $info) { + $this->stdErr->writeln(''); + $this->stdErr->writeln('Importing files to mount ' . $path . ''); + $app = new App($deployment->getWebApp($info['app']), $environment); + $rsync->syncUp($app->getSshUrl(), $archiveDir . '/' . $info['path'], $path, $rsyncOptions); + } + } + + if (!empty($activities) && $this->shouldWait($input)) { + /** @var \Platformsh\Cli\Service\ActivityMonitor $activityMonitor */ + $activityMonitor = $this->getService('activity_monitor'); + $success = $activityMonitor->waitMultiple($activities, $this->getSelectedProject()); + } + + return $success ? 0 : 1; + } + + /** + * @param array $properties + * @param \Platformsh\Client\Model\Environment $environment + * + * @return \Platformsh\Client\Model\Result + */ + private function createEnvironmentVariableFromProperties(array $properties, Environment $environment) + { + $var = $properties; + unset($var['project'], $var['environment'], $var['created_at'], $var['updated_at'], $var['id'], $var['attributes'], $var['inherited']); + + return Variable::create($var, $environment->getLink('#manage-variables'), $this->api()->getHttpClient()); + } + + /** + * @param array $properties + * @param \Platformsh\Client\Model\Project $project + * + * @return \Platformsh\Client\Model\Result + */ + private function createProjectVariableFromProperties(array $properties, Project $project) + { + $var = $properties; + unset($var['project'], $var['environment'], $var['created_at'], $var['updated_at'], $var['id'], $var['attributes']); + + return ProjectLevelVariable::create($var, $project->getLink('#manage-variables'), $this->api()->getHttpClient()); + } + + + /** + * Checks if two variables are equal. + * + * @param array $var1Properties + * @param array $var2Properties + * + * @return bool + */ + private function variablesAreEqual(array $var1Properties, $var2Properties) + { + $keys = [ + 'value', + 'is_json', + 'is_enabled', + 'is_sensitive', + 'is_inheritable', + 'visible_build', + 'visible_runtime', + ]; + foreach ($keys as $key) { + if (array_key_exists($key, $var1Properties) + && (!array_key_exists($key, $var2Properties) || $var1Properties[$key] !== $var2Properties[$key])) { + return false; + } + } + + return true; + } + + /** + * @param string $commandName + * @param array $args + * @param string $suffix + */ + private function runCliCommandViaShell($commandName, array $args = [], $suffix = '') + { + $args = array_merge([ + (new PhpExecutableFinder())->find(false) ?: 'php', + $GLOBALS['argv'][0], + $commandName, + ], $args); + $command = implode(' ', array_map('escapeshellarg', $args)) . $suffix; + + /** @var \Platformsh\Cli\Service\Shell $shell */ + $shell = $this->getService('shell'); + $exitCode = $shell->executeSimple($command); + if ($exitCode !== 0) { + throw new \RuntimeException("Command failed with exit code $exitCode:" . $command); + } + } + + /** + * @param string $archiveDir + * @param array $dumpInfo + */ + private function importDatabaseDump($archiveDir, array $dumpInfo) + { + if (!file_exists($archiveDir . '/' . $dumpInfo['filename'])) { + throw new \RuntimeException('Dump file not found: ' . $archiveDir . '/' . $dumpInfo['filename']); + } + $args = [ + '--project=' . $this->getSelectedProject()->id, + '--environment=' . $this->getSelectedEnvironment()->id, + '--app=' . $dumpInfo['app'], + '--relationship=' . $dumpInfo['relationship'], + '--yes', + ]; + if (!empty($dumpInfo['schema'])) { + $args[] = '--schema=' . $dumpInfo['schema']; + } + if ($this->stdErr->isVerbose()) { + $args[] = '--verbose'; + } + $this->runCliCommandViaShell('db:sql', $args, ' < ' . escapeshellarg($archiveDir . '/' . $dumpInfo['filename'])); + } + + /** + * @param string $archiveDir + * @param array $dumpInfo + */ + private function importMongoDump($archiveDir, array $dumpInfo) + { + if (!file_exists($archiveDir . '/' . $dumpInfo['filename'])) { + throw new \RuntimeException('Dump file not found: ' . $archiveDir . '/' . $dumpInfo['filename']); + } + $args = [ + '--project=' . $this->getSelectedProject()->id, + '--environment=' . $this->getSelectedEnvironment()->id, + '--app=' . $dumpInfo['app'], + '--relationship=' . $dumpInfo['relationship'], + '--yes', + ]; + if ($this->stdErr->isVerbose()) { + $args[] = '--verbose'; + } + $this->runCliCommandViaShell('service:mongo:restore', $args, ' < ' . escapeshellarg($archiveDir . '/' . $dumpInfo['filename'])); + } +} diff --git a/src/Command/CompletionCommand.php b/src/Command/CompletionCommand.php index 2a8bd5076..e71fda619 100644 --- a/src/Command/CompletionCommand.php +++ b/src/Command/CompletionCommand.php @@ -146,6 +146,11 @@ protected function runCompletion() Completion::TYPE_OPTION, [$this, 'getAppNames'] ), + new Completion\ShellPathCompletion( + 'archive:import', + 'file', + Completion::TYPE_ARGUMENT + ), new Completion\ShellPathCompletion( Completion::ALL_COMMANDS, 'identity-file', diff --git a/src/Service/Filesystem.php b/src/Service/Filesystem.php index cc215b8e0..bc6bb13b4 100644 --- a/src/Service/Filesystem.php +++ b/src/Service/Filesystem.php @@ -503,10 +503,52 @@ public function validateDirectory($directory, $writable = false) { if (!is_dir($directory)) { throw new \InvalidArgumentException(sprintf('Directory not found: %s', $directory)); - } elseif (!is_readable($directory)) { + } + elseif (!is_readable($directory)) { throw new \InvalidArgumentException(sprintf('Directory not readable: %s', $directory)); - } elseif ($writable && !is_writable($directory)) { + } + elseif ($writable && !is_writable($directory)) { throw new \InvalidArgumentException(sprintf('Directory not writable: %s', $directory)); } } + + /** + * Makes a temporary directory. + * + * @param string $prefix + * @param bool $autoCleanup + * + * @return string + */ + public function makeTempDir($prefix = 'tmp-', $autoCleanup = true) + { + $tmpName = tempnam(sys_get_temp_dir(), $prefix); + if ($tmpName !== false && is_file($tmpName)) { + $this->fs->remove($tmpName); + } + if (!$tmpName || !mkdir($tmpName)) { + throw new \RuntimeException(sprintf('Failed to create temporary directory: %s', $tmpName)); + } + + if ($autoCleanup) { + register_shutdown_function(function () use ($tmpName) { + if (file_exists($tmpName)) { + $this->fs->remove($tmpName); + } + }); + if (function_exists('pcntl_signal')) { + declare(ticks = 1); + /** @noinspection PhpComposerExtensionStubsInspection */ + pcntl_signal(SIGINT, function () use ($tmpName) { + if (file_exists($tmpName)) { + $this->fs->remove($tmpName); + } + exit(130); + }); + } + + } + + return $tmpName; + } } diff --git a/src/Service/RemoteEnvVars.php b/src/Service/RemoteEnvVars.php index d34b493b7..6a71cafe2 100644 --- a/src/Service/RemoteEnvVars.php +++ b/src/Service/RemoteEnvVars.php @@ -38,15 +38,16 @@ public function __construct(Ssh $ssh, CacheProvider $cache, Shell $shellHelper, * @param string $sshUrl The SSH URL to the application. * @param bool $refresh Whether to refresh the cache. * @param int $ttl The cache lifetime of the result. + * @param bool $prefix Whether to prepend the service.env_prefix. * * @throws \Symfony\Component\Process\Exception\RuntimeException * If the SSH command fails. * * @return string The environment variable or an empty string. */ - public function getEnvVar($variable, $sshUrl, $refresh = false, $ttl = 3600) + public function getEnvVar($variable, $sshUrl, $refresh = false, $ttl = 3600, $prefix = true) { - $varName = $this->config->get('service.env_prefix') . $variable; + $varName = $prefix ? $this->config->get('service.env_prefix') . $variable : $variable; $cacheKey = 'env-' . $sshUrl . '-' . $varName; $cached = $this->cache->fetch($cacheKey); if ($refresh || $cached === false) { @@ -69,12 +70,14 @@ public function getEnvVar($variable, $sshUrl, $refresh = false, $ttl = 3600) * @param string $variable * @param string $sshUrl * @param bool $refresh + * @param int $ttl + * @param bool $prefix * * @return array */ - public function getArrayEnvVar($variable, $sshUrl, $refresh = false) + public function getArrayEnvVar($variable, $sshUrl, $refresh = false, $ttl = 3600, $prefix = true) { - $value = $this->getEnvVar($variable, $sshUrl, $refresh); + $value = $this->getEnvVar($variable, $sshUrl, $refresh, $ttl, $prefix); return json_decode(base64_decode($value), true) ?: []; }