Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
c2df57a
CLI-1793: Consume Cloud API v3 OpenAPI spec in ACLI (#2011)
deepakmishra2 Jun 30, 2026
201a90f
conflict resolution fix.
deepakmishra2 Jul 10, 2026
70dc746
CLI-1827: Automate v3 API spec updates via daily cron PR (#2019)
deepakmishra2 Jul 15, 2026
3cb208a
Merge branch 'main' into feature/meo-v3-gateway
deepakmishra2 Jul 15, 2026
7744b83
fix: use GH_TOKEN secret for automated spec update PRs
deepakmishra2 Jul 17, 2026
4ec1492
Merge branch 'main' into feature/meo-v3-gateway
deepakmishra2 Jul 20, 2026
5c26045
chore: update v3 spec URL to mesh endpoint
deepakmishra2 Jul 23, 2026
5294a3e
Merge branch 'main' into feature/meo-v3-gateway
deepakmishra2 Jul 23, 2026
a114b8c
chore: report initial conflict resolution plan
Copilot Jul 24, 2026
a8afcf8
Merge branch 'main' into feature/meo-v3-gateway
Copilot Jul 24, 2026
af617af
chore: remove internal mesh URL from automated PR body
deepakmishra2 Jul 28, 2026
972ab0b
Merge branch 'main' into feature/meo-v3-gateway
deepakmishra2 Jul 28, 2026
94ad2ba
add api:v3 commands in list command.
deepakmishra2 Jul 28, 2026
66f9625
CLI-1829: Update v3 spec to canonical staging URL and refresh spec
deepakmishra2 Jul 30, 2026
10d1a60
update spec.
deepakmishra2 Jul 30, 2026
e7f301e
mutation test fixes.
deepakmishra2 Jul 30, 2026
570bc06
Merge branch 'main' into feature/meo-v3-gateway
deepakmishra2 Aug 4, 2026
7775102
CLI-1881: Update v3 prod gateway URL to api.acquia.com/v3
deepakmishra2 Aug 13, 2026
d4539a0
Merge branch 'main' into feature/meo-v3-gateway
deepakmishra2 Aug 13, 2026
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
57 changes: 57 additions & 0 deletions .github/workflows/update-v3-spec.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
name: Update Acquia v3 API spec

on:
schedule:
- cron: '0 3 * * *'
workflow_dispatch:

jobs:
update-spec:
runs-on: ubuntu-24.04
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v7

- uses: actions/setup-node@v4
with:
node-version: '20'

- name: Install Redocly CLI
run: npm install -g @redocly/cli@latest

- name: Download and dereference v3 spec
run: |
curl -fsSL https://api.acquia.com/v3/openapi.yaml -o /tmp/acquia-v3-raw.yaml
redocly bundle --dereferenced /tmp/acquia-v3-raw.yaml -o /tmp/acquia-v3-deref.json
jq 'del(.paths["/openapi.yaml"])' /tmp/acquia-v3-deref.json > assets/acquia-v3-spec.json
Comment on lines +27 to +28

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we had to do this because Spec is not dereferenced and the YAML served has internal $ref entries (e.g. $ref: '#/components/parameters/Failover_Service_API_Offset').


- name: Check for changes
id: diff
run: |
if git diff --quiet assets/acquia-v3-spec.json; then
echo "changed=false" >> $GITHUB_OUTPUT
else
echo "changed=true" >> $GITHUB_OUTPUT
fi

- name: Create PR if spec changed
if: steps.diff.outputs.changed == 'true'
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
run: |
BRANCH="automated/update-acquia-v3-spec-${{ github.run_id }}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git checkout -b "$BRANCH"
git add assets/acquia-v3-spec.json
git commit -m "chore: update Acquia v3 OpenAPI spec"
git push origin "$BRANCH"
gh pr create \
--title "chore: update Acquia v3 OpenAPI spec" \
--body "Automated update of the Acquia v3 OpenAPI spec." \
--label "chore" \
--base "${{ github.ref_name }}" \
--head "$BRANCH"
gh pr merge "$BRANCH" --auto --squash
1 change: 1 addition & 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
19 changes: 19 additions & 0 deletions config/prod/services.yml
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,25 @@ services:
accessTokenExpiry: '@=service("cloud.credentials").getCloudAccessTokenExpiry()'
$baseUri: '@=service("cloud.credentials").getBaseUri()'
$accountsUri: '@=service("cloud.credentials").getAccountsUri()'

# v3 (MEO) connector factory — same credentials as v2, prod gateway at
# https://api.acquia.com/v3. Override with ACLI_CLOUD_API_V3_BASE_URI for dev/stage.
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
12 changes: 12 additions & 0 deletions src/CloudApi/CloudCredentials.php
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,18 @@ public function getBaseUri(): ?string
return null;
}

/**
* Base URI for Cloud API v3 (MEO) commands registered under `api:v3:*`.
* Override with `ACLI_CLOUD_API_V3_BASE_URI` for non-production environments.
*/
public function getV3BaseUri(): string
{
if ($uri = getenv('ACLI_CLOUD_API_V3_BASE_URI')) {
return $uri;
}
return 'https://api.acquia.com/v3';
}

public function getAccountsUri(): ?string
{
if ($uri = getenv('ACLI_CLOUD_API_ACCOUNTS_URI')) {
Expand Down
20 changes: 20 additions & 0 deletions src/CloudApi/V3ClientService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?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 override the prod gateway for dev/stage traffic.
*/
class V3ClientService extends ClientService
{
public function __construct(ConnectorFactory $connectorFactory, Application $application, CloudCredentials $credentials)
{
parent::__construct($connectorFactory, $application, $credentials);
}
}
15 changes: 15 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 Down
90 changes: 81 additions & 9 deletions src/Command/Api/ApiCommandHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,26 @@ private function addApiCommandParametersForRequestBody(array $schema, array $acq
}
}

// Symfony Console allows at most one IS_ARRAY argument (it must be last).
// When a spec has multiple required array body params, strip IS_ARRAY from all but the last.
$arrayArgKeys = [];
foreach ($inputDefinition as $index => $parameterDefinition) {
if ($parameterDefinition instanceof InputArgument && $parameterDefinition->isArray()) {
$arrayArgKeys[] = $index;
}
}
// Pop the last key so that argument keeps IS_ARRAY; strip IS_ARRAY from the rest.
array_pop($arrayArgKeys);
foreach ($arrayArgKeys as $index) {
/** @var InputArgument $arg */
$arg = $inputDefinition[$index];
$inputDefinition[$index] = new InputArgument(
$arg->getName(),
InputArgument::REQUIRED,
$arg->getDescription()
);
}

return [$inputDefinition, $usage];
}

Expand Down Expand Up @@ -391,6 +411,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.
*/
Comment on lines +426 to +432
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 +456,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 (in_array($schema['x-cli-name'], $skippedApiCommands, true)) {
if ($this->shouldSkipOperation($schema)) {
continue;
}

if (in_array($cliName, $skippedApiCommands, true)) {
continue;
}

Expand All @@ -421,8 +482,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 +611,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 +769,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 +778,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,
);
}
}
Loading
Loading