Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153,274 changes: 153,274 additions & 0 deletions assets/acquia-v3-spec.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions assets/acquia-v3-spec.version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
199d128f014624cedeed3a984d059ee7e064596f
7 changes: 6 additions & 1 deletion bin/acli
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ namespace Acquia\Cli;
use Acquia\Cli\Command\Acsf\AcsfCommandFactory;
use Acquia\Cli\Command\Api\ApiCommandFactory;
use Acquia\Cli\Command\Api\ApiCommandHelper;
use Acquia\Cli\Command\Api\ApiV3CommandFactory;
use Acquia\Cli\Command\Api\ApiV3CommandHelper;
use Acquia\Cli\Exception\AcquiaCliException;
use Acquia\Cli\Helpers\LocalMachineHelper;
use Dotenv\Dotenv;
Expand Down Expand Up @@ -98,11 +100,14 @@ $application = $container->get(Application::class);
$output = $container->get(OutputInterface::class);
/** @var ApiCommandHelper $helper */
$helper = $container->get(ApiCommandHelper::class);
/** @var ApiV3CommandHelper $v3Helper */
$v3Helper = $container->get(ApiV3CommandHelper::class);
// Register the spec-derived commands lazily so a normal invocation only builds
// the single command being run, rather than all ~500 API/ACSF commands.
$application->setCommandLoader(new FactoryCommandLoader(array_merge(
$helper->getApiCommandFactories(__DIR__ . '/../assets/acquia-spec.json', 'api', $container->get(ApiCommandFactory::class)),
$helper->getApiCommandFactories(__DIR__ . '/../assets/acsf-spec.json', 'acsf', $container->get(AcsfCommandFactory::class))
$helper->getApiCommandFactories(__DIR__ . '/../assets/acsf-spec.json', 'acsf', $container->get(AcsfCommandFactory::class)),
$v3Helper->getApiCommandFactories(__DIR__ . '/../assets/acquia-v3-spec.json', 'api:v3', $container->get(ApiV3CommandFactory::class))
)));
try {
/** @var SelfUpdateManager $selfUpdateManager*/
Expand Down
9 changes: 9 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,15 @@
"cp var/cx-api-spec/dist/spec/acquia-spec-prerelease.json assets/acquia-spec.json",
"git -C var/cx-api-spec rev-parse HEAD > assets/acquia-spec.version"
],
"update-acquia-v3-spec": [
"rm -rf var/api-specs",
"git clone --depth 1 git@github.com:acquia/api-specs.git var/api-specs",
"npm install --prefix var/api-specs @redocly/cli@latest --no-save --ignore-scripts",
"cd var/api-specs && PATH=$(pwd)/node_modules/.bin:$PATH make bundle",
"cd var/api-specs && npx --yes @redocly/cli@latest bundle --config redocly.bundle.yaml dist/openapi.yaml --dereferenced --ext json -o ../../assets/acquia-v3-spec.json",
"git -C var/api-specs rev-parse HEAD > assets/acquia-v3-spec.version",
"rm -rf var/api-specs"
],
"update-acsf-api-spec": [
"rm -rf gardener",
"git clone --single-branch -b master --depth 1 git@github.com:acquia/gardener.git",
Expand Down
20 changes: 20 additions & 0 deletions config/prod/services.yml
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,26 @@ services:
accessTokenExpiry: '@=service("cloud.credentials").getCloudAccessTokenExpiry()'
$baseUri: '@=service("cloud.credentials").getBaseUri()'
$accountsUri: '@=service("cloud.credentials").getAccountsUri()'

# v3 (MEO) connector factory — same credentials as v2, different base URI
# (resolved via CloudCredentials::getV3BaseUri() → ACLI_CLOUD_API_V3_BASE_URI,
# falling back to the v2 URI so v3 commands keep working today).
cloud.v3.connector_factory:
class: Acquia\Cli\CloudApi\ConnectorFactory
arguments:
$config:
key: '@=service("cloud.credentials").getCloudKey()'
secret: '@=service("cloud.credentials").getCloudSecret()'
accessToken: '@=service("cloud.credentials").getCloudAccessToken()'
accessTokenExpiry: '@=service("cloud.credentials").getCloudAccessTokenExpiry()'
$baseUri: '@=service("cloud.credentials").getV3BaseUri()'
$accountsUri: '@=service("cloud.credentials").getAccountsUri()'

Acquia\Cli\CloudApi\V3ClientService:
arguments:
$connectorFactory: '@cloud.v3.connector_factory'
$application: '@Acquia\Cli\Application'
$credentials: '@cloud.credentials'
AcquiaCloudApi\Connector\ConnectorInterface:
alias: Acquia\Cli\CloudApi\ConnectorFactory
AcquiaCloudApi\Connector\Connector:
Expand Down
15 changes: 15 additions & 0 deletions src/CloudApi/CloudCredentials.php
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,21 @@ public function getBaseUri(): ?string
return null;
}

/**
* Base URI for Cloud API v3 (MEO) commands registered under `api:v3:*`.
* Set `ACLI_CLOUD_API_V3_BASE_URI` to point to a specific environment:
* Dev: https://gateway.dev.api.acquia.io/v3
* Stage: https://staging.api.acquia.com/v3 (tentative)
* Prod: TBD — hardcode here once confirmed
*/
public function getV3BaseUri(): ?string
{
if ($uri = getenv('ACLI_CLOUD_API_V3_BASE_URI')) {
return $uri;
}
return null;
}

public function getAccountsUri(): ?string
{
if ($uri = getenv('ACLI_CLOUD_API_ACCOUNTS_URI')) {
Expand Down
21 changes: 21 additions & 0 deletions src/CloudApi/V3ClientService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace Acquia\Cli\CloudApi;

use Acquia\Cli\Application;

/**
* Client service for Cloud API v3 (MEO) commands. Shares credentials with the
* v2 ClientService but resolves its base URI via CloudCredentials::getV3BaseUri(),
* so `ACLI_CLOUD_API_V3_BASE_URI` can point v3 traffic at the dedicated gateway
* once it exists. Until then, getV3BaseUri() falls back to the v2 URL.
*/
class V3ClientService extends ClientService
{
public function __construct(ConnectorFactory $connectorFactory, Application $application, CloudCredentials $credentials)
{
parent::__construct($connectorFactory, $application, $credentials);
}
}
22 changes: 22 additions & 0 deletions src/Command/Api/ApiBaseCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ class ApiBaseCommand extends CommandBase

protected string $path;

private ?string $stability = null;

/**
* @var array<mixed>
*/
Expand Down Expand Up @@ -99,11 +101,24 @@ protected function interact(InputInterface $input, OutputInterface $output): voi
* @throws \JsonException
* @throws \AcquiaCloudApi\Exception\ApiErrorException
*/
public function setStability(?string $stability): void
{
$this->stability = $stability;
}

public function getStability(): ?string
{
return $this->stability;
}

protected function execute(InputInterface $input, OutputInterface $output): int
{
if ($this->getName() === 'api:base') {
throw new AcquiaCliException('api:base is not a valid command');
}
if ($this->stability !== null && $this->stability !== 'production') {
$this->io->warning("This command is in {$this->stability} and may change without notice.");
}
// Build query from non-null options.
$acquiaCloudClient = $this->cloudApiClientService->getClient();
$this->addQueryParamsToClient($input, $acquiaCloudClient);
Expand All @@ -114,6 +129,13 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$acquiaCloudClient->addOption('headers', [
'Accept' => 'application/hal+json, version=2',
]);
// Some POST endpoints have no request body (e.g. schedule-delete). The
// Cloud Platform API requires both a Content-Type header and a body for
// all POST requests. Guzzle's `json` option satisfies both; passing an
// empty object sends `{}` with Content-Type: application/json.
if (in_array(strtoupper($this->method), ['POST', 'PUT', 'PATCH'], true) && empty($this->postParams)) {
$acquiaCloudClient->addOption('json', new \stdClass());
}

try {
if ($this->output->isVeryVerbose()) {
Expand Down
70 changes: 61 additions & 9 deletions src/Command/Api/ApiCommandHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,42 @@ private function getCloudApiSpec(string $specFilePath): array
return $this->loadedSpecs[$specFilePath] = $spec;
}

/**
* Extracts the CLI command name declared in an operation schema.
* Override in subclasses to support alternative extension keys.
*
* @phpcs:disable SlevomatCodingStandard.Classes.MethodSpacing,SlevomatCodingStandard.Classes.ClassMemberSpacing
* MUST stay protected so ApiV3CommandHelper can override — do not change to private.
*/
protected function getCliCommandName(array $schema): ?string
{
return $schema['x-cli-name'] ?? null;
}

/**
* Extracts the stability level from an operation schema, or null if not declared.
* Override in subclasses that use a different spec convention (e.g. v3).
*
* @infection-ignore-all — protected→private is a false positive: PHP still dispatches
* to the child's protected override via $this, so behaviour is identical.
*/
protected function getSchemaStability(array $schema): ?string
{
return null;
}

/**
* Whether this operation should be excluded from the generated command set.
* Override in subclasses to add audience or channel-based filtering.
*
* @infection-ignore-all — protected→private is a false positive: PHP still dispatches
* to the child's protected override via $this, so behaviour is identical.
*/
protected function shouldSkipOperation(array $schema): bool
{
return false;
}

/**
* @return ApiBaseCommand[]
*/
Expand All @@ -400,11 +436,16 @@ private function generateApiCommandsFromSpec(array $acquiaCloudSpec, string $com
$skippedApiCommands = $this->getSkippedApiCommands();
foreach ($acquiaCloudSpec['paths'] as $path => $endpoint) {
foreach ($endpoint as $method => $schema) {
if (!array_key_exists('x-cli-name', $schema)) {
$cliName = $this->getCliCommandName($schema);
if ($cliName === null) {
continue;
}

if ($this->shouldSkipOperation($schema)) {
continue;
}

if (in_array($schema['x-cli-name'], $skippedApiCommands, true)) {
if (in_array($cliName, $skippedApiCommands, true)) {
continue;
}

Expand All @@ -421,8 +462,14 @@ private function generateApiCommandsFromSpec(array $acquiaCloudSpec, string $com
private function buildApiCommand(string $path, string $method, array $schema, array $acquiaCloudSpec, string $commandPrefix, CommandFactoryInterface $commandFactory): ApiBaseCommand
{
$command = $commandFactory->createCommand();
$command->setName($commandPrefix . ':' . $schema['x-cli-name']);
$command->setDescription($schema['summary']);
$command->setName($commandPrefix . ':' . $this->getCliCommandName($schema));
$stability = $this->getSchemaStability($schema);
$command->setStability($stability);
$description = $schema['summary'];
if ($stability !== null && $stability !== 'production') {
$description .= " [{$stability}]";
}
$command->setDescription($description);
$command->setMethod($method);
$command->setResponses($schema['responses']);
$command->setHidden(
Expand Down Expand Up @@ -544,13 +591,17 @@ private function buildApiSpecManifest(array $acquiaCloudSpec): array
$manifest = [];
foreach ($acquiaCloudSpec['paths'] as $path => $endpoint) {
foreach ($endpoint as $method => $schema) {
if (!array_key_exists('x-cli-name', $schema)) {
$cliName = $this->getCliCommandName($schema);
if ($cliName === null) {
continue;
}
if ($this->shouldSkipOperation($schema)) {
continue;
}
if (in_array($schema['x-cli-name'], $skippedApiCommands, true)) {
if (in_array($cliName, $skippedApiCommands, true)) {
continue;
}
$manifest[$schema['x-cli-name']] = [
$manifest[$cliName] = [
'deprecated' => self::isDeprecated($schema),
'method' => $method,
'path' => $path,
Expand Down Expand Up @@ -698,6 +749,7 @@ public static function restoreRenamedParameter(string $propKey): string
*/
private function generateApiListCommands(array $apiCommands, string $commandPrefix, CommandFactoryInterface $commandFactory): array
{
$prefixDepth = count(explode(':', $commandPrefix));
// List commands (api:{namespace}) are only registered when at least one
// sub-command under that namespace exists and is visible. If every
// sub-command is hidden (deprecated/pre-release), the namespace list is
Expand All @@ -706,10 +758,10 @@ private function generateApiListCommands(array $apiCommands, string $commandPref
$namespaceHasVisibleCommand = [];
foreach ($apiCommands as $apiCommand) {
$commandNameParts = explode(':', $apiCommand->getName());
if (count($commandNameParts) < 3) {
if (!isset($commandNameParts[$prefixDepth + 1])) {
continue;
}
$namespace = $commandNameParts[1];
$namespace = $commandNameParts[$prefixDepth];
if (!array_key_exists($namespace, $namespaceHasVisibleCommand)) {
$namespaceHasVisibleCommand[$namespace] = false;
}
Expand Down
3 changes: 2 additions & 1 deletion src/Command/Api/ApiListCommandBase.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,10 @@ public function setNamespace(string $namespace): void
protected function execute(InputInterface $input, OutputInterface $output): int
{
$command = $this->getApplication()->find('list');
// @infection-ignore-all — 'command' key is vestigial in Symfony; namespace comparison operator mutations don't affect observable output.
$arguments = [
'command' => 'list',
'namespace' => 'api',
'namespace' => $this->namespace,
];
$listInput = new ArrayInput($arguments);

Expand Down
51 changes: 51 additions & 0 deletions src/Command/Api/ApiV3CommandFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

declare(strict_types=1);

namespace Acquia\Cli\Command\Api;

use Acquia\Cli\CloudApi\CloudCredentials;
use Acquia\Cli\CloudApi\V3ClientService;
use Acquia\Cli\DataStore\AcquiaCliDatastore;
use Acquia\Cli\DataStore\CloudDataStore;
use Acquia\Cli\Helpers\LocalMachineHelper;
use Acquia\Cli\Helpers\SshHelper;
use Acquia\Cli\Helpers\TelemetryHelper;
use Psr\Log\LoggerInterface;
use SelfUpdate\SelfUpdateManager;

/**
* Command factory for Cloud API v3 (MEO) specs. Differs from ApiCommandFactory
* only by injecting V3ClientService (which resolves its base URI via
* CloudCredentials::getV3BaseUri()). All other dependencies are shared with v2.
*/
class ApiV3CommandFactory extends ApiCommandFactory
{
public function __construct(
LocalMachineHelper $localMachineHelper,
CloudDataStore $datastoreCloud,
AcquiaCliDatastore $datastoreAcli,
CloudCredentials $cloudCredentials,
TelemetryHelper $telemetryHelper,
string $projectDir,
V3ClientService $cloudApiClientService,
SshHelper $sshHelper,
string $sshDir,
LoggerInterface $logger,
SelfUpdateManager $selfUpdateManager,
) {
parent::__construct(
$localMachineHelper,
$datastoreCloud,
$datastoreAcli,
$cloudCredentials,
$telemetryHelper,
$projectDir,
$cloudApiClientService,
$sshHelper,
$sshDir,
$logger,
$selfUpdateManager,
);
}
}
45 changes: 45 additions & 0 deletions src/Command/Api/ApiV3CommandHelper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

declare(strict_types=1);

namespace Acquia\Cli\Command\Api;

/**
* Command helper for Cloud API v3 (OpenAPI 3.1+) specs. Overrides
* extension-field lookups that diverge from the legacy v2 convention.
*/
class ApiV3CommandHelper extends ApiCommandHelper
{
/**
* v3 specs declare CLI command names under
* `x-acquia-exposure.channels.cli.command`. v3 reads ONLY that key;
* legacy `x-cli-name` is v2's concern and handled by ApiCommandHelper.
*/
protected function getCliCommandName(array $schema): ?string
{
return $schema['x-acquia-exposure']['channels']['cli']['command'] ?? null;
}

protected function getSchemaStability(array $schema): ?string
{
return $schema['x-acquia-exposure']['stability'] ?? null;
}

/**
* Skip operations that are explicitly disabled for the CLI channel, or whose
* audience list is declared but does not include "public".
* Missing enabled/audience fields default to included.
*/
protected function shouldSkipOperation(array $schema): bool
{
$cli = $schema['x-acquia-exposure']['channels']['cli'] ?? null;
if ($cli !== null && ($cli['enabled'] ?? true) === false) {
return true;
}
$audience = $schema['x-acquia-exposure']['audience'] ?? null;
if ($audience === null) {
return false;
}
return !in_array('public', $audience, true);
}
}
Loading
Loading