-
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 1 commit
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,190 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace Acquia\Cli\Command\Source; | ||
|
|
||
| use Acquia\Cli\ApiCredentialsInterface; | ||
| use Acquia\Cli\Attribute\RequireAuth; | ||
| 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\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\OutputInterface; | ||
| use Symfony\Component\Finder\Finder; | ||
| use Symfony\Component\Yaml\Yaml; | ||
|
|
||
| /** | ||
| * Push local Source configuration to a site via the Sites Aggregation Service. | ||
| * | ||
| * Reads every .yml file under .acquia/config/ in the current project and | ||
| * assembles them into a single YAML document keyed by config collection (the | ||
| * root directory is the default collection) and then by config name. This | ||
| * mirrors the structure produced by `drush source:config:dump --single-yaml`, | ||
| * which is what a future source:config:pull command writes out. | ||
| */ | ||
| #[RequireAuth] | ||
| #[AsCommand(name: 'source:config:push', description: 'Push Source configuration from .acquia/config to a site')] | ||
| final class ConfigPushCommand extends CommandBase | ||
| { | ||
| /** | ||
| * The directory (relative to the project root) holding config files. | ||
| */ | ||
| private const CONFIG_DIR = '.acquia/config'; | ||
|
|
||
| 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 | ||
|
Check warning on line 79 in src/Command/Source/ConfigPushCommand.php
|
||
| ->acceptEnvironmentId() | ||
| ->acceptSiteInstanceId() | ||
| ->addOption('force', 'f', InputOption::VALUE_NONE, 'Do not ask for confirmation before pushing'); | ||
| } | ||
|
|
||
| protected function execute(InputInterface $input, OutputInterface $output): int | ||
| { | ||
| $this->setDirAndRequireProjectCwd($input); | ||
|
|
||
| $siteInstance = $this->determineSiteInstance($input); | ||
| 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; | ||
|
|
||
| $payload = $this->assemblePayload(); | ||
| if ($payload === []) { | ||
| throw new AcquiaCliException(sprintf('No configuration files found in %s.', self::CONFIG_DIR)); | ||
| } | ||
| $yaml = Yaml::dump($payload, 10, 2); | ||
|
|
||
| if (!$input->getOption('force')) { | ||
| $answer = $this->io->confirm( | ||
| sprintf('Push configuration from %s to the %s environment?', self::CONFIG_DIR, $environment->name), | ||
| false, | ||
| ); | ||
| if (!$answer) { | ||
| return Command::SUCCESS; | ||
| } | ||
| } | ||
|
|
||
| $sourceConfig = new SourceConfig($this->sasClient->getClient()); | ||
|
|
||
| $response = $sourceConfig->push($environment->uuid, $yaml); | ||
| // @todo DXBE-20: Confirm the operation ID field name with the SAS team. | ||
| $operationId = $response->id ?? null; | ||
| if (!is_string($operationId)) { | ||
| throw new AcquiaCliException('The SAS API response did not include an operation ID.'); | ||
| } | ||
|
|
||
| $this->io->writeln(sprintf('Config push submitted (operation %s). Waiting for it to complete...', $operationId)); | ||
|
|
||
| return $this->waitForPush($sourceConfig, $operationId) ? Command::SUCCESS : Command::FAILURE; | ||
| } | ||
|
|
||
| /** | ||
| * Assemble the payload from the config files on disk. | ||
| * | ||
| * Returns a structure keyed by collection name (the default collection is | ||
| * the empty string; subdirectories become dotted collection names like | ||
| * "language.es"), then by config name (the file name minus .yml). | ||
| * Collections with no config files are omitted. | ||
| * | ||
| * @return array<string, array<string, mixed>> | ||
| */ | ||
| private function assemblePayload(): array | ||
| { | ||
| $configDir = $this->dir . '/' . self::CONFIG_DIR; | ||
| if (!is_dir($configDir)) { | ||
| return []; | ||
| } | ||
|
|
||
| $finder = new Finder(); | ||
| $finder->files()->in($configDir)->name('*.yml'); | ||
|
|
||
| $payload = []; | ||
| foreach ($finder as $file) { | ||
| $relativeDir = $file->getRelativePath(); | ||
| // The root directory maps to the default collection (""). | ||
| // Subdirectories map to dotted collection names: language/es | ||
| // becomes language.es. | ||
| $collection = $relativeDir === '' ? '' : str_replace('/', '.', $relativeDir); | ||
| $name = $file->getBasename('.yml'); | ||
| $payload[$collection][$name] = Yaml::parseFile($file->getPathname()); | ||
| } | ||
|
|
||
| return $payload; | ||
| } | ||
|
|
||
| /** | ||
| * 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 waitForPush(SourceConfig $sourceConfig, string $operationId): bool | ||
| { | ||
| $status = null; | ||
| $checkStatus = static function () use ($sourceConfig, $operationId, &$status): bool { | ||
| $response = $sourceConfig->getPushStatus($operationId); | ||
| $status = $response->status ?? 'unknown'; | ||
| return !in_array($status, ['pending', 'running'], true); | ||
| }; | ||
| $onDone = static function (): void { | ||
| }; | ||
|
|
||
| LoopHelper::getLoopy($this->output, $this->io, 'Pushing configuration...', $checkStatus, $onDone); | ||
|
|
||
| if ($status === 'succeeded') { | ||
| $this->io->success('Configuration pushed successfully.'); | ||
| return true; | ||
| } | ||
|
|
||
| $this->io->error(sprintf('Config push ended with status: %s', $status)); | ||
| return false; | ||
| } | ||
|
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.
|
||
| { | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace Acquia\Cli\SasApi; | ||
|
|
||
| use Acquia\Cli\ApiCredentialsInterface; | ||
| use Acquia\Cli\Application; | ||
| use Acquia\Cli\CloudApi\ClientService; | ||
|
|
||
| class SasClientService extends ClientService | ||
| { | ||
| public function __construct(SasConnectorFactory $connectorFactory, Application $application, ApiCredentialsInterface $credentials) | ||
| { | ||
| parent::__construct($connectorFactory, $application, $credentials); | ||
|
Check warning on line 15 in src/SasApi/SasClientService.php
|
||
| } | ||
|
|
||
| public function getClient(): SasClient | ||
| { | ||
| $client = SasClient::factory($this->connector); | ||
| $this->configureClient($client); | ||
|
|
||
| return $client; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace Acquia\Cli\SasApi; | ||
|
|
||
| use AcquiaCloudApi\Connector\Connector; | ||
|
|
||
| /** | ||
| * Connector for the Sites Aggregation Service (SAS) API. | ||
| * | ||
| * SAS shares the Accounts authentication layer with the Cloud API, so the | ||
| * parent class provides OAuth2 client-credentials tokens (Bearer auth) with | ||
| * no changes. The only difference is the base URI requests are sent to. | ||
| */ | ||
| class SasConnector extends Connector | ||
| { | ||
| /** | ||
| * @param array<string, string> $config | ||
| */ | ||
| public function __construct(array $config, ?string $baseUri = null, ?string $urlAccessToken = null) | ||
|
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. Constructor forwards all three arguments unchanged and the class adds nothing. Deleting this also deletes |
||
| { | ||
|
phenaproxima marked this conversation as resolved.
|
||
| parent::__construct($config, $baseUri, $urlAccessToken); | ||
|
Check warning on line 23 in src/SasApi/SasConnector.php
|
||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace Acquia\Cli\SasApi; | ||
|
|
||
| use Acquia\Cli\ConnectorFactoryInterface; | ||
| use AcquiaCloudApi\Connector\ConnectorInterface; | ||
|
|
||
| class SasConnectorFactory implements ConnectorFactoryInterface | ||
| { | ||
| /** | ||
| * @param array<string, string|null> $config | ||
| */ | ||
| public function __construct(protected array $config, protected ?string $baseUri = null, protected ?string $accountsUri = null) | ||
| { | ||
| } | ||
|
|
||
| public function createConnector(): ConnectorInterface | ||
| { | ||
| return new SasConnector($this->config, $this->baseUri, $this->accountsUri); | ||
| } | ||
| } | ||
|
phenaproxima marked this conversation as resolved.
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace Acquia\Cli\SasApi; | ||
|
|
||
| use Acquia\Cli\ApiCredentialsInterface; | ||
|
|
||
| /** | ||
| * Configuration for the Sites Aggregation Service (SAS) API. | ||
| * | ||
| * Authentication is identical to the Cloud API (the same Accounts-issued | ||
| * key/secret and access token), which is why the services file feeds this | ||
| * class's data from the standard cloud credentials. This class exists to | ||
| * provide the SAS base URI, the only piece of configuration unique to SAS. | ||
| */ | ||
| class SasCredentials implements ApiCredentialsInterface | ||
| { | ||
| public function getCloudKey(): ?string | ||
|
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.
There's a trap in that: Dropping |
||
| { | ||
| // Unused: the SAS connector is configured from cloud.credentials | ||
| // directly. See config/prod/services.yml. | ||
| return null; | ||
| } | ||
|
|
||
| public function getCloudSecret(): ?string | ||
| { | ||
| // Unused: see getCloudKey(). | ||
| return null; | ||
| } | ||
|
|
||
| /** | ||
| * Get the SAS API base URI. | ||
| * | ||
| * @todo DXBE-20: Confirm the env var name and the production URI with the | ||
| * SAS team. Follows the ACLI_CLOUD_API_BASE_URI convention. | ||
| */ | ||
| public function getBaseUri(): ?string | ||
| { | ||
| if ($uri = getenv('ACLI_SAS_API_BASE_URI')) { | ||
| return $uri; | ||
| } | ||
|
|
||
| return 'https://sites-aggregation-service.acquia.com/api'; | ||
|
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. Two things. The value: The fallback: Either hardcode a confirmed host, or throw an exception when the env var is unset. |
||
| } | ||
| } | ||
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.
Fair point on the convention, but we're deliberately holding off on an execute()-level test for now: the SAS endpoint doesn't exist yet, so any test would just cement a placeholder request/response shape we'd have to redo once the real API lands. The payload assembly (the novel logic) is covered. We'll add command-level coverage once the endpoint's contract is settled — tracked as part of DXBE-20.