Repository navigation
DXBE-8: Add source:link, source:unlink, source:cms:config:pull and source:cms:config:push commands
#2035
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
DXBE-8: Add source:link, source:unlink, source:cms:config:pull and source:cms:config:push commands
#2035
Changes from all commits
730e2e0
3fcb660
d385b3e
45544f9
4768081
e44a7a1
fe9bc2e
26851be
b0e7df2
e178a96
0f1279e
1ed4383
687b50f
4c0ca17
8c1a0a3
ab407a4
38b6d37
667185a
48a98cb
fcdf1da
da9dad9
7439d4c
fc834f4
50dd3d6
b270feb
6fad178
eac4094
a7a982b
32ca28b
270dabb
0891863
56d53b4
f876aa8
ba0d14b
94238d5
96d9d5f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace Acquia\Cli\Command\Source; | ||
|
|
||
| use Acquia\Cli\Exception\AcquiaCliException; | ||
| use Symfony\Component\Console\Input\InputOption; | ||
|
|
||
| /** | ||
| * Base class for the commands that move configuration between a Source site | ||
| * and the working copy's .acquia/config directory. | ||
| */ | ||
| abstract class ConfigCommandBase extends SourceCommandBase | ||
| { | ||
| protected function configure(): void | ||
| { | ||
| $this | ||
| ->addOption('site', null, InputOption::VALUE_REQUIRED, 'The Source site ID (defaults to the one recorded by acli source:link)') | ||
| ->addOption('format', null, InputOption::VALUE_REQUIRED, 'The output format (txt or json)', 'txt'); | ||
| } | ||
|
|
||
| /** | ||
| * Whether --format=json was passed: the outcome is then the only stdout. | ||
| * | ||
| * @throws \Acquia\Cli\Exception\AcquiaCliException | ||
| */ | ||
| protected function outputsJson(): bool | ||
| { | ||
| $format = $this->input->getOption('format'); | ||
| if (!in_array($format, ['txt', 'json'], true)) { | ||
| throw new AcquiaCliException('Unknown output format "{format}". Use txt or json.', ['format' => $format]); | ||
| } | ||
| return $format === 'json'; | ||
| } | ||
|
|
||
| /** | ||
| * @throws \Acquia\Cli\Exception\AcquiaCliException | ||
| */ | ||
| protected function determineSourceSite(string $workingCopyDir): string | ||
| { | ||
| $siteId = $this->input->getOption('site') ?? $this->sourceDatastore($workingCopyDir)->get('source_site_id'); | ||
| if (!$siteId) { | ||
| throw new AcquiaCliException('Could not determine the Source site. Pass --site=<sourceSiteId> or run acli source:link first.'); | ||
| } | ||
| return $siteId; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace Acquia\Cli\Command\Source; | ||
|
|
||
| use Acquia\Cli\Attribute\RequireAuth; | ||
| use Acquia\Cli\Helpers\SourceConfigDocument; | ||
| use Symfony\Component\Console\Attribute\AsCommand; | ||
| use Symfony\Component\Console\Command\Command; | ||
| use Symfony\Component\Console\Input\InputInterface; | ||
| use Symfony\Component\Console\Output\OutputInterface; | ||
|
|
||
| #[RequireAuth] | ||
| #[AsCommand(name: 'source:cms:config:pull', description: "Export a Source site's configuration to .acquia/config")] | ||
| final class ConfigPullCommand extends ConfigCommandBase | ||
| { | ||
| protected function execute(InputInterface $input, OutputInterface $output): int | ||
| { | ||
| $json = $this->outputsJson(); | ||
| $root = $this->workingCopyDir(); | ||
| $siteId = $this->determineSourceSite($root); | ||
| $response = $this->cloudApiClientService->getClient()->request('get', "/source-sites/$siteId/config"); | ||
| $files = SourceConfigDocument::toFiles($response->configuration); | ||
|
|
||
| // Write everything into a sibling directory first, so that a failure | ||
| // halfway leaves the existing .acquia/config untouched. | ||
| $configDir = "$root/.acquia/config"; | ||
| $tmpDir = "$configDir.tmp"; | ||
| $filesystem = $this->localMachineHelper->getFilesystem(); | ||
| $filesystem->remove($tmpDir); | ||
| try { | ||
| foreach ($files as $path => $contents) { | ||
| $filesystem->dumpFile("$tmpDir/$path", $contents); | ||
| } | ||
| $filesystem->remove($configDir); | ||
| $filesystem->rename($tmpDir, $configDir); | ||
| } finally { | ||
| $filesystem->remove($tmpDir); | ||
| } | ||
| if ($json) { | ||
| $output->writeln(json_encode(['directory' => $configDir, 'files' => array_keys($files)], JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT)); | ||
| } else { | ||
| $this->io->success(sprintf('Exported %d configuration files from Source site %s to %s', count($files), $siteId, $configDir)); | ||
| } | ||
|
|
||
| return Command::SUCCESS; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,169 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace Acquia\Cli\Command\Source; | ||
|
|
||
| use Acquia\Cli\Attribute\RequireAuth; | ||
| use Acquia\Cli\Exception\AcquiaCliException; | ||
| use Acquia\Cli\Helpers\LoopHelper; | ||
| use Acquia\Cli\Helpers\SourceConfigDocument; | ||
| use AcquiaCloudApi\Connector\Client; | ||
| use AcquiaCloudApi\Exception\ApiErrorException; | ||
| use Symfony\Component\Console\Attribute\AsCommand; | ||
| use Symfony\Component\Console\Command\Command; | ||
| use Symfony\Component\Console\Input\InputInterface; | ||
| use Symfony\Component\Console\Input\InputOption; | ||
| use Symfony\Component\Console\Output\NullOutput; | ||
| use Symfony\Component\Console\Output\OutputInterface; | ||
| use Symfony\Component\Console\Style\SymfonyStyle; | ||
| use Throwable; | ||
|
|
||
| #[RequireAuth] | ||
| #[AsCommand(name: 'source:cms:config:push', description: 'Import .acquia/config into a Source site')] | ||
| final class ConfigPushCommand extends ConfigCommandBase | ||
| { | ||
| /** | ||
| * --status's exit code while the site's latest import has not finished; | ||
| * distinct from Command::SUCCESS/FAILURE so a CI/CD run can poll on it. | ||
| */ | ||
| private const EXIT_STILL_RUNNING = 2; | ||
|
|
||
| protected function configure(): void | ||
| { | ||
| parent::configure(); | ||
| $this->addOption('yes', 'y', InputOption::VALUE_NONE, 'Skip the confirmation prompt (required when non-interactive)'); | ||
| $this->addOption('status', null, InputOption::VALUE_NONE, "Report the site's latest import instead of starting one"); | ||
| } | ||
|
|
||
| protected function execute(InputInterface $input, OutputInterface $output): int | ||
| { | ||
| $json = $this->outputsJson(); | ||
| $root = $this->workingCopyDir(); | ||
| $siteId = $this->determineSourceSite($root); | ||
| $client = $this->cloudApiClientService->getClient(); | ||
|
|
||
| if ($input->getOption('status')) { | ||
| $import = $client->request('get', "/source-sites/$siteId/config/import"); | ||
| return $this->reportResult($input, $output, $json, $import, fn (object $import): int => $this->reportStatus($siteId, $import)); | ||
| } | ||
|
|
||
| $configDir = "$root/.acquia/config"; | ||
| if (!is_dir($configDir)) { | ||
| throw new AcquiaCliException('{dir} does not exist. Run acli source:cms:config:pull first.', ['dir' => $configDir]); | ||
| } | ||
| $document = SourceConfigDocument::fromDirectory($configDir); | ||
|
|
||
| if (!$input->getOption('yes')) { | ||
| if (!$input->isInteractive()) { | ||
| throw new AcquiaCliException('Pass --yes to push without confirmation when running non-interactively.'); | ||
| } | ||
| if (!$this->io->confirm("Replace the configuration of Source site $siteId with the contents of $configDir? The site will be offline while the import runs, and its database is backed up first.")) { | ||
| return Command::SUCCESS; | ||
| } | ||
| } | ||
|
|
||
| try { | ||
| $client->request('put', "/source-sites/$siteId/config", ['json' => ['source' => 'request', 'configuration' => $document]]); | ||
| } catch (ApiErrorException $e) { | ||
| // 409: the site is already importing (or exporting) configuration. | ||
| if ($e->getResponseBody()->error === 'conflict') { | ||
| throw new AcquiaCliException('A configuration sync is already running for Source site {site}. Wait for it to finish, then push again.', ['site' => $siteId]); | ||
| } | ||
| throw $e; | ||
| } | ||
|
|
||
| if ($json) { | ||
| // The import resource is the only stdout: silence the spinner and the report. | ||
| $this->output = new NullOutput(); | ||
| $this->io = new SymfonyStyle($input, $this->output); | ||
| } | ||
| $import = $this->waitForImport($client, $siteId); | ||
| return $this->reportResult($input, $output, $json, $import, fn (object $import): int => $this->reportImport($siteId, $import)); | ||
| } | ||
|
|
||
| /** | ||
| * Reports $import via $report, printing it as JSON afterward when $json. | ||
| * | ||
| * @param callable(object): int $report | ||
| */ | ||
| private function reportResult(InputInterface $input, OutputInterface $output, bool $json, object $import, callable $report): int | ||
| { | ||
| if ($json) { | ||
| $this->output = new NullOutput(); | ||
| $this->io = new SymfonyStyle($input, $this->output); | ||
| } | ||
| $exitCode = $report($import); | ||
| if ($json) { | ||
| $output->writeln(json_encode($import, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT)); | ||
| } | ||
| return $exitCode; | ||
| } | ||
|
|
||
| /** | ||
| * Polls the site's latest import until it is no longer running. | ||
| */ | ||
| private function waitForImport(Client $client, string $siteId): object | ||
| { | ||
| $import = null; | ||
| $error = null; | ||
| $poll = static function () use ($client, $siteId, &$import, &$error): bool { | ||
| try { | ||
| $import = $client->request('get', "/source-sites/$siteId/config/import"); | ||
| return $import->status !== 'running'; | ||
| } catch (Throwable $e) { | ||
| // An exception escaping the loop would leave its timers behind. | ||
| $error = $e; | ||
| return true; | ||
| } | ||
| }; | ||
| // getLoopy() requires a done callback; the outcome is read from $import | ||
| // and $error above once it returns, so there is nothing to do here. | ||
| LoopHelper::getLoopy($this->output, $this->io, 'Importing configuration', $poll, static function (): void { | ||
| }); | ||
|
phenaproxima marked this conversation as resolved.
|
||
| if ($error !== null) { | ||
| throw new AcquiaCliException('The import was accepted but its outcome is unknown ({reason}). Check the site before pushing again. The import may still finish, and the status it reports may then be that of a later import.', ['reason' => $error->getMessage()]); | ||
| } | ||
| return $import; | ||
| } | ||
|
|
||
| /** | ||
| * Reports the site's latest import for --status, without waiting for it to finish. | ||
| */ | ||
| private function reportStatus(string $siteId, object $import): int | ||
| { | ||
| if ($import->status === 'running') { | ||
| $this->io->writeln("Source site $siteId's latest import started at $import->started_at and is still running."); | ||
| return self::EXIT_STILL_RUNNING; | ||
| } | ||
| $this->io->writeln("Source site $siteId's latest import started at $import->started_at."); | ||
| return $this->reportImport($siteId, $import); | ||
| } | ||
|
|
||
| private function reportImport(string $siteId, object $import): int | ||
| { | ||
| switch ($import->status) { | ||
| case 'succeeded': | ||
| $this->io->success("Imported .acquia/config into Source site $siteId."); | ||
| return Command::SUCCESS; | ||
|
|
||
| case 'refused': | ||
| $this->io->error("Source site $siteId refused the configuration; nothing was imported:"); | ||
| foreach ($import->violations ?? [] as $violation) { | ||
| $location = match (true) { | ||
| isset($violation->collection) => "$violation->collection: $violation->config", | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🤔 Will the default collection be NULL, or an empty string (which it is actual value)? If it's the latter, then this arm will always execute.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good Q, currently in flux; see https://acquia.atlassian.net/browse/DXBE-91 + https://acquia.atlassian.net/browse/DXBE-110. Holding off responding to this until that's resolved. This is merge-blocking. |
||
| isset($violation->config) => $violation->config, | ||
| default => 'document', | ||
| }; | ||
| $this->io->writeln(sprintf(' - %s [%s]: %s', $location, $violation->code, $violation->message)); | ||
| } | ||
| return Command::FAILURE; | ||
|
|
||
| case 'failed': | ||
| $this->io->error("The import into Source site $siteId failed; the site was rolled back to its previous configuration."); | ||
| return Command::FAILURE; | ||
| } | ||
| // Only the 45-minute watchdog in LoopHelper can leave the status at "running". | ||
| throw new AcquiaCliException('The import was accepted but its outcome is unknown (status {status}). Check the site before pushing again. The import may still finish, and the status it reports may then be that of a later import.', ['status' => $import->status]); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace Acquia\Cli\Command\Source; | ||
|
|
||
| use Acquia\Cli\Attribute\RequireAuth; | ||
| use Acquia\Cli\Exception\AcquiaCliException; | ||
| use Symfony\Component\Console\Attribute\AsCommand; | ||
| use Symfony\Component\Console\Command\Command; | ||
| use Symfony\Component\Console\Input\InputArgument; | ||
| use Symfony\Component\Console\Input\InputInterface; | ||
| use Symfony\Component\Console\Output\OutputInterface; | ||
|
|
||
| #[RequireAuth] | ||
| #[AsCommand(name: 'source:link', description: 'Associate your working copy with a Source site')] | ||
| final class LinkCommand extends SourceCommandBase | ||
|
phenaproxima marked this conversation as resolved.
|
||
| { | ||
| protected function configure(): void | ||
| { | ||
| $this->addArgument('sourceSiteId', InputArgument::OPTIONAL, 'The Source site ID'); | ||
| } | ||
|
|
||
| protected function execute(InputInterface $input, OutputInterface $output): int | ||
| { | ||
| $datastore = $this->sourceDatastore($this->workingCopyDir()); | ||
| $client = $this->cloudApiClientService->getClient(); | ||
| if ($siteId = $input->getArgument('sourceSiteId')) { | ||
| // The ID becomes a URL path segment. | ||
| if (!preg_match('/^[a-zA-Z0-9-]+$/', $siteId)) { | ||
| throw new AcquiaCliException('"{id}" is not a valid Source site ID: only letters, digits and hyphens are allowed.', ['id' => $siteId]); | ||
| } | ||
| $site = $client->request('get', "/source-sites/$siteId"); | ||
| } else { | ||
| if ($linked = $datastore->get('source_site_id')) { | ||
| $output->writeln("This working copy is already linked to Source site <options=bold>$linked</>. Run <options=bold>acli source:link <sourceSiteId></> to link it to another site."); | ||
| return 1; | ||
| } | ||
| if (!$input->isInteractive()) { | ||
| throw new AcquiaCliException('Pass the Source site ID as the sourceSiteId argument when running non-interactively.'); | ||
| } | ||
| $sites = $client->request('get', '/source-sites'); | ||
| if (!$sites) { | ||
| throw new AcquiaCliException('There are no Source sites to link to.'); | ||
| } | ||
| // Only the first page of /source-sites; add pagination when a subscription exceeds one page. | ||
| $site = $this->promptChooseFromObjectsOrArrays($sites, 'id', 'label', 'Select a Source site'); | ||
| } | ||
| $datastore->set('source_site_id', $site->id); | ||
| $this->io->success("Linked this working copy to Source site $site->label ($site->id) by writing to $datastore->filepath"); | ||
|
|
||
| return Command::SUCCESS; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace Acquia\Cli\Command\Source; | ||
|
|
||
| use Acquia\Cli\Command\CommandBase; | ||
| use Acquia\Cli\Config\AcquiaCliConfig; | ||
| use Acquia\Cli\DataStore\AcquiaCliDatastore; | ||
| use Acquia\Cli\Helpers\LocalMachineHelper; | ||
|
|
||
| /** | ||
| * Base class for commands that operate on a Source working copy. | ||
| * | ||
| * A working copy is the directory holding .acquia/config (usually a git | ||
| * repository); it has no docroot, so the project directory acli infers for | ||
| * Drupal projects does not apply. The commands may run from any directory | ||
| * inside the working copy, including from inside .acquia/config itself. | ||
| */ | ||
| abstract class SourceCommandBase extends CommandBase | ||
|
phenaproxima marked this conversation as resolved.
|
||
| { | ||
| /** | ||
| * The directory the search for the working copy starts from. | ||
| * | ||
| * Defaults to the current directory; tests set it by reflection so that | ||
| * they never have to chdir(). | ||
| */ | ||
| protected string $cwd; | ||
|
|
||
| protected function workingCopyDir(): string | ||
| { | ||
| return LocalMachineHelper::getSourceWorkingCopyDir($this->cwd ?? getcwd()); | ||
| } | ||
|
|
||
| /** | ||
| * The .acquia-cli.yml at the working copy root, where the Source site is recorded. | ||
| */ | ||
| protected function sourceDatastore(string $workingCopyDir): AcquiaCliDatastore | ||
| { | ||
| return new AcquiaCliDatastore($this->localMachineHelper, new AcquiaCliConfig(), $workingCopyDir . '/.acquia-cli.yml'); | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍 This correctly does not expect
exportto ever fail, because upon export, config is not validated: https://github.com/acquia/product-specs/pull/214/changes/bf4674abf6e766de9ac09567846b805614322866.