Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
730e2e0
DXBE-20: Add source:config:push command with SAS API client
Aug 17, 2026
3fcb660
DXBE-20: Add source:config:push to KernelTest command list assertion
Aug 17, 2026
d385b3e
DXBE-20: Normalize Windows directory separators in config collections
Aug 17, 2026
45544f9
DXBE-20: Support token-only auth in SasConnectorFactory, fix config type
Aug 17, 2026
4768081
DXBE-20: Use environment ID (not uuid) for CodebaseEnvironmentResponse
Aug 17, 2026
e44a7a1
DXBE-20: Send no payload; config is read from the deployed repo
Aug 17, 2026
fe9bc2e
DXBE-20: Add source:config:pull, extract shared Source command base
Aug 17, 2026
26851be
DXBE-20: Write exported config to disk on source:config:pull
Aug 17, 2026
b0e7df2
DXBE-20: Add execute() tests for source:config commands
Aug 17, 2026
e178a96
DXBE-20: Kill escaped mutants with targeted unit tests
Aug 17, 2026
0f1279e
DXBE-20: Fix writer ordering test, ignore unobservable glue mutants
Aug 17, 2026
1ed4383
DXBE-20: Name dump depth/indent constants, fix factory test assertion
Aug 17, 2026
687b50f
DXBE-20: Distinguish factory auth branches to kill condition mutants
Aug 17, 2026
4c0ca17
DXBE-20: Assert key/secret branch sets clientId to kill ReturnRemoval
Aug 17, 2026
8c1a0a3
DXBE-20: Ignore unobservable ReturnRemoval mutant, drop unhelpful test
Aug 17, 2026
ab407a4
Merge branch 'main' into DXBE-20-source-config-push
wimleers Sep 14, 2026
38b6d37
DXBE-19: Add SourceConfigDocument to convert between a config documen…
wimleers Sep 14, 2026
667185a
DXBE-19: Add source:link to record the Source site in the working cop…
wimleers Sep 14, 2026
48a98cb
DXBE-19, DXBE-8: Drive source:cms:config:pull and push through Cloud …
wimleers Sep 14, 2026
fcdf1da
DXBE-8: Add --format=json to source:cms:config:pull and push
wimleers Sep 14, 2026
da9dad9
DXBE-19: Refuse conflicting pushes, unsafe paths and malformed site IDs
wimleers Sep 14, 2026
7439d4c
DXBE-19: Document the exact fixture regeneration recipe
wimleers Sep 14, 2026
fc834f4
DXBE-19: Kill the three mutants the wording and validation changes le…
wimleers Sep 14, 2026
50dd3d6
DXBE-19: Carry the site UUID in the test configuration documents
wimleers Sep 14, 2026
b270feb
DXBE-19: Say why getSourceWorkingCopyDir() is neither getProjectDir()…
wimleers Sep 14, 2026
6fad178
DXBE-19: @phenaproxima review: Say the site will be offline in the pu…
wimleers Sep 15, 2026
eac4094
Merge remote-tracking branch 'origin/main' into DXBE-20-source-config…
wimleers Sep 15, 2026
a7a982b
DXBE-20: Fix Windows-only test failures in source config push/pull tests
wimleers Sep 15, 2026
32ca28b
DXBE-20: @phenaproxima review: four fixes
wimleers Sep 15, 2026
270dabb
DXBE-20: @phenaproxima review: drop stray ponytail comment
wimleers Sep 16, 2026
0891863
DXBE-20: Add `source:unlink` command
wimleers Sep 17, 2026
56d53b4
DXBE-20: document why YAML decoding flags diverge from core
wimleers Sep 17, 2026
f876aa8
DXBE-20: Fix KernelTest failure on the source:unlink addition
wimleers Sep 17, 2026
ba0d14b
DXBE-20: Add `--status` to `source:cms:config:push`
wimleers Sep 23, 2026
94238d5
Merge branch 'main' into DXBE-20-source-config-push
Oct 8, 2026
96d9d5f
DXBE-20: Send source: request in the config push body
wimleers Oct 14, 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
10 changes: 10 additions & 0 deletions config/prod/services.yml
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,16 @@ services:
$connectorFactory: '@cloud.v3.connector_factory'
$application: '@Acquia\Cli\Application'
$credentials: '@cloud.credentials'
# Source commands talk to Cloud API v3.
Acquia\Cli\Command\Source\LinkCommand:
arguments:
$cloudApiClientService: '@Acquia\Cli\CloudApi\V3ClientService'
Acquia\Cli\Command\Source\ConfigPullCommand:
arguments:
$cloudApiClientService: '@Acquia\Cli\CloudApi\V3ClientService'
Acquia\Cli\Command\Source\ConfigPushCommand:
arguments:
$cloudApiClientService: '@Acquia\Cli\CloudApi\V3ClientService'
AcquiaCloudApi\Connector\ConnectorInterface:
alias: Acquia\Cli\CloudApi\ConnectorFactory
AcquiaCloudApi\Connector\Connector:
Expand Down
48 changes: 48 additions & 0 deletions src/Command/Source/ConfigCommandBase.php
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;
}
}
49 changes: 49 additions & 0 deletions src/Command/Source/ConfigPullCommand.php
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");

@wimleers wimleers Sep 14, 2026 •

Copy link
Copy Markdown
Member

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 export to ever fail, because upon export, config is not validated: https://github.com/acquia/product-specs/pull/214/changes/bf4674abf6e766de9ac09567846b805614322866.

$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;
}
}
169 changes: 169 additions & 0 deletions src/Command/Source/ConfigPushCommand.php
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 {
});
Comment thread
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",

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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]);
}
}
54 changes: 54 additions & 0 deletions src/Command/Source/LinkCommand.php
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
Comment thread
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;
}
}
42 changes: 42 additions & 0 deletions src/Command/Source/SourceCommandBase.php
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
Comment thread
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');
}
}
Loading
Loading