-
Notifications
You must be signed in to change notification settings - Fork 61
DXBE-20: Add source:config:push command with SAS API client #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?
Changes from all commits
730e2e0
3fcb660
d385b3e
45544f9
4768081
e44a7a1
fe9bc2e
26851be
b0e7df2
e178a96
0f1279e
1ed4383
687b50f
4c0ca17
8c1a0a3
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,171 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace Acquia\Cli\Command\Source; | ||
|
|
||
| use Acquia\Cli\ApiCredentialsInterface; | ||
| use Acquia\Cli\CloudApi\ClientService; | ||
| use Acquia\Cli\Command\CommandBase; | ||
| use Acquia\Cli\DataStore\AcquiaCliDatastore; | ||
| use Acquia\Cli\DataStore\CloudDataStore; | ||
| use Acquia\Cli\Exception\AcquiaCliException; | ||
| use Acquia\Cli\Helpers\LocalMachineHelper; | ||
| use Acquia\Cli\Helpers\LoopHelper; | ||
| use Acquia\Cli\Helpers\SshHelper; | ||
| use Acquia\Cli\Helpers\TelemetryHelper; | ||
| use Acquia\Cli\SasApi\SasClientService; | ||
| use Acquia\Cli\SasApi\SourceConfig; | ||
| use Psr\Log\LoggerInterface; | ||
| use SelfUpdate\SelfUpdateManager; | ||
| use Symfony\Component\Console\Command\Command; | ||
| use Symfony\Component\Console\Input\InputInterface; | ||
| use Symfony\Component\Console\Input\InputOption; | ||
| use Symfony\Component\Console\Output\OutputInterface; | ||
|
|
||
| /** | ||
| * Base class for Source config commands. | ||
| * | ||
| * Both directions are thin triggers over the SAS API: they ask SAS to run a | ||
| * `drush source:config:*` command on the environment. Push (import) reads | ||
| * config from the site's git repository into the CMS; pull (export) does the | ||
| * reverse and returns the exported config for the command to write to disk. | ||
| * No push payload travels through these commands. | ||
| */ | ||
| abstract class ConfigCommandBase extends CommandBase | ||
| { | ||
| public function __construct( | ||
| LocalMachineHelper $localMachineHelper, | ||
| CloudDataStore $datastoreCloud, | ||
| AcquiaCliDatastore $datastoreAcli, | ||
| ApiCredentialsInterface $cloudCredentials, | ||
| TelemetryHelper $telemetryHelper, | ||
| string $projectDir, | ||
| ClientService $cloudApiClientService, | ||
| SshHelper $sshHelper, | ||
| string $sshDir, | ||
| LoggerInterface $logger, | ||
| SelfUpdateManager $selfUpdateManager, | ||
| private readonly SasClientService $sasClient, | ||
| ) { | ||
| parent::__construct( | ||
| $localMachineHelper, | ||
| $datastoreCloud, | ||
| $datastoreAcli, | ||
| $cloudCredentials, | ||
| $telemetryHelper, | ||
| $projectDir, | ||
| $cloudApiClientService, | ||
| $sshHelper, | ||
| $sshDir, | ||
| $logger, | ||
| $selfUpdateManager, | ||
| ); | ||
| } | ||
|
|
||
| protected function configure(): void | ||
| { | ||
| $this | ||
| ->acceptEnvironmentId() | ||
| ->acceptSiteInstanceId() | ||
| ->addOption('force', 'f', InputOption::VALUE_NONE, 'Do not ask for confirmation'); | ||
| } | ||
|
|
||
| /** | ||
| * Trigger the config operation on the environment and return the decoded response. | ||
| */ | ||
| abstract protected function triggerOperation(SourceConfig $sourceConfig, string $environmentId): object; | ||
|
|
||
| /** | ||
| * A short verb phrase describing the operation, e.g. "Importing configuration". | ||
| */ | ||
| abstract protected function operationLabel(): string; | ||
|
|
||
| /** | ||
| * Handle a successfully completed operation. | ||
| * | ||
| * The default does nothing (push). Pull overrides this to fetch the | ||
| * exported payload and write it to disk. | ||
| * | ||
| * @infection-ignore-all ProtectedVisibility mutates this to private, which | ||
|
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. Nine |
||
| * is killed by the pull command overriding it, but Infection does not | ||
| * attribute the subclass test's coverage back to this base declaration. | ||
| */ | ||
| protected function onSuccess(SourceConfig $sourceConfig, string $operationId): int | ||
| { | ||
| $this->io->success($this->operationLabel() . ' completed successfully.'); | ||
|
|
||
| return Command::SUCCESS; | ||
| } | ||
|
|
||
| protected function execute(InputInterface $input, OutputInterface $output): int | ||
| { | ||
| $this->setDirAndRequireProjectCwd($input); | ||
|
|
||
| $siteInstance = $this->determineSiteInstance($input); | ||
|
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. The PR description says site identity is resolved from the git remote with no arguments needed.
All six |
||
| if ($siteInstance === null) { | ||
| throw new AcquiaCliException( | ||
| 'Could not determine a Source site instance. Run this command from a repository linked to an Acquia Cloud application, or pass --siteInstanceId.' | ||
| ); | ||
| } | ||
|
|
||
| $environment = $siteInstance->environment; | ||
|
|
||
| if (!$input->getOption('force')) { | ||
| $answer = $this->io->confirm( | ||
| sprintf('%s on the %s environment?', $this->operationLabel(), $environment->name), | ||
| false, | ||
| ); | ||
| if (!$answer) { | ||
|
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.
That fixes the no-op locally. Exiting non-zero when the remote operation fails needs upstream first — point 2 of my review on acquia/sites-aggregation-service#971, since without a notification to poll the command can't know it failed. |
||
| return Command::SUCCESS; | ||
| } | ||
| } | ||
|
|
||
| $sourceConfig = new SourceConfig($this->sasClient->getClient()); | ||
|
|
||
| $response = $this->triggerOperation($sourceConfig, $environment->id); | ||
| // @todo DXBE-20: Confirm the operation ID field name with the SAS team. | ||
| $operationId = $response->id ?? null; | ||
|
Comment on lines
+127
to
+128
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. acquia/sites-aggregation-service#971 returns Needs upstream first, then still changes here. Point 2 of my review on acquia/sites-aggregation-service#971 asks for |
||
| if (!is_string($operationId)) { | ||
| throw new AcquiaCliException('The SAS API response did not include an operation ID.'); | ||
| } | ||
|
|
||
| $this->io->writeln(sprintf('%s submitted (operation %s). Waiting for it to complete...', $this->operationLabel(), $operationId)); | ||
|
|
||
| if (!$this->waitForOperation($sourceConfig, $operationId)) { | ||
| return Command::FAILURE; | ||
| } | ||
|
|
||
| return $this->onSuccess($sourceConfig, $operationId); | ||
| } | ||
|
|
||
| /** | ||
| * Poll the operation until it leaves the in-progress states. | ||
| * | ||
| * @todo DXBE-20: Confirm the status field name and its values with the | ||
| * SAS team. Assumes a `status` field mirroring the task gateway's | ||
| * phases (pending/running/succeeded/failed). | ||
| */ | ||
| private function waitForOperation(SourceConfig $sourceConfig, string $operationId): bool | ||
|
Comment on lines
+145
to
+149
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.
So There is a real surface to aim at, though. SAS already exposes Needs upstream first, then still changes here. Point 2 of my review on acquia/sites-aggregation-service#971 decides which: if |
||
| { | ||
| $status = null; | ||
| $checkStatus = static function () use ($sourceConfig, $operationId, &$status): bool { | ||
| $response = $sourceConfig->getStatus($operationId); | ||
| $status = $response->status ?? 'unknown'; | ||
| return !in_array($status, ['pending', 'running'], true); | ||
| }; | ||
| $onDone = static function (): void { | ||
| }; | ||
|
|
||
| // @infection-ignore-all The spinner message is transient (overwritten | ||
| // as the spinner advances) and never appears in the captured output, | ||
| // so its concatenation cannot be asserted by a test. | ||
| LoopHelper::getLoopy($this->output, $this->io, $this->operationLabel() . '...', $checkStatus, $onDone); | ||
|
|
||
| if ($status !== 'succeeded') { | ||
| $this->io->error(sprintf('%s ended with status: %s', $this->operationLabel(), $status)); | ||
| } | ||
|
|
||
| return $status === 'succeeded'; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,112 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace Acquia\Cli\Command\Source; | ||
|
|
||
| use Acquia\Cli\Attribute\RequireAuth; | ||
| use Acquia\Cli\Exception\AcquiaCliException; | ||
| use Acquia\Cli\SasApi\SourceConfig; | ||
| use Symfony\Component\Console\Attribute\AsCommand; | ||
| use Symfony\Component\Console\Command\Command; | ||
| use Symfony\Component\Filesystem\Filesystem; | ||
| use Symfony\Component\Yaml\Yaml; | ||
|
|
||
| /** | ||
| * Trigger a Source config export on a site via the Sites Aggregation Service. | ||
| * | ||
| * Asks SAS to run a config export on the environment, which exports config | ||
| * from the CMS. The exported config comes back as a single YAML document keyed | ||
| * by collection, then config name; this command writes it out to | ||
| * .acquia/config/ as individual files, replacing whatever is there. | ||
| */ | ||
| #[RequireAuth] | ||
| #[AsCommand(name: 'source:config:pull', description: 'Export Source configuration from a site')] | ||
| final class ConfigPullCommand extends ConfigCommandBase | ||
| { | ||
| /** | ||
| * The directory (relative to the project root) config is written to. | ||
| */ | ||
| private const CONFIG_DIR = '.acquia/config'; | ||
|
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.
The option is needed here regardless. The push side additionally needs upstream first: the directory can't be communicated to SAS at all while acquia/sites-aggregation-service#971 takes no body — point 1 of my review on acquia/sites-aggregation-service#971. See my comment on |
||
|
|
||
| /** | ||
| * The inline depth and indentation for dumped config YAML. | ||
| * | ||
| * @infection-ignore-all Increment/DecrementInteger on the depth is not | ||
| * observable: any depth beyond the config's actual nesting produces | ||
| * identical output, and the indent behavior is covered by the nested | ||
| * structure test. The values only need to be "deep enough". | ||
| */ | ||
| private const DUMP_DEPTH = 10; | ||
|
|
||
| private const DUMP_INDENT = 2; | ||
|
|
||
| protected function triggerOperation(SourceConfig $sourceConfig, string $environmentId): object | ||
| { | ||
| return $sourceConfig->pull($environmentId); | ||
| } | ||
|
|
||
| protected function operationLabel(): string | ||
| { | ||
| return 'Exporting configuration'; | ||
| } | ||
|
|
||
| /** | ||
| * Fetch the exported payload and write it to .acquia/config/. | ||
| * | ||
| * The directory is wiped and rewritten so the local files mirror the | ||
| * remote state exactly — config removed in the CMS disappears locally too. | ||
| */ | ||
| protected function onSuccess(SourceConfig $sourceConfig, string $operationId): int | ||
| { | ||
| $yaml = $sourceConfig->getExportPayload($operationId); | ||
| $payload = Yaml::parse($yaml); | ||
|
|
||
| if (!is_array($payload)) { | ||
| throw new AcquiaCliException('The SAS API returned an invalid config payload.'); | ||
| } | ||
|
|
||
| $this->writePayload($payload); | ||
|
|
||
| $this->io->success(sprintf('Configuration exported to %s.', self::CONFIG_DIR)); | ||
|
|
||
| return Command::SUCCESS; | ||
| } | ||
|
|
||
| /** | ||
| * Wipe and rewrite .acquia/config/ from the payload. | ||
| * | ||
| * The payload maps collection names to config items. The default | ||
| * collection ("") writes to the config root; other collections write to | ||
| * dotted subdirectories (language.es becomes language/es). | ||
| * | ||
| * @param array<string, array<string, mixed>> $payload | ||
| */ | ||
| private function writePayload(array $payload): void | ||
| { | ||
| $configDir = $this->dir . '/' . self::CONFIG_DIR; | ||
| $filesystem = new Filesystem(); | ||
|
|
||
| // Wipe the directory so the local files mirror the remote state. | ||
| $filesystem->remove($configDir); | ||
|
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. Pull is Not covered by my review on acquia/sites-aggregation-service#971 either — that asks about the push payload, not a config export endpoint. That matters because pull is the half that deletes files. Suggest splitting pull into its own PR. If it stays: name the directory in the prompt, write to a temp dir and swap, and refuse to wipe on an empty payload. |
||
| $filesystem->mkdir($configDir); | ||
|
|
||
| foreach ($payload as $collection => $items) { | ||
| if (!is_array($items)) { | ||
| continue; | ||
| } | ||
| // The default collection ("") is the config root; other collections | ||
| // map their dotted name to a subdirectory (language.es -> language/es). | ||
| $collectionDir = $collection === '' | ||
| ? $configDir | ||
| : $configDir . '/' . str_replace('.', '/', $collection); | ||
|
|
||
| foreach ($items as $name => $values) { | ||
| $filesystem->dumpFile( | ||
|
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.
|
||
| sprintf('%s/%s.yml', $collectionDir, $name), | ||
| Yaml::dump($values, self::DUMP_DEPTH, self::DUMP_INDENT), | ||
| ); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace Acquia\Cli\Command\Source; | ||
|
|
||
| use Acquia\Cli\Attribute\RequireAuth; | ||
| use Acquia\Cli\SasApi\SourceConfig; | ||
| use Symfony\Component\Console\Attribute\AsCommand; | ||
|
|
||
| /** | ||
| * Trigger a Source config import on a site via the Sites Aggregation Service. | ||
| * | ||
| * Asks SAS to run `drush source:config:import` on the environment, which | ||
| * reads config from the site's git repository and applies it to the CMS. | ||
| */ | ||
| #[RequireAuth] | ||
| #[AsCommand(name: 'source:config:push', description: 'Import deployed Source configuration on a site')] | ||
| final class ConfigPushCommand extends ConfigCommandBase | ||
| { | ||
| protected function triggerOperation(SourceConfig $sourceConfig, string $environmentId): object | ||
| { | ||
| return $sourceConfig->push($environmentId); | ||
|
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.
To be clear about the mechanism: the rollback in acquia/haas-drupal#2233 is The workable route is the payload: |
||
| } | ||
|
|
||
| protected function operationLabel(): string | ||
| { | ||
| return 'Importing configuration'; | ||
| } | ||
|
Comment on lines
+26
to
+29
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.
Someone pointing this at production can't tell from that sentence that the site goes down. Anchoring here because the warning is push-specific: pull doesn't take the site offline. But the base's |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace Acquia\Cli\SasApi; | ||
|
|
||
| use AcquiaCloudApi\Connector\Client; | ||
|
|
||
| /** | ||
| * Client for the Sites Aggregation Service (SAS) API. | ||
| * | ||
| * Response processing is inherited unchanged from the Cloud API client. | ||
| */ | ||
| class SasClient extends Client | ||
|
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.
|
||
| { | ||
| } | ||
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.
--force.-fis taken elsewhere by--factory-url(AuthAcsfLoginCommand.php:22) and--format(MakeDocsCommand.php:34), so no collision — but worth a deliberate call on a new flag versus--no-interactionsemantics, and it needs to be consistent with the-nbehaviour I flagged on line 119.