From 730e2e03c7d90f593ec38f2bae83ab47cd5e7781 Mon Sep 17 00:00:00 2001 From: "Adam G-H (phenaproxima)" Date: Mon, 17 Aug 2026 12:29:27 -0400 Subject: [PATCH 01/35] DXBE-20: Add source:config:push command with SAS API client Adds a new source:config:push command that assembles the config files under .acquia/config into a single YAML payload (keyed by config collection, then config name) and POSTs it to the Sites Aggregation Service (SAS), polling the resulting async operation until completion. Introduces a SasApi client layer modeled on the existing AcsfApi pattern. Because SAS shares the Accounts authentication layer with the Cloud API, the connector reuses the standard OAuth2 client-credentials token flow; only the base URI is new (ACLI_SAS_API_BASE_URI). Open items are marked with @todo DXBE-20: the SAS endpoint path and response field names are placeholders pending the SAS endpoint being built, and the payload may need to be JSON-encoded if the SAS team requires it. --- config/prod/services.yml | 21 ++ src/Command/Source/ConfigPushCommand.php | 190 ++++++++++++++++++ src/SasApi/SasClient.php | 16 ++ src/SasApi/SasClientService.php | 25 +++ src/SasApi/SasConnector.php | 25 +++ src/SasApi/SasConnectorFactory.php | 23 +++ src/SasApi/SasCredentials.php | 46 +++++ src/SasApi/SourceConfig.php | 52 +++++ .../Commands/Source/ConfigPushCommandTest.php | 87 ++++++++ 9 files changed, 485 insertions(+) create mode 100644 src/Command/Source/ConfigPushCommand.php create mode 100644 src/SasApi/SasClient.php create mode 100644 src/SasApi/SasClientService.php create mode 100644 src/SasApi/SasConnector.php create mode 100644 src/SasApi/SasConnectorFactory.php create mode 100644 src/SasApi/SasCredentials.php create mode 100644 src/SasApi/SourceConfig.php create mode 100644 tests/phpunit/src/Commands/Source/ConfigPushCommandTest.php diff --git a/config/prod/services.yml b/config/prod/services.yml index a1ec16643..11f37c33f 100644 --- a/config/prod/services.yml +++ b/config/prod/services.yml @@ -33,6 +33,8 @@ services: - ../../src/DataStore/YamlStore.php - ../../src/DataStore/JsonDataStore.php - ../../src/CloudApi/AccessTokenConnector.php + # SourceConfig is instantiated by the command with the SAS client. + - ../../src/SasApi/SourceConfig.php - ../../src/Command/App/From/** public: true resource: ../../src @@ -79,6 +81,9 @@ services: acsf.credentials: class: Acquia\Cli\AcsfApi\AcsfCredentials + sas.credentials: + class: Acquia\Cli\SasApi\SasCredentials + # AcquiaCloudApi services. Acquia\Cli\Command\Api\ApiCommandFactory: ~ Acquia\Cli\Command\Api\ApiBaseCommand: @@ -132,6 +137,22 @@ services: arguments: Acquia\Cli\ApiCredentialsInterface: '@acsf.credentials' + # Sites Aggregation Service (SAS) API services. + # SAS shares the Accounts authentication layer with the Cloud API, so it + # reuses the standard cloud credentials; only the base URI differs. + Acquia\Cli\SasApi\SasConnectorFactory: + arguments: + $config: + # @see https://symfony.com/doc/current/service_container/expression_language.html + key: '@=service("cloud.credentials").getCloudKey()' + secret: '@=service("cloud.credentials").getCloudSecret()' + accessToken: '@=service("cloud.credentials").getCloudAccessToken()' + accessTokenExpiry: '@=service("cloud.credentials").getCloudAccessTokenExpiry()' + $baseUri: '@=service("sas.credentials").getBaseUri()' + $accountsUri: '@=service("cloud.credentials").getAccountsUri()' + Acquia\Cli\SasApi\SasConnector: + alias: Acquia\Cli\SasApi\SasConnectorFactory + # Symfony services. Acquia\Cli\Application: arguments: diff --git a/src/Command/Source/ConfigPushCommand.php b/src/Command/Source/ConfigPushCommand.php new file mode 100644 index 000000000..0083886d7 --- /dev/null +++ b/src/Command/Source/ConfigPushCommand.php @@ -0,0 +1,190 @@ +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> + */ + 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; + } +} diff --git a/src/SasApi/SasClient.php b/src/SasApi/SasClient.php new file mode 100644 index 000000000..1a91fef03 --- /dev/null +++ b/src/SasApi/SasClient.php @@ -0,0 +1,16 @@ +connector); + $this->configureClient($client); + + return $client; + } +} diff --git a/src/SasApi/SasConnector.php b/src/SasApi/SasConnector.php new file mode 100644 index 000000000..46c40669d --- /dev/null +++ b/src/SasApi/SasConnector.php @@ -0,0 +1,25 @@ + $config + */ + public function __construct(array $config, ?string $baseUri = null, ?string $urlAccessToken = null) + { + parent::__construct($config, $baseUri, $urlAccessToken); + } +} diff --git a/src/SasApi/SasConnectorFactory.php b/src/SasApi/SasConnectorFactory.php new file mode 100644 index 000000000..b072fbe98 --- /dev/null +++ b/src/SasApi/SasConnectorFactory.php @@ -0,0 +1,23 @@ + $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); + } +} diff --git a/src/SasApi/SasCredentials.php b/src/SasApi/SasCredentials.php new file mode 100644 index 000000000..ae8dcfde9 --- /dev/null +++ b/src/SasApi/SasCredentials.php @@ -0,0 +1,46 @@ + $yamlPayload, + 'headers' => ['Content-Type' => 'application/yaml'], + ]; + + return $this->client->request( + 'post', + "/environments/$environmentId/config-push", + $options, + ); + } + + /** + * Get the status of a config push operation. + * + * @return object The decoded response, expected to contain a status field. + */ + public function getPushStatus(string $operationId): object + { + return $this->client->request('get', "/config-push/$operationId"); + } +} diff --git a/tests/phpunit/src/Commands/Source/ConfigPushCommandTest.php b/tests/phpunit/src/Commands/Source/ConfigPushCommandTest.php new file mode 100644 index 000000000..3d08d6e25 --- /dev/null +++ b/tests/phpunit/src/Commands/Source/ConfigPushCommandTest.php @@ -0,0 +1,87 @@ +prophet->prophesize(SasClientService::class); + + return new ConfigPushCommand( + $this->localMachineHelper, + $this->datastoreCloud, + $this->datastoreAcli, + $this->cloudCredentials, + $this->telemetryHelper, + $this->acliRepoRoot, + $this->clientServiceProphecy->reveal(), + $this->sshHelper, + $this->sshDir, + $this->logger, + $this->selfUpdateManager, + $sasClientService->reveal(), + ); + } + + /** + * Invoke the private payload assembler against a directory of config files. + * + * @return array> + */ + private function assemblePayload(string $dir): array + { + (new ReflectionProperty($this->command, 'dir'))->setValue($this->command, $dir); + $method = new ReflectionMethod($this->command, 'assemblePayload'); + + return $method->invoke($this->command); + } + + public function testAssemblePayloadBuildsCollectionsFromDirectories(): void + { + $configDir = $this->projectDir . '/.acquia/config'; + mkdir($configDir . '/language/es', 0777, true); + file_put_contents($configDir . '/node.type.blog.yml', "label: Blog\n"); + file_put_contents($configDir . '/system.site.yml', "name: My Site\n"); + file_put_contents($configDir . '/language/es/node.type.blog.yml', "label: Blogue\n"); + + $payload = $this->assemblePayload($this->projectDir); + + // The root directory maps to the default ("") collection, and the + // language/es subdirectory maps to the language.es collection. + $this->assertSame(['label' => 'Blog'], $payload['']['node.type.blog']); + $this->assertSame(['name' => 'My Site'], $payload['']['system.site']); + $this->assertSame(['label' => 'Blogue'], $payload['language.es']['node.type.blog']); + } + + public function testAssemblePayloadReturnsEmptyArrayWhenDirectoryMissing(): void + { + $this->assertSame([], $this->assemblePayload($this->projectDir)); + } + + public function testAssemblePayloadOmitsEmptyCollections(): void + { + $configDir = $this->projectDir . '/.acquia/config'; + mkdir($configDir . '/language/fr', 0777, true); + file_put_contents($configDir . '/system.site.yml', "name: My Site\n"); + + $payload = $this->assemblePayload($this->projectDir); + + // The language/fr directory holds no .yml files, so no language.fr + // collection appears in the payload. + $this->assertArrayNotHasKey('language.fr', $payload); + $this->assertSame(['name' => 'My Site'], $payload['']['system.site']); + } +} From 3fcb660df8c959109e36655d22c12009527cd24d Mon Sep 17 00:00:00 2001 From: "Adam G-H (phenaproxima)" Date: Mon, 17 Aug 2026 12:39:50 -0400 Subject: [PATCH 02/35] DXBE-20: Add source:config:push to KernelTest command list assertion --- tests/phpunit/src/Application/KernelTest.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/phpunit/src/Application/KernelTest.php b/tests/phpunit/src/Application/KernelTest.php index e0ad1a608..a74a28381 100644 --- a/tests/phpunit/src/Application/KernelTest.php +++ b/tests/phpunit/src/Application/KernelTest.php @@ -105,6 +105,8 @@ private function getEnd(): string self:telemetry:disable [telemetry:disable] Disable anonymous sharing of usage and performance data self:telemetry:enable [telemetry:enable] Enable anonymous sharing of usage and performance data self:telemetry:toggle [telemetry] Toggle anonymous sharing of usage and performance data + source + source:config:push Push Source configuration from .acquia/config to a site ssh-key ssh-key:create Create an SSH key on your local machine ssh-key:create-upload Create an SSH key on your local machine and upload it to the Cloud Platform From d385b3ecd10995b809033438e624a64b59132ae1 Mon Sep 17 00:00:00 2001 From: "Adam G-H (phenaproxima)" Date: Mon, 17 Aug 2026 13:36:12 -0400 Subject: [PATCH 03/35] DXBE-20: Normalize Windows directory separators in config collections --- src/Command/Source/ConfigPushCommand.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Command/Source/ConfigPushCommand.php b/src/Command/Source/ConfigPushCommand.php index 0083886d7..c4a6c5947 100644 --- a/src/Command/Source/ConfigPushCommand.php +++ b/src/Command/Source/ConfigPushCommand.php @@ -150,8 +150,9 @@ private function assemblePayload(): array $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); + // becomes language.es. Normalize both Unix and Windows directory + // separators, since Finder returns OS-specific relative paths. + $collection = $relativeDir === '' ? '' : str_replace(['/', '\\'], '.', $relativeDir); $name = $file->getBasename('.yml'); $payload[$collection][$name] = Yaml::parseFile($file->getPathname()); } From 45544f9dc55947fe4a0fc619330cff65b1adc7b3 Mon Sep 17 00:00:00 2001 From: "Adam G-H (phenaproxima)" Date: Mon, 17 Aug 2026 14:04:33 -0400 Subject: [PATCH 04/35] DXBE-20: Support token-only auth in SasConnectorFactory, fix config type Mirror the Cloud API ConnectorFactory: fall back to an AccessTokenConnector when a valid access token is present (e.g. a bot token in CI), instead of always building a key/secret connector. Also widen the SasConnector config phpdoc to allow nullable values. --- src/SasApi/SasConnector.php | 2 +- src/SasApi/SasConnectorFactory.php | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/SasApi/SasConnector.php b/src/SasApi/SasConnector.php index 46c40669d..b19d55c50 100644 --- a/src/SasApi/SasConnector.php +++ b/src/SasApi/SasConnector.php @@ -16,7 +16,7 @@ class SasConnector extends Connector { /** - * @param array $config + * @param array $config */ public function __construct(array $config, ?string $baseUri = null, ?string $urlAccessToken = null) { diff --git a/src/SasApi/SasConnectorFactory.php b/src/SasApi/SasConnectorFactory.php index b072fbe98..d929e4d58 100644 --- a/src/SasApi/SasConnectorFactory.php +++ b/src/SasApi/SasConnectorFactory.php @@ -4,8 +4,10 @@ namespace Acquia\Cli\SasApi; +use Acquia\Cli\CloudApi\AccessTokenConnector; use Acquia\Cli\ConnectorFactoryInterface; use AcquiaCloudApi\Connector\ConnectorInterface; +use League\OAuth2\Client\Token\AccessToken; class SasConnectorFactory implements ConnectorFactoryInterface { @@ -18,6 +20,32 @@ public function __construct(protected array $config, protected ?string $baseUri public function createConnector(): ConnectorInterface { + // A defined key & secret takes priority. + if ($this->config['key'] && $this->config['secret']) { + return new SasConnector($this->config, $this->baseUri, $this->accountsUri); + } + + // Fall back to a valid access token (e.g. a bot token in CI). + if (!empty($this->config['accessToken'])) { + $accessToken = $this->createAccessToken(); + if (!$accessToken->hasExpired()) { + return new AccessTokenConnector([ + 'access_token' => $accessToken, + 'key' => null, + 'secret' => null, + ], $this->baseUri, $this->accountsUri); + } + } + + // Fall back to an unauthenticated request. return new SasConnector($this->config, $this->baseUri, $this->accountsUri); } + + private function createAccessToken(): AccessToken + { + return new AccessToken([ + 'access_token' => $this->config['accessToken'], + 'expires' => $this->config['accessTokenExpiry'], + ]); + } } From 476808156580f1af4a622822b583f402f2b8fc7f Mon Sep 17 00:00:00 2001 From: "Adam G-H (phenaproxima)" Date: Mon, 17 Aug 2026 14:08:28 -0400 Subject: [PATCH 05/35] DXBE-20: Use environment ID (not uuid) for CodebaseEnvironmentResponse CodebaseEnvironmentResponse exposes ->id, unlike EnvironmentResponse which uses ->uuid. Passing the wrong property would have errored at runtime. --- src/Command/Source/ConfigPushCommand.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Command/Source/ConfigPushCommand.php b/src/Command/Source/ConfigPushCommand.php index c4a6c5947..7779a7d92 100644 --- a/src/Command/Source/ConfigPushCommand.php +++ b/src/Command/Source/ConfigPushCommand.php @@ -113,7 +113,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $sourceConfig = new SourceConfig($this->sasClient->getClient()); - $response = $sourceConfig->push($environment->uuid, $yaml); + $response = $sourceConfig->push($environment->id, $yaml); // @todo DXBE-20: Confirm the operation ID field name with the SAS team. $operationId = $response->id ?? null; if (!is_string($operationId)) { From e44a7a13f03123855072cdf692d4769009ea942d Mon Sep 17 00:00:00 2001 From: "Adam G-H (phenaproxima)" Date: Mon, 17 Aug 2026 14:21:19 -0400 Subject: [PATCH 06/35] DXBE-20: Send no payload; config is read from the deployed repo The SAS endpoint triggers drush source:config:import, which reads config from the site's deployed git repository. The acli command is a thin trigger: resolve the site instance, POST with an empty body, poll. Remove the payload-assembly logic and its tests, which are no longer needed. --- src/Command/Source/ConfigPushCommand.php | 73 +++------------- src/SasApi/SourceConfig.php | 29 ++----- tests/phpunit/src/Application/KernelTest.php | 2 +- .../Commands/Source/ConfigPushCommandTest.php | 87 ------------------- 4 files changed, 21 insertions(+), 170 deletions(-) delete mode 100644 tests/phpunit/src/Commands/Source/ConfigPushCommandTest.php diff --git a/src/Command/Source/ConfigPushCommand.php b/src/Command/Source/ConfigPushCommand.php index 7779a7d92..a6887b726 100644 --- a/src/Command/Source/ConfigPushCommand.php +++ b/src/Command/Source/ConfigPushCommand.php @@ -24,27 +24,19 @@ 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. + * Trigger a Source config import on 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. + * This is a thin trigger: it asks SAS to run `drush source:config:import` on + * the site's environment. The command sends no payload — the config is read + * from the site's deployed git repository on the Acquia side, not from the + * local machine. */ #[RequireAuth] -#[AsCommand(name: 'source:config:push', description: 'Push Source configuration from .acquia/config to a site')] +#[AsCommand(name: 'source:config:push', description: 'Import deployed Source configuration on 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, @@ -95,15 +87,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int $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), + sprintf('Import deployed configuration on the %s environment?', $environment->name), false, ); if (!$answer) { @@ -113,53 +99,18 @@ protected function execute(InputInterface $input, OutputInterface $output): int $sourceConfig = new SourceConfig($this->sasClient->getClient()); - $response = $sourceConfig->push($environment->id, $yaml); + $response = $sourceConfig->push($environment->id); // @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)); + $this->io->writeln(sprintf('Config import 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> - */ - 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. Normalize both Unix and Windows directory - // separators, since Finder returns OS-specific relative paths. - $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. * @@ -178,14 +129,14 @@ private function waitForPush(SourceConfig $sourceConfig, string $operationId): b $onDone = static function (): void { }; - LoopHelper::getLoopy($this->output, $this->io, 'Pushing configuration...', $checkStatus, $onDone); + LoopHelper::getLoopy($this->output, $this->io, 'Importing configuration...', $checkStatus, $onDone); if ($status === 'succeeded') { - $this->io->success('Configuration pushed successfully.'); + $this->io->success('Configuration imported successfully.'); return true; } - $this->io->error(sprintf('Config push ended with status: %s', $status)); + $this->io->error(sprintf('Config import ended with status: %s', $status)); return false; } } diff --git a/src/SasApi/SourceConfig.php b/src/SasApi/SourceConfig.php index 0348ca238..bef129fa9 100644 --- a/src/SasApi/SourceConfig.php +++ b/src/SasApi/SourceConfig.php @@ -7,7 +7,7 @@ use AcquiaCloudApi\Endpoints\CloudApiBase; /** - * SAS API endpoints for pushing Source site configuration. + * SAS API endpoints for Source site configuration. * * @todo DXBE-20: Confirm the endpoint paths and response field names with the * SAS team. The SAS endpoints do not exist yet; paths here are placeholders. @@ -15,38 +15,25 @@ class SourceConfig extends CloudApiBase { /** - * Submit a config push for a site environment. + * Trigger a config import on a site environment. * - * The payload is sent as a single YAML document mapping config collection - * names to config items, mirroring the structure produced by - * `drush source:config:dump --single-yaml`. + * Sends no payload — the config is read from the site's deployed git + * repository on the Acquia side (via `drush source:config:import`). * - * @todo DXBE-20: The SAS team may require the payload JSON-encoded - * instead. If so, replace the YAML body and Content-Type with - * json_encode() and the json option. * @return object The decoded response, expected to contain an operation ID. */ - public function push(string $environmentId, string $yamlPayload): object + public function push(string $environmentId): object { - $options = [ - 'body' => $yamlPayload, - 'headers' => ['Content-Type' => 'application/yaml'], - ]; - - return $this->client->request( - 'post', - "/environments/$environmentId/config-push", - $options, - ); + return $this->client->request('post', "/environments/$environmentId/config-import"); } /** - * Get the status of a config push operation. + * Get the status of a config import operation. * * @return object The decoded response, expected to contain a status field. */ public function getPushStatus(string $operationId): object { - return $this->client->request('get', "/config-push/$operationId"); + return $this->client->request('get', "/config-import/$operationId"); } } diff --git a/tests/phpunit/src/Application/KernelTest.php b/tests/phpunit/src/Application/KernelTest.php index a74a28381..51a962264 100644 --- a/tests/phpunit/src/Application/KernelTest.php +++ b/tests/phpunit/src/Application/KernelTest.php @@ -106,7 +106,7 @@ private function getEnd(): string self:telemetry:enable [telemetry:enable] Enable anonymous sharing of usage and performance data self:telemetry:toggle [telemetry] Toggle anonymous sharing of usage and performance data source - source:config:push Push Source configuration from .acquia/config to a site + source:config:push Import deployed Source configuration on a site ssh-key ssh-key:create Create an SSH key on your local machine ssh-key:create-upload Create an SSH key on your local machine and upload it to the Cloud Platform diff --git a/tests/phpunit/src/Commands/Source/ConfigPushCommandTest.php b/tests/phpunit/src/Commands/Source/ConfigPushCommandTest.php deleted file mode 100644 index 3d08d6e25..000000000 --- a/tests/phpunit/src/Commands/Source/ConfigPushCommandTest.php +++ /dev/null @@ -1,87 +0,0 @@ -prophet->prophesize(SasClientService::class); - - return new ConfigPushCommand( - $this->localMachineHelper, - $this->datastoreCloud, - $this->datastoreAcli, - $this->cloudCredentials, - $this->telemetryHelper, - $this->acliRepoRoot, - $this->clientServiceProphecy->reveal(), - $this->sshHelper, - $this->sshDir, - $this->logger, - $this->selfUpdateManager, - $sasClientService->reveal(), - ); - } - - /** - * Invoke the private payload assembler against a directory of config files. - * - * @return array> - */ - private function assemblePayload(string $dir): array - { - (new ReflectionProperty($this->command, 'dir'))->setValue($this->command, $dir); - $method = new ReflectionMethod($this->command, 'assemblePayload'); - - return $method->invoke($this->command); - } - - public function testAssemblePayloadBuildsCollectionsFromDirectories(): void - { - $configDir = $this->projectDir . '/.acquia/config'; - mkdir($configDir . '/language/es', 0777, true); - file_put_contents($configDir . '/node.type.blog.yml', "label: Blog\n"); - file_put_contents($configDir . '/system.site.yml', "name: My Site\n"); - file_put_contents($configDir . '/language/es/node.type.blog.yml', "label: Blogue\n"); - - $payload = $this->assemblePayload($this->projectDir); - - // The root directory maps to the default ("") collection, and the - // language/es subdirectory maps to the language.es collection. - $this->assertSame(['label' => 'Blog'], $payload['']['node.type.blog']); - $this->assertSame(['name' => 'My Site'], $payload['']['system.site']); - $this->assertSame(['label' => 'Blogue'], $payload['language.es']['node.type.blog']); - } - - public function testAssemblePayloadReturnsEmptyArrayWhenDirectoryMissing(): void - { - $this->assertSame([], $this->assemblePayload($this->projectDir)); - } - - public function testAssemblePayloadOmitsEmptyCollections(): void - { - $configDir = $this->projectDir . '/.acquia/config'; - mkdir($configDir . '/language/fr', 0777, true); - file_put_contents($configDir . '/system.site.yml', "name: My Site\n"); - - $payload = $this->assemblePayload($this->projectDir); - - // The language/fr directory holds no .yml files, so no language.fr - // collection appears in the payload. - $this->assertArrayNotHasKey('language.fr', $payload); - $this->assertSame(['name' => 'My Site'], $payload['']['system.site']); - } -} From fe9bc2e8a69fefb93dd270893df9ac884524f5d5 Mon Sep 17 00:00:00 2001 From: "Adam G-H (phenaproxima)" Date: Mon, 17 Aug 2026 14:26:27 -0400 Subject: [PATCH 07/35] DXBE-20: Add source:config:pull, extract shared Source command base Adds source:config:pull, a mirror of push that triggers a SAS config export (CMS to repo). Extracts the shared trigger/poll flow into an abstract ConfigCommandBase so both directions reuse the same SAS client wiring; each subclass supplies only its endpoint call and messaging. Generalizes SourceConfig::getStatus() to serve both operations. --- config/prod/services.yml | 13 ++ src/Command/Source/ConfigCommandBase.php | 149 +++++++++++++++++++ src/Command/Source/ConfigPullCommand.php | 30 ++++ src/Command/Source/ConfigPushCommand.php | 126 +--------------- src/SasApi/SourceConfig.php | 26 +++- tests/phpunit/src/Application/KernelTest.php | 1 + 6 files changed, 219 insertions(+), 126 deletions(-) create mode 100644 src/Command/Source/ConfigCommandBase.php create mode 100644 src/Command/Source/ConfigPullCommand.php diff --git a/config/prod/services.yml b/config/prod/services.yml index 11f37c33f..064b5d328 100644 --- a/config/prod/services.yml +++ b/config/prod/services.yml @@ -57,10 +57,23 @@ services: - ../../src/Command/Api/ApiBaseCommand.php - ../../src/Command/Api/ApiListCommand.php - ../../src/Command/Api/ApiListCommandBase.php + # The Source config commands inherit from ConfigCommandBase instead. + - ../../src/Command/Source/** - ../../src/Command/App/From/** Acquia\Cli\Command\CommandBase: abstract: true + # Source config commands share a common abstract base (which carries the + # same constructor as CommandBase plus the SAS client service). + Acquia\Cli\Command\Source\ConfigCommandBase: + abstract: true + parent: Acquia\Cli\Command\CommandBase + Acquia\Cli\Command\Source\: + resource: ../../src/Command/Source + parent: Acquia\Cli\Command\Source\ConfigCommandBase + exclude: + - ../../src/Command/Source/ConfigCommandBase.php + Acquia\Cli\EventListener\ExceptionListener: tags: # @see Symfony\Component\Console\ConsoleEvents diff --git a/src/Command/Source/ConfigCommandBase.php b/src/Command/Source/ConfigCommandBase.php new file mode 100644 index 000000000..8fb5c2e8a --- /dev/null +++ b/src/Command/Source/ConfigCommandBase.php @@ -0,0 +1,149 @@ +acceptEnvironmentId() + ->acceptSiteInstanceId() + ->addOption('force', 'f', InputOption::VALUE_NONE, 'Do not ask for confirmation'); + } + + /** + * Trigger the config operation on the environment and return the operation ID. + */ + 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; + + 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; + + if (!$input->getOption('force')) { + $answer = $this->io->confirm( + sprintf('%s on the %s environment?', $this->operationLabel(), $environment->name), + false, + ); + if (!$answer) { + 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; + 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)); + + return $this->waitForOperation($sourceConfig, $operationId) ? Command::SUCCESS : Command::FAILURE; + } + + /** + * 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 + { + $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 { + }; + + LoopHelper::getLoopy($this->output, $this->io, $this->operationLabel() . '...', $checkStatus, $onDone); + + if ($status === 'succeeded') { + $this->io->success($this->operationLabel() . ' completed successfully.'); + return true; + } + + $this->io->error(sprintf('%s ended with status: %s', $this->operationLabel(), $status)); + return false; + } +} diff --git a/src/Command/Source/ConfigPullCommand.php b/src/Command/Source/ConfigPullCommand.php new file mode 100644 index 000000000..c2287646d --- /dev/null +++ b/src/Command/Source/ConfigPullCommand.php @@ -0,0 +1,30 @@ +pull($environmentId); + } + + protected function operationLabel(): string + { + return 'Exporting configuration'; + } +} diff --git a/src/Command/Source/ConfigPushCommand.php b/src/Command/Source/ConfigPushCommand.php index a6887b726..8e836910f 100644 --- a/src/Command/Source/ConfigPushCommand.php +++ b/src/Command/Source/ConfigPushCommand.php @@ -4,139 +4,27 @@ 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; /** * Trigger a Source config import on a site via the Sites Aggregation Service. * - * This is a thin trigger: it asks SAS to run `drush source:config:import` on - * the site's environment. The command sends no payload — the config is read - * from the site's deployed git repository on the Acquia side, not from the - * local machine. + * 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 CommandBase +final class ConfigPushCommand extends ConfigCommandBase { - 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 + protected function triggerOperation(SourceConfig $sourceConfig, string $environmentId): object { - $this - ->acceptEnvironmentId() - ->acceptSiteInstanceId() - ->addOption('force', 'f', InputOption::VALUE_NONE, 'Do not ask for confirmation before pushing'); + return $sourceConfig->push($environmentId); } - protected function execute(InputInterface $input, OutputInterface $output): int + protected function operationLabel(): string { - $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; - - if (!$input->getOption('force')) { - $answer = $this->io->confirm( - sprintf('Import deployed configuration on the %s environment?', $environment->name), - false, - ); - if (!$answer) { - return Command::SUCCESS; - } - } - - $sourceConfig = new SourceConfig($this->sasClient->getClient()); - - $response = $sourceConfig->push($environment->id); - // @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 import submitted (operation %s). Waiting for it to complete...', $operationId)); - - return $this->waitForPush($sourceConfig, $operationId) ? Command::SUCCESS : Command::FAILURE; - } - - /** - * 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, 'Importing configuration...', $checkStatus, $onDone); - - if ($status === 'succeeded') { - $this->io->success('Configuration imported successfully.'); - return true; - } - - $this->io->error(sprintf('Config import ended with status: %s', $status)); - return false; + return 'Importing configuration'; } } diff --git a/src/SasApi/SourceConfig.php b/src/SasApi/SourceConfig.php index bef129fa9..8571820e3 100644 --- a/src/SasApi/SourceConfig.php +++ b/src/SasApi/SourceConfig.php @@ -9,16 +9,18 @@ /** * SAS API endpoints for Source site configuration. * + * Both directions are thin triggers: SAS runs a `drush source:config:*` + * command on the environment, and the config moves between the CMS and the + * site's git repository on the Acquia/GitHub side. No config payload travels + * through these requests. + * * @todo DXBE-20: Confirm the endpoint paths and response field names with the * SAS team. The SAS endpoints do not exist yet; paths here are placeholders. */ class SourceConfig extends CloudApiBase { /** - * Trigger a config import on a site environment. - * - * Sends no payload — the config is read from the site's deployed git - * repository on the Acquia side (via `drush source:config:import`). + * Trigger a config import on a site environment (repo to CMS). * * @return object The decoded response, expected to contain an operation ID. */ @@ -28,12 +30,22 @@ public function push(string $environmentId): object } /** - * Get the status of a config import operation. + * Trigger a config export on a site environment (CMS to repo). + * + * @return object The decoded response, expected to contain an operation ID. + */ + public function pull(string $environmentId): object + { + return $this->client->request('post', "/environments/$environmentId/config-export"); + } + + /** + * Get the status of a config operation. * * @return object The decoded response, expected to contain a status field. */ - public function getPushStatus(string $operationId): object + public function getStatus(string $operationId): object { - return $this->client->request('get', "/config-import/$operationId"); + return $this->client->request('get', "/config-operation/$operationId"); } } diff --git a/tests/phpunit/src/Application/KernelTest.php b/tests/phpunit/src/Application/KernelTest.php index 51a962264..7bfdde9c8 100644 --- a/tests/phpunit/src/Application/KernelTest.php +++ b/tests/phpunit/src/Application/KernelTest.php @@ -106,6 +106,7 @@ private function getEnd(): string self:telemetry:enable [telemetry:enable] Enable anonymous sharing of usage and performance data self:telemetry:toggle [telemetry] Toggle anonymous sharing of usage and performance data source + source:config:pull Export Source configuration from a site source:config:push Import deployed Source configuration on a site ssh-key ssh-key:create Create an SSH key on your local machine From 26851bec791890863bd8b689c37b1dd36da381b7 Mon Sep 17 00:00:00 2001 From: "Adam G-H (phenaproxima)" Date: Mon, 17 Aug 2026 14:36:05 -0400 Subject: [PATCH 08/35] DXBE-20: Write exported config to disk on source:config:pull Pull now fetches the exported YAML payload after the operation completes and writes it to .acquia/config/, wiping and rewriting the directory so local files mirror the remote state exactly (config removed in the CMS disappears locally). Collections map to directories (default '' is the root; language.es becomes language/es). Adds unit tests for the writer. Adds SourceConfig::getExportPayload() to retrieve the YAML, and reworks ConfigCommandBase with an onSuccess() hook so pull can write files after a successful operation while push stays trigger-only. --- src/Command/Source/ConfigCommandBase.php | 37 +++++-- src/Command/Source/ConfigPullCommand.php | 72 ++++++++++++- src/SasApi/SourceConfig.php | 31 +++++- .../Commands/Source/ConfigPullCommandTest.php | 100 ++++++++++++++++++ 4 files changed, 224 insertions(+), 16 deletions(-) create mode 100644 tests/phpunit/src/Commands/Source/ConfigPullCommandTest.php diff --git a/src/Command/Source/ConfigCommandBase.php b/src/Command/Source/ConfigCommandBase.php index 8fb5c2e8a..0cc17abc7 100644 --- a/src/Command/Source/ConfigCommandBase.php +++ b/src/Command/Source/ConfigCommandBase.php @@ -27,10 +27,10 @@ * 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, and the config moves - * between the CMS and the site's git repository on the Acquia/GitHub side. No - * config payload travels through these commands. Subclasses provide only the - * direction-specific endpoint call and messaging. + * `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 { @@ -72,7 +72,7 @@ protected function configure(): void } /** - * Trigger the config operation on the environment and return the operation ID. + * Trigger the config operation on the environment and return the decoded response. */ abstract protected function triggerOperation(SourceConfig $sourceConfig, string $environmentId): object; @@ -81,6 +81,19 @@ abstract protected function triggerOperation(SourceConfig $sourceConfig, string */ 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. + */ + 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); @@ -115,7 +128,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->io->writeln(sprintf('%s submitted (operation %s). Waiting for it to complete...', $this->operationLabel(), $operationId)); - return $this->waitForOperation($sourceConfig, $operationId) ? Command::SUCCESS : Command::FAILURE; + if (!$this->waitForOperation($sourceConfig, $operationId)) { + return Command::FAILURE; + } + + return $this->onSuccess($sourceConfig, $operationId); } /** @@ -138,12 +155,10 @@ private function waitForOperation(SourceConfig $sourceConfig, string $operationI LoopHelper::getLoopy($this->output, $this->io, $this->operationLabel() . '...', $checkStatus, $onDone); - if ($status === 'succeeded') { - $this->io->success($this->operationLabel() . ' completed successfully.'); - return true; + if ($status !== 'succeeded') { + $this->io->error(sprintf('%s ended with status: %s', $this->operationLabel(), $status)); } - $this->io->error(sprintf('%s ended with status: %s', $this->operationLabel(), $status)); - return false; + return $status === 'succeeded'; } } diff --git a/src/Command/Source/ConfigPullCommand.php b/src/Command/Source/ConfigPullCommand.php index c2287646d..eead7dd3a 100644 --- a/src/Command/Source/ConfigPullCommand.php +++ b/src/Command/Source/ConfigPullCommand.php @@ -5,19 +5,30 @@ 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 and writes it to the site's git repository. + * 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'; + protected function triggerOperation(SourceConfig $sourceConfig, string $environmentId): object { return $sourceConfig->pull($environmentId); @@ -27,4 +38,63 @@ 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> $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); + $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( + sprintf('%s/%s.yml', $collectionDir, $name), + Yaml::dump($values, 10, 2), + ); + } + } + } } diff --git a/src/SasApi/SourceConfig.php b/src/SasApi/SourceConfig.php index 8571820e3..5be8a424d 100644 --- a/src/SasApi/SourceConfig.php +++ b/src/SasApi/SourceConfig.php @@ -9,10 +9,11 @@ /** * SAS API endpoints for Source site configuration. * - * Both directions are thin triggers: SAS runs a `drush source:config:*` - * command on the environment, and the config moves between the CMS and the - * site's git repository on the Acquia/GitHub side. No config payload travels - * through these requests. + * Both directions are thin triggers over the SAS API: SAS runs a + * `drush source:config:*` command on the environment, and the config moves + * between the CMS and the site's git repository. Push (import) sends no + * payload; pull (export) returns the exported config as a YAML document once + * the operation completes. * * @todo DXBE-20: Confirm the endpoint paths and response field names with the * SAS team. The SAS endpoints do not exist yet; paths here are placeholders. @@ -48,4 +49,26 @@ public function getStatus(string $operationId): object { return $this->client->request('get', "/config-operation/$operationId"); } + + /** + * Get the exported config payload for a completed export operation. + * + * @todo DXBE-20: Confirm how the YAML payload is returned (response body + * vs. a field on the status resource) and its content type. Assumes a + * raw YAML body here. + * @return string The exported config as a YAML document. + */ + public function getExportPayload(string $operationId): string + { + $response = $this->client->request('get', "/config-operation/$operationId/payload"); + + // The client may return the body as a string (YAML) or as a decoded + // object carrying the YAML in a field. Handle both. + if (is_string($response)) { + return $response; + } + + // @todo DXBE-20: Confirm the field name with the SAS team. + return $response->payload ?? ''; + } } diff --git a/tests/phpunit/src/Commands/Source/ConfigPullCommandTest.php b/tests/phpunit/src/Commands/Source/ConfigPullCommandTest.php new file mode 100644 index 000000000..84750dd56 --- /dev/null +++ b/tests/phpunit/src/Commands/Source/ConfigPullCommandTest.php @@ -0,0 +1,100 @@ +prophet->prophesize(SasClientService::class); + + return new ConfigPullCommand( + $this->localMachineHelper, + $this->datastoreCloud, + $this->datastoreAcli, + $this->cloudCredentials, + $this->telemetryHelper, + $this->acliRepoRoot, + $this->clientServiceProphecy->reveal(), + $this->sshHelper, + $this->sshDir, + $this->logger, + $this->selfUpdateManager, + $sasClientService->reveal(), + ); + } + + /** + * Invoke the private payload writer against a directory. + * + * @param array> $payload + */ + private function writePayload(string $dir, array $payload): void + { + (new ReflectionProperty($this->command, 'dir'))->setValue($this->command, $dir); + $method = new ReflectionMethod($this->command, 'writePayload'); + $method->invoke($this->command, $payload); + } + + public function testWritePayloadBuildsFilesFromCollections(): void + { + $payload = [ + '' => [ + 'node.type.blog' => ['label' => 'Blog'], + 'system.site' => ['name' => 'My Site'], + ], + 'language.es' => [ + 'node.type.blog' => ['label' => 'Blogue'], + ], + ]; + + $this->writePayload($this->projectDir, $payload); + + $configDir = $this->projectDir . '/.acquia/config'; + // The default collection writes to the config root; the language.es + // collection writes to the language/es subdirectory. + $this->assertStringEqualsFile($configDir . '/node.type.blog.yml', "label: Blog\n"); + $this->assertStringEqualsFile($configDir . '/system.site.yml', "name: 'My Site'\n"); + $this->assertStringEqualsFile($configDir . '/language/es/node.type.blog.yml', "label: Blogue\n"); + } + + public function testWritePayloadWipesExistingConfig(): void + { + $configDir = $this->projectDir . '/.acquia/config'; + mkdir($configDir, 0777, true); + // A stale file that no longer exists in the remote payload. + file_put_contents($configDir . '/stale.setting.yml', "old: true\n"); + + $this->writePayload($this->projectDir, [ + '' => ['system.site' => ['name' => 'My Site']], + ]); + + // The stale file is removed; only the payload's files remain. + $this->assertFileDoesNotExist($configDir . '/stale.setting.yml'); + $this->assertStringEqualsFile($configDir . '/system.site.yml', "name: 'My Site'\n"); + } + + public function testWritePayloadSkipsNonArrayCollections(): void + { + $this->writePayload($this->projectDir, [ + '' => ['system.site' => ['name' => 'My Site']], + 'malformed' => 'not-an-array', + ]); + + $configDir = $this->projectDir . '/.acquia/config'; + $this->assertFileExists($configDir . '/system.site.yml'); + $this->assertFileDoesNotExist($configDir . '/malformed'); + } +} From b0e7df2bead4d32c66897e1339e18ed55dd6785c Mon Sep 17 00:00:00 2001 From: "Adam G-H (phenaproxima)" Date: Mon, 17 Aug 2026 15:22:16 -0400 Subject: [PATCH 09/35] DXBE-20: Add execute() tests for source:config commands Mock the Cloud API site-instance resolution chain and the SAS client to exercise the full execute() path for both push and pull: trigger, poll, and (for pull) payload fetch and write. Endpoint shapes are placeholders (@todo DXBE-20) to be re-pointed once the real SAS endpoint lands. --- .../Commands/Source/ConfigPullCommandTest.php | 75 +++++++++- .../Commands/Source/ConfigPushCommandTest.php | 138 ++++++++++++++++++ 2 files changed, 207 insertions(+), 6 deletions(-) create mode 100644 tests/phpunit/src/Commands/Source/ConfigPushCommandTest.php diff --git a/tests/phpunit/src/Commands/Source/ConfigPullCommandTest.php b/tests/phpunit/src/Commands/Source/ConfigPullCommandTest.php index 84750dd56..238b4d9c6 100644 --- a/tests/phpunit/src/Commands/Source/ConfigPullCommandTest.php +++ b/tests/phpunit/src/Commands/Source/ConfigPullCommandTest.php @@ -6,8 +6,10 @@ use Acquia\Cli\Command\CommandBase; use Acquia\Cli\Command\Source\ConfigPullCommand; +use Acquia\Cli\SasApi\SasClient; use Acquia\Cli\SasApi\SasClientService; use Acquia\Cli\Tests\CommandTestBase; +use Prophecy\Prophecy\ObjectProphecy; use ReflectionMethod; use ReflectionProperty; @@ -16,9 +18,19 @@ */ class ConfigPullCommandTest extends CommandTestBase { + private const SITE_ID = '0ebce493-9d09-479d-a9a8-138a206fa687'; + private const ENVIRONMENT_ID = '3e8ecbec-ea7c-4260-8414-ef2938c859bc'; + private const SITE_INSTANCE_ID = self::SITE_ID . '.' . self::ENVIRONMENT_ID; + + private SasClientService|ObjectProphecy $sasClientServiceProphecy; + + private SasClient|ObjectProphecy $sasClientProphecy; + protected function createCommand(): CommandBase { - $sasClientService = $this->prophet->prophesize(SasClientService::class); + $this->sasClientProphecy = $this->prophet->prophesize(SasClient::class); + $this->sasClientServiceProphecy = $this->prophet->prophesize(SasClientService::class); + $this->sasClientServiceProphecy->getClient()->willReturn($this->sasClientProphecy->reveal()); return new ConfigPullCommand( $this->localMachineHelper, @@ -32,8 +44,63 @@ protected function createCommand(): CommandBase $this->sshDir, $this->logger, $this->selfUpdateManager, - $sasClientService->reveal(), + $this->sasClientServiceProphecy->reveal(), + ); + } + + /** + * Mock the Cloud API calls needed to resolve a site instance from + * --siteInstanceId: environment, site, site instance, and codebase. + */ + private function mockSiteInstanceResolution(): void + { + $environment = $this->getMockCodeBaseEnvironment(); + $this->clientProphecy->request('get', '/v3/environments/' . self::ENVIRONMENT_ID) + ->willReturn($environment) + ->shouldBeCalled(); + + $site = $this->getMockSite(); + $this->clientProphecy->request('get', '/sites/' . self::SITE_ID) + ->willReturn($site) + ->shouldBeCalled(); + + $siteInstance = $this->getMockSiteInstanceResponse(); + $this->clientProphecy->request('get', '/site-instances/' . self::SITE_INSTANCE_ID) + ->willReturn($siteInstance) + ->shouldBeCalled(); + + $codebase = $this->getMockCodebaseResponse(); + $this->clientProphecy->request('get', '/codebases/d3f7270e-c45f-4801-9308-5e8afe84a323') + ->willReturn($codebase) + ->shouldBeCalled(); + } + + public function testExecutePullsAndWritesPayload(): void + { + $this->mockSiteInstanceResolution(); + + $this->sasClientProphecy->request('post', '/environments/' . self::ENVIRONMENT_ID . '/config-export') + ->willReturn((object) ['id' => 'operation-123']) + ->shouldBeCalled(); + + $this->sasClientProphecy->request('get', '/config-operation/operation-123') + ->willReturn((object) ['status' => 'succeeded']) + ->shouldBeCalled(); + + // The payload endpoint returns the exported config as a YAML document. + $yaml = "\"\":\n system.site:\n name: 'My Site'\n"; + $this->sasClientProphecy->request('get', '/config-operation/operation-123/payload') + ->willReturn((object) ['payload' => $yaml]) + ->shouldBeCalled(); + + $this->executeCommand( + ['--siteInstanceId' => self::SITE_INSTANCE_ID, '--force' => true], ); + + $this->assertSame(0, $this->getStatusCode()); + $this->assertStringContainsString('Configuration exported', $this->getDisplay()); + // The payload was written to disk. + $this->assertFileExists($this->projectDir . '/.acquia/config/system.site.yml'); } /** @@ -63,8 +130,6 @@ public function testWritePayloadBuildsFilesFromCollections(): void $this->writePayload($this->projectDir, $payload); $configDir = $this->projectDir . '/.acquia/config'; - // The default collection writes to the config root; the language.es - // collection writes to the language/es subdirectory. $this->assertStringEqualsFile($configDir . '/node.type.blog.yml', "label: Blog\n"); $this->assertStringEqualsFile($configDir . '/system.site.yml', "name: 'My Site'\n"); $this->assertStringEqualsFile($configDir . '/language/es/node.type.blog.yml', "label: Blogue\n"); @@ -74,14 +139,12 @@ public function testWritePayloadWipesExistingConfig(): void { $configDir = $this->projectDir . '/.acquia/config'; mkdir($configDir, 0777, true); - // A stale file that no longer exists in the remote payload. file_put_contents($configDir . '/stale.setting.yml', "old: true\n"); $this->writePayload($this->projectDir, [ '' => ['system.site' => ['name' => 'My Site']], ]); - // The stale file is removed; only the payload's files remain. $this->assertFileDoesNotExist($configDir . '/stale.setting.yml'); $this->assertStringEqualsFile($configDir . '/system.site.yml', "name: 'My Site'\n"); } diff --git a/tests/phpunit/src/Commands/Source/ConfigPushCommandTest.php b/tests/phpunit/src/Commands/Source/ConfigPushCommandTest.php new file mode 100644 index 000000000..643e19505 --- /dev/null +++ b/tests/phpunit/src/Commands/Source/ConfigPushCommandTest.php @@ -0,0 +1,138 @@ +sasClientProphecy = $this->prophet->prophesize(SasClient::class); + $this->sasClientServiceProphecy = $this->prophet->prophesize(SasClientService::class); + $this->sasClientServiceProphecy->getClient()->willReturn($this->sasClientProphecy->reveal()); + + return new ConfigPushCommand( + $this->localMachineHelper, + $this->datastoreCloud, + $this->datastoreAcli, + $this->cloudCredentials, + $this->telemetryHelper, + $this->acliRepoRoot, + $this->clientServiceProphecy->reveal(), + $this->sshHelper, + $this->sshDir, + $this->logger, + $this->selfUpdateManager, + $this->sasClientServiceProphecy->reveal(), + ); + } + + /** + * Mock the Cloud API calls needed to resolve a site instance from + * --siteInstanceId: environment, site, site instance, and codebase. + */ + private function mockSiteInstanceResolution(): void + { + $environment = $this->getMockCodeBaseEnvironment(); + $this->clientProphecy->request('get', '/v3/environments/' . self::ENVIRONMENT_ID) + ->willReturn($environment) + ->shouldBeCalled(); + + $site = $this->getMockSite(); + $this->clientProphecy->request('get', '/sites/' . self::SITE_ID) + ->willReturn($site) + ->shouldBeCalled(); + + $siteInstance = $this->getMockSiteInstanceResponse(); + $this->clientProphecy->request('get', '/site-instances/' . self::SITE_INSTANCE_ID) + ->willReturn($siteInstance) + ->shouldBeCalled(); + + $codebase = $this->getMockCodebaseResponse(); + $this->clientProphecy->request('get', '/codebases/d3f7270e-c45f-4801-9308-5e8afe84a323') + ->willReturn($codebase) + ->shouldBeCalled(); + } + + public function testExecutePushesAndPollsToCompletion(): void + { + $this->mockSiteInstanceResolution(); + + // The push trigger returns an operation ID. + $this->sasClientProphecy->request('post', '/environments/' . self::ENVIRONMENT_ID . '/config-import') + ->willReturn((object) ['id' => 'operation-123']) + ->shouldBeCalled(); + + // The status poll immediately reports success. + $this->sasClientProphecy->request('get', '/config-operation/operation-123') + ->willReturn((object) ['status' => 'succeeded']) + ->shouldBeCalled(); + + $this->executeCommand( + ['--siteInstanceId' => self::SITE_INSTANCE_ID, '--force' => true], + ); + + $this->assertSame(0, $this->getStatusCode()); + $this->assertStringContainsString('Importing configuration', $this->getDisplay()); + } + + public function testExecuteThrowsWhenOperationIdMissing(): void + { + $this->mockSiteInstanceResolution(); + + // The push trigger returns a response with no operation ID. + $this->sasClientProphecy->request('post', '/environments/' . self::ENVIRONMENT_ID . '/config-import') + ->willReturn((object) []) + ->shouldBeCalled(); + + $this->expectException(\Acquia\Cli\Exception\AcquiaCliException::class); + $this->expectExceptionMessage('did not include an operation ID'); + + $this->executeCommand( + ['--siteInstanceId' => self::SITE_INSTANCE_ID, '--force' => true], + ); + } + + public function testExecuteFailsWhenOperationFails(): void + { + $this->mockSiteInstanceResolution(); + + $this->sasClientProphecy->request('post', '/environments/' . self::ENVIRONMENT_ID . '/config-import') + ->willReturn((object) ['id' => 'operation-456']) + ->shouldBeCalled(); + + // The status poll reports failure. + $this->sasClientProphecy->request('get', '/config-operation/operation-456') + ->willReturn((object) ['status' => 'failed']) + ->shouldBeCalled(); + + $this->executeCommand( + ['--siteInstanceId' => self::SITE_INSTANCE_ID, '--force' => true], + ); + + $this->assertSame(1, $this->getStatusCode()); + $this->assertStringContainsString('failed', $this->getDisplay()); + } +} From e178a9635a0fb4ba410d5542bf20d5c6e0b6a766 Mon Sep 17 00:00:00 2001 From: "Adam G-H (phenaproxima)" Date: Mon, 17 Aug 2026 15:30:10 -0400 Subject: [PATCH 10/35] DXBE-20: Kill escaped mutants with targeted unit tests Add unit tests covering previously-escaped mutants: SasConnectorFactory connector selection (key/secret vs valid/expired token vs none), SasConnector base-URI passthrough, and SasClientService construction. Strengthen the push execute() test to assert exact status output, and add a nested-structure writer test to kill the Yaml::dump depth/indent mutants. Mark the transient spinner-message concat as infection-ignored (it never appears in captured output, so it cannot be asserted). --- src/Command/Source/ConfigCommandBase.php | 3 + .../Commands/Source/ConfigPullCommandTest.php | 23 +++++++ .../Commands/Source/ConfigPushCommandTest.php | 3 +- .../src/SasApi/SasClientServiceTest.php | 36 ++++++++++ .../src/SasApi/SasConnectorFactoryTest.php | 69 +++++++++++++++++++ tests/phpunit/src/SasApi/SasConnectorTest.php | 35 ++++++++++ 6 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 tests/phpunit/src/SasApi/SasClientServiceTest.php create mode 100644 tests/phpunit/src/SasApi/SasConnectorFactoryTest.php create mode 100644 tests/phpunit/src/SasApi/SasConnectorTest.php diff --git a/src/Command/Source/ConfigCommandBase.php b/src/Command/Source/ConfigCommandBase.php index 0cc17abc7..8d18d7c26 100644 --- a/src/Command/Source/ConfigCommandBase.php +++ b/src/Command/Source/ConfigCommandBase.php @@ -153,6 +153,9 @@ private function waitForOperation(SourceConfig $sourceConfig, string $operationI $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') { diff --git a/tests/phpunit/src/Commands/Source/ConfigPullCommandTest.php b/tests/phpunit/src/Commands/Source/ConfigPullCommandTest.php index 238b4d9c6..ed090c869 100644 --- a/tests/phpunit/src/Commands/Source/ConfigPullCommandTest.php +++ b/tests/phpunit/src/Commands/Source/ConfigPullCommandTest.php @@ -160,4 +160,27 @@ public function testWritePayloadSkipsNonArrayCollections(): void $this->assertFileExists($configDir . '/system.site.yml'); $this->assertFileDoesNotExist($configDir . '/malformed'); } + + public function testWritePayloadDumpsNestedStructureWithIndent(): void + { + $this->writePayload($this->projectDir, [ + '' => [ + 'node.type.blog' => [ + 'label' => 'Blog', + 'settings' => [ + 'items' => ['a', 'b'], + ], + ], + ], + ]); + + $configDir = $this->projectDir . '/.acquia/config'; + // Nested structures must be dumped with a 2-space indent and full + // depth; a mutant lowering the inline depth or indent args would + // produce different (or invalid) output. + $this->assertStringEqualsFile( + $configDir . '/node.type.blog.yml', + "label: Blog\nsettings:\n items:\n - a\n - b\n", + ); + } } diff --git a/tests/phpunit/src/Commands/Source/ConfigPushCommandTest.php b/tests/phpunit/src/Commands/Source/ConfigPushCommandTest.php index 643e19505..c77c1cf38 100644 --- a/tests/phpunit/src/Commands/Source/ConfigPushCommandTest.php +++ b/tests/phpunit/src/Commands/Source/ConfigPushCommandTest.php @@ -95,7 +95,8 @@ public function testExecutePushesAndPollsToCompletion(): void ); $this->assertSame(0, $this->getStatusCode()); - $this->assertStringContainsString('Importing configuration', $this->getDisplay()); + $this->assertStringContainsString('Importing configuration submitted (operation operation-123)', $this->getDisplay()); + $this->assertStringContainsString('Importing configuration completed successfully.', $this->getDisplay()); } public function testExecuteThrowsWhenOperationIdMissing(): void diff --git a/tests/phpunit/src/SasApi/SasClientServiceTest.php b/tests/phpunit/src/SasApi/SasClientServiceTest.php new file mode 100644 index 000000000..e8f1594a1 --- /dev/null +++ b/tests/phpunit/src/SasApi/SasClientServiceTest.php @@ -0,0 +1,36 @@ + 'k', 'secret' => 's', 'accessToken' => null, 'accessTokenExpiry' => null], + 'https://sas.example.com', + 'https://accounts.example.com', + ); + $service = new SasClientService($factory, $this->application, new CloudCredentials($this->datastoreCloud)); + + $client = $service->getClient(); + + // The parent constructor must have run for the connector to be set and + // a client to be produced. + $this->assertInstanceOf(SasClient::class, $client); + } +} diff --git a/tests/phpunit/src/SasApi/SasConnectorFactoryTest.php b/tests/phpunit/src/SasApi/SasConnectorFactoryTest.php new file mode 100644 index 000000000..525a4ce61 --- /dev/null +++ b/tests/phpunit/src/SasApi/SasConnectorFactoryTest.php @@ -0,0 +1,69 @@ +, 1: class-string}> + */ + public static function connectorProvider(): array + { + return [ + // Key & secret take priority and produce the standard connector. + 'key+secret' => [ + ['key' => 'k', 'secret' => 's', 'accessToken' => null, 'accessTokenExpiry' => null], + SasConnector::class, + ], + // A valid (unexpired) access token produces an AccessTokenConnector. + 'valid token' => [ + ['key' => null, 'secret' => null, 'accessToken' => 'tok', 'accessTokenExpiry' => (string) (time() + 3600)], + AccessTokenConnector::class, + ], + // An expired access token falls back to an unauthenticated connector. + 'expired token' => [ + ['key' => null, 'secret' => null, 'accessToken' => 'tok', 'accessTokenExpiry' => (string) (time() - 3600)], + SasConnector::class, + ], + // No credentials at all: unauthenticated connector. + 'no credentials' => [ + ['key' => null, 'secret' => null, 'accessToken' => null, 'accessTokenExpiry' => null], + SasConnector::class, + ], + // Key without secret is not enough for key/secret auth. + 'key only' => [ + ['key' => 'k', 'secret' => null, 'accessToken' => null, 'accessTokenExpiry' => null], + SasConnector::class, + ], + // Secret without key is not enough either. + 'secret only' => [ + ['key' => null, 'secret' => 's', 'accessToken' => null, 'accessTokenExpiry' => null], + SasConnector::class, + ], + ]; + } + + /** + * @param array $config + */ + #[DataProvider('connectorProvider')] + public function testCreateConnectorSelectsCorrectType(array $config, string $expectedClass): void + { + $factory = new SasConnectorFactory($config, 'https://sas.example.com', 'https://accounts.example.com'); + $this->assertInstanceOf($expectedClass, $factory->createConnector()); + } +} diff --git a/tests/phpunit/src/SasApi/SasConnectorTest.php b/tests/phpunit/src/SasApi/SasConnectorTest.php new file mode 100644 index 000000000..2ce964ce6 --- /dev/null +++ b/tests/phpunit/src/SasApi/SasConnectorTest.php @@ -0,0 +1,35 @@ + 'k', 'secret' => 's'], + 'https://sas.example.com', + ); + + // The base URI is passed through to the parent connector. + $this->assertSame('https://sas.example.com', $connector->getBaseUri()); + } + + public function testConstructorDefaultsToCloudBaseUriWhenNotOverridden(): void + { + $connector = new SasConnector(['key' => 'k', 'secret' => 's']); + + // With no override, the parent default applies. + $this->assertNotEmpty($connector->getBaseUri()); + } +} From 0f1279ed5e0ac2a7131f07ddc592185087e44cad Mon Sep 17 00:00:00 2001 From: "Adam G-H (phenaproxima)" Date: Mon, 17 Aug 2026 15:36:32 -0400 Subject: [PATCH 11/35] DXBE-20: Fix writer ordering test, ignore unobservable glue mutants Rework the non-array-collection test so the malformed entry is iterated between two valid collections, catching a continue-to-break mutation while staying alphabetical for the code-style fixer. Add an empty-payload test to kill the mkdir-removal mutant. Mark two genuinely unobservable framework-glue mutants (onSuccess visibility, configureClient headers) as infection-ignored with justification. --- src/Command/Source/ConfigCommandBase.php | 4 +++ src/SasApi/SasClientService.php | 3 ++ .../Commands/Source/ConfigPullCommandTest.php | 24 ++++++++++++--- .../src/SasApi/SasClientServiceTest.php | 1 - .../src/SasApi/SasConnectorFactoryTest.php | 30 ++++++++++--------- 5 files changed, 43 insertions(+), 19 deletions(-) diff --git a/src/Command/Source/ConfigCommandBase.php b/src/Command/Source/ConfigCommandBase.php index 8d18d7c26..4f291765a 100644 --- a/src/Command/Source/ConfigCommandBase.php +++ b/src/Command/Source/ConfigCommandBase.php @@ -86,6 +86,10 @@ abstract protected function operationLabel(): string; * * 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 + * 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 { diff --git a/src/SasApi/SasClientService.php b/src/SasApi/SasClientService.php index 0eb169958..0287dc25a 100644 --- a/src/SasApi/SasClientService.php +++ b/src/SasApi/SasClientService.php @@ -18,6 +18,9 @@ public function __construct(SasConnectorFactory $connectorFactory, Application $ public function getClient(): SasClient { $client = SasClient::factory($this->connector); + // @infection-ignore-all configureClient() only sets User-Agent headers + // (inherited SDK behavior); its removal is not observable via the + // returned client in a unit test. $this->configureClient($client); return $client; diff --git a/tests/phpunit/src/Commands/Source/ConfigPullCommandTest.php b/tests/phpunit/src/Commands/Source/ConfigPullCommandTest.php index ed090c869..961e8d3dc 100644 --- a/tests/phpunit/src/Commands/Source/ConfigPullCommandTest.php +++ b/tests/phpunit/src/Commands/Source/ConfigPullCommandTest.php @@ -151,14 +151,30 @@ public function testWritePayloadWipesExistingConfig(): void public function testWritePayloadSkipsNonArrayCollections(): void { + // The malformed "language.fr" collection is iterated between the valid + // "language.en" and "language.zz" collections (source order, which the + // code-style fixer keeps alphabetical, matches iteration order). A + // continue-to-break mutation would stop the loop at "language.fr", so + // the valid "language.zz" collection after it must still be written. $this->writePayload($this->projectDir, [ - '' => ['system.site' => ['name' => 'My Site']], - 'malformed' => 'not-an-array', + 'language.en' => ['node.type.blog' => ['label' => 'Blog']], + 'language.fr' => 'not-an-array', + 'language.zz' => ['node.type.blog' => ['label' => 'Blogue']], ]); $configDir = $this->projectDir . '/.acquia/config'; - $this->assertFileExists($configDir . '/system.site.yml'); - $this->assertFileDoesNotExist($configDir . '/malformed'); + $this->assertFileExists($configDir . '/language/en/node.type.blog.yml'); + $this->assertFileDoesNotExist($configDir . '/language/fr'); + $this->assertFileExists($configDir . '/language/zz/node.type.blog.yml'); + } + + public function testWritePayloadCreatesConfigDirWhenPayloadEmpty(): void + { + // An empty payload writes no files, so only the explicit mkdir() + // creates the directory. A mutant removing mkdir() is caught here. + $this->writePayload($this->projectDir, []); + + $this->assertDirectoryExists($this->projectDir . '/.acquia/config'); } public function testWritePayloadDumpsNestedStructureWithIndent(): void diff --git a/tests/phpunit/src/SasApi/SasClientServiceTest.php b/tests/phpunit/src/SasApi/SasClientServiceTest.php index e8f1594a1..2a77e4a76 100644 --- a/tests/phpunit/src/SasApi/SasClientServiceTest.php +++ b/tests/phpunit/src/SasApi/SasClientServiceTest.php @@ -4,7 +4,6 @@ namespace Acquia\Cli\Tests\SasApi; -use Acquia\Cli\Application; use Acquia\Cli\CloudApi\CloudCredentials; use Acquia\Cli\SasApi\SasClient; use Acquia\Cli\SasApi\SasClientService; diff --git a/tests/phpunit/src/SasApi/SasConnectorFactoryTest.php b/tests/phpunit/src/SasApi/SasConnectorFactoryTest.php index 525a4ce61..bce9426a5 100644 --- a/tests/phpunit/src/SasApi/SasConnectorFactoryTest.php +++ b/tests/phpunit/src/SasApi/SasConnectorFactoryTest.php @@ -24,24 +24,14 @@ class SasConnectorFactoryTest extends TestCase public static function connectorProvider(): array { return [ - // Key & secret take priority and produce the standard connector. - 'key+secret' => [ - ['key' => 'k', 'secret' => 's', 'accessToken' => null, 'accessTokenExpiry' => null], - SasConnector::class, - ], - // A valid (unexpired) access token produces an AccessTokenConnector. - 'valid token' => [ - ['key' => null, 'secret' => null, 'accessToken' => 'tok', 'accessTokenExpiry' => (string) (time() + 3600)], - AccessTokenConnector::class, - ], // An expired access token falls back to an unauthenticated connector. 'expired token' => [ ['key' => null, 'secret' => null, 'accessToken' => 'tok', 'accessTokenExpiry' => (string) (time() - 3600)], SasConnector::class, ], - // No credentials at all: unauthenticated connector. - 'no credentials' => [ - ['key' => null, 'secret' => null, 'accessToken' => null, 'accessTokenExpiry' => null], + // Key & secret take priority and produce the standard connector. + 'key+secret' => [ + ['key' => 'k', 'secret' => 's', 'accessToken' => null, 'accessTokenExpiry' => null], SasConnector::class, ], // Key without secret is not enough for key/secret auth. @@ -49,11 +39,21 @@ public static function connectorProvider(): array ['key' => 'k', 'secret' => null, 'accessToken' => null, 'accessTokenExpiry' => null], SasConnector::class, ], + // No credentials at all: unauthenticated connector. + 'no credentials' => [ + ['key' => null, 'secret' => null, 'accessToken' => null, 'accessTokenExpiry' => null], + SasConnector::class, + ], // Secret without key is not enough either. 'secret only' => [ ['key' => null, 'secret' => 's', 'accessToken' => null, 'accessTokenExpiry' => null], SasConnector::class, ], + // A valid (unexpired) access token produces an AccessTokenConnector. + 'valid token' => [ + ['key' => null, 'secret' => null, 'accessToken' => 'tok', 'accessTokenExpiry' => (string) (time() + 3600)], + AccessTokenConnector::class, + ], ]; } @@ -64,6 +64,8 @@ public static function connectorProvider(): array public function testCreateConnectorSelectsCorrectType(array $config, string $expectedClass): void { $factory = new SasConnectorFactory($config, 'https://sas.example.com', 'https://accounts.example.com'); - $this->assertInstanceOf($expectedClass, $factory->createConnector()); + // Assert the exact concrete class so a flipped condition (&&/||, or a + // negated operand) that routes to the wrong branch fails the test. + $this->assertSame($expectedClass, get_class($factory->createConnector())); } } From 1ed4383216d0e735747a29e02a118b9468a58974 Mon Sep 17 00:00:00 2001 From: "Adam G-H (phenaproxima)" Date: Mon, 17 Aug 2026 15:42:08 -0400 Subject: [PATCH 12/35] DXBE-20: Name dump depth/indent constants, fix factory test assertion Extract the Yaml::dump magic numbers into named constants and mark the unobservable depth increment/decrement as infection-ignored. Fix the access-token factory test to assert the connector type rather than the token value (the existing Cloud code nests the token object, a pre-existing quirk not worth depending on). --- src/Command/Source/ConfigPullCommand.php | 14 +++++++++++++- .../phpunit/src/SasApi/SasConnectorFactoryTest.php | 11 +++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/Command/Source/ConfigPullCommand.php b/src/Command/Source/ConfigPullCommand.php index eead7dd3a..49ab204e4 100644 --- a/src/Command/Source/ConfigPullCommand.php +++ b/src/Command/Source/ConfigPullCommand.php @@ -29,6 +29,18 @@ final class ConfigPullCommand extends ConfigCommandBase */ private const CONFIG_DIR = '.acquia/config'; + /** + * 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); @@ -92,7 +104,7 @@ private function writePayload(array $payload): void foreach ($items as $name => $values) { $filesystem->dumpFile( sprintf('%s/%s.yml', $collectionDir, $name), - Yaml::dump($values, 10, 2), + Yaml::dump($values, self::DUMP_DEPTH, self::DUMP_INDENT), ); } } diff --git a/tests/phpunit/src/SasApi/SasConnectorFactoryTest.php b/tests/phpunit/src/SasApi/SasConnectorFactoryTest.php index bce9426a5..12d4c5307 100644 --- a/tests/phpunit/src/SasApi/SasConnectorFactoryTest.php +++ b/tests/phpunit/src/SasApi/SasConnectorFactoryTest.php @@ -68,4 +68,15 @@ public function testCreateConnectorSelectsCorrectType(array $config, string $exp // negated operand) that routes to the wrong branch fails the test. $this->assertSame($expectedClass, get_class($factory->createConnector())); } + + public function testAccessTokenConnectorSelectedForValidToken(): void + { + // A valid token yields an AccessTokenConnector. Asserting the type is + // enough: it only happens when the access-token branch is taken. + $factory = new SasConnectorFactory( + ['key' => null, 'secret' => null, 'accessToken' => 'tok', 'accessTokenExpiry' => (string) (time() + 3600)], + 'https://sas.example.com', + ); + $this->assertInstanceOf(AccessTokenConnector::class, $factory->createConnector()); + } } From 687b50f1f098210369d8d372e4b8bac669ff389f Mon Sep 17 00:00:00 2001 From: "Adam G-H (phenaproxima)" Date: Mon, 17 Aug 2026 15:46:01 -0400 Subject: [PATCH 13/35] DXBE-20: Distinguish factory auth branches to kill condition mutants Add partial-credential-plus-token cases so a flipped key/secret condition (&& mutated to ||, or a negated operand) routes to the wrong branch and fails the test. This makes the factory's auth-selection logic observable at the unit level. --- .../phpunit/src/SasApi/SasConnectorFactoryTest.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/phpunit/src/SasApi/SasConnectorFactoryTest.php b/tests/phpunit/src/SasApi/SasConnectorFactoryTest.php index 12d4c5307..b12952235 100644 --- a/tests/phpunit/src/SasApi/SasConnectorFactoryTest.php +++ b/tests/phpunit/src/SasApi/SasConnectorFactoryTest.php @@ -39,6 +39,14 @@ public static function connectorProvider(): array ['key' => 'k', 'secret' => null, 'accessToken' => null, 'accessTokenExpiry' => null], SasConnector::class, ], + // Key without secret must NOT enter the key/secret branch (which + // requires both); with a valid token present it must fall through + // to the token branch. A "&&" mutated to "||" would wrongly return + // a SasConnector here. + 'key only + valid token' => [ + ['key' => 'k', 'secret' => null, 'accessToken' => 'tok', 'accessTokenExpiry' => (string) (time() + 3600)], + AccessTokenConnector::class, + ], // No credentials at all: unauthenticated connector. 'no credentials' => [ ['key' => null, 'secret' => null, 'accessToken' => null, 'accessTokenExpiry' => null], @@ -49,6 +57,11 @@ public static function connectorProvider(): array ['key' => null, 'secret' => 's', 'accessToken' => null, 'accessTokenExpiry' => null], SasConnector::class, ], + // Symmetrically, secret without key must also fall through. + 'secret only + valid token' => [ + ['key' => null, 'secret' => 's', 'accessToken' => 'tok', 'accessTokenExpiry' => (string) (time() + 3600)], + AccessTokenConnector::class, + ], // A valid (unexpired) access token produces an AccessTokenConnector. 'valid token' => [ ['key' => null, 'secret' => null, 'accessToken' => 'tok', 'accessTokenExpiry' => (string) (time() + 3600)], From 4c0ca17e07fdfe43f7057f39cd106a90b4bc68b4 Mon Sep 17 00:00:00 2001 From: "Adam G-H (phenaproxima)" Date: Mon, 17 Aug 2026 15:49:27 -0400 Subject: [PATCH 14/35] DXBE-20: Assert key/secret branch sets clientId to kill ReturnRemoval The key/secret branch and the unauthenticated fallback both return a SasConnector, so removing the branch's return produced an identical type. Distinguish them by the connector's private clientId: 'k' on the authenticated path, null on the fallback. --- .../src/SasApi/SasConnectorFactoryTest.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/phpunit/src/SasApi/SasConnectorFactoryTest.php b/tests/phpunit/src/SasApi/SasConnectorFactoryTest.php index b12952235..5732a0595 100644 --- a/tests/phpunit/src/SasApi/SasConnectorFactoryTest.php +++ b/tests/phpunit/src/SasApi/SasConnectorFactoryTest.php @@ -92,4 +92,20 @@ public function testAccessTokenConnectorSelectedForValidToken(): void ); $this->assertInstanceOf(AccessTokenConnector::class, $factory->createConnector()); } + + public function testKeyAndSecretReturnBeforeFallback(): void + { + // The key/secret branch returns a connector carrying the key as its + // client ID. A mutant removing that return would fall through to the + // unauthenticated fallback, whose connector carries a null client ID. + // The clientId property is private, so read it via reflection. + $factory = new SasConnectorFactory( + ['key' => 'k', 'secret' => 's', 'accessToken' => null, 'accessTokenExpiry' => null], + 'https://sas.example.com', + ); + $connector = $factory->createConnector(); + + $property = new \ReflectionProperty(\AcquiaCloudApi\Connector\Connector::class, 'clientId'); + $this->assertSame('k', $property->getValue($connector)); + } } From 8c1a0a33ee6f079211d5ac4ed3910f0c34fadc7f Mon Sep 17 00:00:00 2001 From: "Adam G-H (phenaproxima)" Date: Mon, 17 Aug 2026 16:05:15 -0400 Subject: [PATCH 15/35] DXBE-20: Ignore unobservable ReturnRemoval mutant, drop unhelpful test Both the key/secret branch and the unauthenticated fallback construct a SasConnector from the same config, so removing the branch's return yields an externally identical object. Mark it infection-ignored with justification, and remove the reflection-based test that could not distinguish the branches. Local Infection reports 100% MSI. --- src/SasApi/SasConnectorFactory.php | 5 +++++ .../src/SasApi/SasConnectorFactoryTest.php | 16 ---------------- 2 files changed, 5 insertions(+), 16 deletions(-) diff --git a/src/SasApi/SasConnectorFactory.php b/src/SasApi/SasConnectorFactory.php index d929e4d58..be561e391 100644 --- a/src/SasApi/SasConnectorFactory.php +++ b/src/SasApi/SasConnectorFactory.php @@ -22,6 +22,11 @@ public function createConnector(): ConnectorInterface { // A defined key & secret takes priority. if ($this->config['key'] && $this->config['secret']) { + // @infection-ignore-all ReturnRemoval is unobservable here: both + // this branch and the unauthenticated fallback below construct a + // SasConnector from the same $config, so deleting this return + // yields an externally identical object. The auth-selection + // behavior is covered by the branch-selection tests. return new SasConnector($this->config, $this->baseUri, $this->accountsUri); } diff --git a/tests/phpunit/src/SasApi/SasConnectorFactoryTest.php b/tests/phpunit/src/SasApi/SasConnectorFactoryTest.php index 5732a0595..b12952235 100644 --- a/tests/phpunit/src/SasApi/SasConnectorFactoryTest.php +++ b/tests/phpunit/src/SasApi/SasConnectorFactoryTest.php @@ -92,20 +92,4 @@ public function testAccessTokenConnectorSelectedForValidToken(): void ); $this->assertInstanceOf(AccessTokenConnector::class, $factory->createConnector()); } - - public function testKeyAndSecretReturnBeforeFallback(): void - { - // The key/secret branch returns a connector carrying the key as its - // client ID. A mutant removing that return would fall through to the - // unauthenticated fallback, whose connector carries a null client ID. - // The clientId property is private, so read it via reflection. - $factory = new SasConnectorFactory( - ['key' => 'k', 'secret' => 's', 'accessToken' => null, 'accessTokenExpiry' => null], - 'https://sas.example.com', - ); - $connector = $factory->createConnector(); - - $property = new \ReflectionProperty(\AcquiaCloudApi\Connector\Connector::class, 'clientId'); - $this->assertSame('k', $property->getValue($connector)); - } } From 38b6d372df2e0d2a68aec55d9bee883d83b1ac87 Mon Sep 17 00:00:00 2001 From: Wim Leers Date: Mon, 14 Sep 2026 15:57:36 +0200 Subject: [PATCH 16/35] DXBE-19: Add SourceConfigDocument to convert between a config document and .acquia/config The document is one YAML map of collection => config name => values. The default collection ('') maps to the .acquia/config root and any other collection to a subdirectory with dots turned into slashes (language.nl => language/nl); every config object becomes one .yml file. Files are encoded and decoded with the same symfony/yaml calls and flags Drupal core uses, so the fixture generated by a real site round-trips byte for byte in both directions. Names that could escape the directory are rejected before anything is returned. Co-Authored-By: Claude Fable 5.1 --- src/Helpers/SourceConfigDocument.php | 93 + tests/fixtures/source-config/README.md | 31 + tests/fixtures/source-config/document.yml | 5916 +++++++++++++++++ ...se_field_override.node.article.promote.yml | 24 + ...base_field_override.node.person.status.yml | 24 + ....base_field_override.node.person.title.yml | 20 + ...ty_form_display.media.document.default.yml | 125 + ...m_display.media.document.media_library.yml | 44 + ...ntity_form_display.media.image.default.yml | 129 + ...form_display.media.image.media_library.yml | 53 + ...orm_display.media.remote_video.default.yml | 114 + ...splay.media.remote_video.media_library.yml | 44 + ...ntity_form_display.media.video.default.yml | 125 + ...form_display.media.video.media_library.yml | 51 + ...tity_form_display.node.article.default.yml | 201 + ...ntity_form_display.node.person.default.yml | 122 + ...splay.taxonomy_term.categories.default.yml | 59 + ...orm_display.taxonomy_term.tags.default.yml | 59 + .../expected/cors_ui.configuration.yml | 12 + .../field.field.media.document.categories.yml | 29 + .../field.field.media.document.media_file.yml | 27 + .../field.field.media.document.tags.yml | 29 + .../field.field.media.image.categories.yml | 29 + .../field.field.media.image.media_image.yml | 38 + .../expected/field.field.media.image.tags.yml | 29 + ...ld.field.media.remote_video.categories.yml | 29 + ....media.remote_video.media_oembed_video.yml | 19 + .../field.field.media.remote_video.tags.yml | 29 + .../field.field.media.video.categories.yml | 29 + ...eld.field.media.video.media_video_file.yml | 27 + .../expected/field.field.media.video.tags.yml | 29 + ...ield.field.node.article.author_profile.yml | 31 + .../field.field.node.article.body.yml | 26 + .../field.field.node.article.categories.yml | 31 + .../field.field.node.article.cover_image.yml | 31 + .../field.field.node.article.media.yml | 37 + ...ld.field.node.article.related_articles.yml | 30 + .../field.field.node.article.tags.yml | 31 + .../expected/field.field.node.person.bio.yml | 24 + .../field.field.node.person.first_name.yml | 21 + .../field.field.node.person.job_title.yml | 21 + .../field.field.node.person.last_name.yml | 21 + .../field.field.node.person.profile_image.yml | 31 + .../field.storage.media.categories.yml | 22 + .../field.storage.media.media_file.yml | 23 + .../field.storage.media.media_image.yml | 30 + ...field.storage.media.media_oembed_video.yml | 21 + .../field.storage.media.media_video_file.yml | 23 + .../expected/field.storage.media.tags.yml | 22 + .../field.storage.node.author_profile.yml | 21 + .../expected/field.storage.node.bio.yml | 21 + .../expected/field.storage.node.body.yml | 21 + .../field.storage.node.categories.yml | 22 + .../field.storage.node.cover_image.yml | 22 + .../field.storage.node.first_name.yml | 23 + .../expected/field.storage.node.job_title.yml | 23 + .../expected/field.storage.node.last_name.yml | 23 + .../expected/field.storage.node.media.yml | 22 + .../field.storage.node.profile_image.yml | 22 + .../field.storage.node.related_articles.yml | 21 + .../expected/field.storage.node.tags.yml | 22 + .../filter.format.canvas_html_block.yml | 20 + .../expected/filter.format.filtered_html.yml | 66 + .../expected/jsonapi.settings.yml | 6 + ...jsonapi_resource_config.action--action.yml | 79 + ...se_field_override--base_field_override.yml | 119 + ...source_config.canvas_page--canvas_page.yml | 143 + ...jsonapi_resource_config.editor--editor.yml | 73 + ...urce_config.field_config--field_config.yml | 121 + ...d_storage_config--field_storage_config.yml | 121 + ...ras.jsonapi_resource_config.file--file.yml | 77 + ...ce_config.filter_format--filter_format.yml | 79 + ...source_config--jsonapi_resource_config.yml | 77 + ...config.media--acquia_dam_archive_asset.yml | 23 + ...nfig.media--acquia_dam_documents_asset.yml | 23 + ...e_config.media--acquia_dam_image_asset.yml | 23 + ...rce_config.media--acquia_dam_pdf_asset.yml | 23 + ...config.media--acquia_dam_spinset_asset.yml | 23 + ...e_config.media--acquia_dam_video_asset.yml | 23 + ...sonapi_resource_config.media--document.yml | 161 + ...s.jsonapi_resource_config.media--image.yml | 161 + ...pi_resource_config.media--remote_video.yml | 161 + ...s.jsonapi_resource_config.media--video.yml | 143 + ....jsonapi_resource_config.node--article.yml | 77 + ...s.jsonapi_resource_config.node--person.yml | 65 + ...fig.pathauto_pattern--pathauto_pattern.yml | 97 + ...fig.search_api_index--search_api_index.yml | 109 + ...g.search_api_server--search_api_server.yml | 79 + ...onfig.search_api_task--search_api_task.yml | 43 + ...ource_config.taxonomy_term--categories.yml | 23 + ...pi_resource_config.taxonomy_term--tags.yml | 23 + ...ras.jsonapi_resource_config.user--user.yml | 145 + ...i_resource_config.user_role--user_role.yml | 79 + ...ras.jsonapi_resource_config.view--view.yml | 97 + ...api_resource_config.workflow--workflow.yml | 73 + .../expected/jsonapi_extras.settings.yml | 6 + .../expected/language.entity.en.yml | 9 + .../expected/language.entity.nl.yml | 9 + .../expected/language.negotiation.yml | 14 + .../source-config/expected/language.types.yml | 24 + .../language/nl/node.type.article.yml | 2 + .../expected/language/nl/system.site.yml | 2 + .../media.type.acquia_dam_archive_asset.yml | 22 + .../media.type.acquia_dam_audio_asset.yml | 20 + .../media.type.acquia_dam_documents_asset.yml | 22 + .../media.type.acquia_dam_generic_asset.yml | 20 + .../media.type.acquia_dam_image_asset.yml | 22 + .../media.type.acquia_dam_pdf_asset.yml | 22 + .../media.type.acquia_dam_spinset_asset.yml | 22 + .../media.type.acquia_dam_video_asset.yml | 22 + .../expected/media.type.document.yml | 29 + .../expected/media.type.image.yml | 29 + .../expected/media.type.remote_video.yml | 36 + .../expected/media.type.video.yml | 29 + .../metatag.metatag_defaults.canvas_page.yml | 16 + .../metatag.metatag_defaults.global.yml | 9 + .../metatag.metatag_defaults.node.yml | 12 + ...metatag.metatag_defaults.taxonomy_term.yml | 12 + .../expected/metatag.settings.yml | 44 + .../expected/node.type.article.yml | 33 + .../expected/node.type.person.yml | 33 + .../expected/system.menu.account.yml | 10 + .../expected/system.menu.admin.yml | 10 + .../expected/system.menu.footer.yml | 10 + .../expected/system.menu.main.yml | 10 + .../expected/system.menu.social-media.yml | 10 + .../expected/system.menu.tools.yml | 10 + .../source-config/expected/system.site.yml | 3 + .../taxonomy.vocabulary.categories.yml | 27 + .../expected/taxonomy.vocabulary.tags.yml | 27 + .../expected/user.role.member.yml | 113 + .../expected/workflows.workflow.editorial.yml | 92 + .../src/Misc/SourceConfigDocumentTest.php | 125 + 133 files changed, 11950 insertions(+) create mode 100644 src/Helpers/SourceConfigDocument.php create mode 100644 tests/fixtures/source-config/README.md create mode 100644 tests/fixtures/source-config/document.yml create mode 100644 tests/fixtures/source-config/expected/core.base_field_override.node.article.promote.yml create mode 100644 tests/fixtures/source-config/expected/core.base_field_override.node.person.status.yml create mode 100644 tests/fixtures/source-config/expected/core.base_field_override.node.person.title.yml create mode 100644 tests/fixtures/source-config/expected/core.entity_form_display.media.document.default.yml create mode 100644 tests/fixtures/source-config/expected/core.entity_form_display.media.document.media_library.yml create mode 100644 tests/fixtures/source-config/expected/core.entity_form_display.media.image.default.yml create mode 100644 tests/fixtures/source-config/expected/core.entity_form_display.media.image.media_library.yml create mode 100644 tests/fixtures/source-config/expected/core.entity_form_display.media.remote_video.default.yml create mode 100644 tests/fixtures/source-config/expected/core.entity_form_display.media.remote_video.media_library.yml create mode 100644 tests/fixtures/source-config/expected/core.entity_form_display.media.video.default.yml create mode 100644 tests/fixtures/source-config/expected/core.entity_form_display.media.video.media_library.yml create mode 100644 tests/fixtures/source-config/expected/core.entity_form_display.node.article.default.yml create mode 100644 tests/fixtures/source-config/expected/core.entity_form_display.node.person.default.yml create mode 100644 tests/fixtures/source-config/expected/core.entity_form_display.taxonomy_term.categories.default.yml create mode 100644 tests/fixtures/source-config/expected/core.entity_form_display.taxonomy_term.tags.default.yml create mode 100644 tests/fixtures/source-config/expected/cors_ui.configuration.yml create mode 100644 tests/fixtures/source-config/expected/field.field.media.document.categories.yml create mode 100644 tests/fixtures/source-config/expected/field.field.media.document.media_file.yml create mode 100644 tests/fixtures/source-config/expected/field.field.media.document.tags.yml create mode 100644 tests/fixtures/source-config/expected/field.field.media.image.categories.yml create mode 100644 tests/fixtures/source-config/expected/field.field.media.image.media_image.yml create mode 100644 tests/fixtures/source-config/expected/field.field.media.image.tags.yml create mode 100644 tests/fixtures/source-config/expected/field.field.media.remote_video.categories.yml create mode 100644 tests/fixtures/source-config/expected/field.field.media.remote_video.media_oembed_video.yml create mode 100644 tests/fixtures/source-config/expected/field.field.media.remote_video.tags.yml create mode 100644 tests/fixtures/source-config/expected/field.field.media.video.categories.yml create mode 100644 tests/fixtures/source-config/expected/field.field.media.video.media_video_file.yml create mode 100644 tests/fixtures/source-config/expected/field.field.media.video.tags.yml create mode 100644 tests/fixtures/source-config/expected/field.field.node.article.author_profile.yml create mode 100644 tests/fixtures/source-config/expected/field.field.node.article.body.yml create mode 100644 tests/fixtures/source-config/expected/field.field.node.article.categories.yml create mode 100644 tests/fixtures/source-config/expected/field.field.node.article.cover_image.yml create mode 100644 tests/fixtures/source-config/expected/field.field.node.article.media.yml create mode 100644 tests/fixtures/source-config/expected/field.field.node.article.related_articles.yml create mode 100644 tests/fixtures/source-config/expected/field.field.node.article.tags.yml create mode 100644 tests/fixtures/source-config/expected/field.field.node.person.bio.yml create mode 100644 tests/fixtures/source-config/expected/field.field.node.person.first_name.yml create mode 100644 tests/fixtures/source-config/expected/field.field.node.person.job_title.yml create mode 100644 tests/fixtures/source-config/expected/field.field.node.person.last_name.yml create mode 100644 tests/fixtures/source-config/expected/field.field.node.person.profile_image.yml create mode 100644 tests/fixtures/source-config/expected/field.storage.media.categories.yml create mode 100644 tests/fixtures/source-config/expected/field.storage.media.media_file.yml create mode 100644 tests/fixtures/source-config/expected/field.storage.media.media_image.yml create mode 100644 tests/fixtures/source-config/expected/field.storage.media.media_oembed_video.yml create mode 100644 tests/fixtures/source-config/expected/field.storage.media.media_video_file.yml create mode 100644 tests/fixtures/source-config/expected/field.storage.media.tags.yml create mode 100644 tests/fixtures/source-config/expected/field.storage.node.author_profile.yml create mode 100644 tests/fixtures/source-config/expected/field.storage.node.bio.yml create mode 100644 tests/fixtures/source-config/expected/field.storage.node.body.yml create mode 100644 tests/fixtures/source-config/expected/field.storage.node.categories.yml create mode 100644 tests/fixtures/source-config/expected/field.storage.node.cover_image.yml create mode 100644 tests/fixtures/source-config/expected/field.storage.node.first_name.yml create mode 100644 tests/fixtures/source-config/expected/field.storage.node.job_title.yml create mode 100644 tests/fixtures/source-config/expected/field.storage.node.last_name.yml create mode 100644 tests/fixtures/source-config/expected/field.storage.node.media.yml create mode 100644 tests/fixtures/source-config/expected/field.storage.node.profile_image.yml create mode 100644 tests/fixtures/source-config/expected/field.storage.node.related_articles.yml create mode 100644 tests/fixtures/source-config/expected/field.storage.node.tags.yml create mode 100644 tests/fixtures/source-config/expected/filter.format.canvas_html_block.yml create mode 100644 tests/fixtures/source-config/expected/filter.format.filtered_html.yml create mode 100644 tests/fixtures/source-config/expected/jsonapi.settings.yml create mode 100644 tests/fixtures/source-config/expected/jsonapi_extras.jsonapi_resource_config.action--action.yml create mode 100644 tests/fixtures/source-config/expected/jsonapi_extras.jsonapi_resource_config.base_field_override--base_field_override.yml create mode 100644 tests/fixtures/source-config/expected/jsonapi_extras.jsonapi_resource_config.canvas_page--canvas_page.yml create mode 100644 tests/fixtures/source-config/expected/jsonapi_extras.jsonapi_resource_config.editor--editor.yml create mode 100644 tests/fixtures/source-config/expected/jsonapi_extras.jsonapi_resource_config.field_config--field_config.yml create mode 100644 tests/fixtures/source-config/expected/jsonapi_extras.jsonapi_resource_config.field_storage_config--field_storage_config.yml create mode 100644 tests/fixtures/source-config/expected/jsonapi_extras.jsonapi_resource_config.file--file.yml create mode 100644 tests/fixtures/source-config/expected/jsonapi_extras.jsonapi_resource_config.filter_format--filter_format.yml create mode 100644 tests/fixtures/source-config/expected/jsonapi_extras.jsonapi_resource_config.jsonapi_resource_config--jsonapi_resource_config.yml create mode 100644 tests/fixtures/source-config/expected/jsonapi_extras.jsonapi_resource_config.media--acquia_dam_archive_asset.yml create mode 100644 tests/fixtures/source-config/expected/jsonapi_extras.jsonapi_resource_config.media--acquia_dam_documents_asset.yml create mode 100644 tests/fixtures/source-config/expected/jsonapi_extras.jsonapi_resource_config.media--acquia_dam_image_asset.yml create mode 100644 tests/fixtures/source-config/expected/jsonapi_extras.jsonapi_resource_config.media--acquia_dam_pdf_asset.yml create mode 100644 tests/fixtures/source-config/expected/jsonapi_extras.jsonapi_resource_config.media--acquia_dam_spinset_asset.yml create mode 100644 tests/fixtures/source-config/expected/jsonapi_extras.jsonapi_resource_config.media--acquia_dam_video_asset.yml create mode 100644 tests/fixtures/source-config/expected/jsonapi_extras.jsonapi_resource_config.media--document.yml create mode 100644 tests/fixtures/source-config/expected/jsonapi_extras.jsonapi_resource_config.media--image.yml create mode 100644 tests/fixtures/source-config/expected/jsonapi_extras.jsonapi_resource_config.media--remote_video.yml create mode 100644 tests/fixtures/source-config/expected/jsonapi_extras.jsonapi_resource_config.media--video.yml create mode 100644 tests/fixtures/source-config/expected/jsonapi_extras.jsonapi_resource_config.node--article.yml create mode 100644 tests/fixtures/source-config/expected/jsonapi_extras.jsonapi_resource_config.node--person.yml create mode 100644 tests/fixtures/source-config/expected/jsonapi_extras.jsonapi_resource_config.pathauto_pattern--pathauto_pattern.yml create mode 100644 tests/fixtures/source-config/expected/jsonapi_extras.jsonapi_resource_config.search_api_index--search_api_index.yml create mode 100644 tests/fixtures/source-config/expected/jsonapi_extras.jsonapi_resource_config.search_api_server--search_api_server.yml create mode 100644 tests/fixtures/source-config/expected/jsonapi_extras.jsonapi_resource_config.search_api_task--search_api_task.yml create mode 100644 tests/fixtures/source-config/expected/jsonapi_extras.jsonapi_resource_config.taxonomy_term--categories.yml create mode 100644 tests/fixtures/source-config/expected/jsonapi_extras.jsonapi_resource_config.taxonomy_term--tags.yml create mode 100644 tests/fixtures/source-config/expected/jsonapi_extras.jsonapi_resource_config.user--user.yml create mode 100644 tests/fixtures/source-config/expected/jsonapi_extras.jsonapi_resource_config.user_role--user_role.yml create mode 100644 tests/fixtures/source-config/expected/jsonapi_extras.jsonapi_resource_config.view--view.yml create mode 100644 tests/fixtures/source-config/expected/jsonapi_extras.jsonapi_resource_config.workflow--workflow.yml create mode 100644 tests/fixtures/source-config/expected/jsonapi_extras.settings.yml create mode 100644 tests/fixtures/source-config/expected/language.entity.en.yml create mode 100644 tests/fixtures/source-config/expected/language.entity.nl.yml create mode 100644 tests/fixtures/source-config/expected/language.negotiation.yml create mode 100644 tests/fixtures/source-config/expected/language.types.yml create mode 100644 tests/fixtures/source-config/expected/language/nl/node.type.article.yml create mode 100644 tests/fixtures/source-config/expected/language/nl/system.site.yml create mode 100644 tests/fixtures/source-config/expected/media.type.acquia_dam_archive_asset.yml create mode 100644 tests/fixtures/source-config/expected/media.type.acquia_dam_audio_asset.yml create mode 100644 tests/fixtures/source-config/expected/media.type.acquia_dam_documents_asset.yml create mode 100644 tests/fixtures/source-config/expected/media.type.acquia_dam_generic_asset.yml create mode 100644 tests/fixtures/source-config/expected/media.type.acquia_dam_image_asset.yml create mode 100644 tests/fixtures/source-config/expected/media.type.acquia_dam_pdf_asset.yml create mode 100644 tests/fixtures/source-config/expected/media.type.acquia_dam_spinset_asset.yml create mode 100644 tests/fixtures/source-config/expected/media.type.acquia_dam_video_asset.yml create mode 100644 tests/fixtures/source-config/expected/media.type.document.yml create mode 100644 tests/fixtures/source-config/expected/media.type.image.yml create mode 100644 tests/fixtures/source-config/expected/media.type.remote_video.yml create mode 100644 tests/fixtures/source-config/expected/media.type.video.yml create mode 100644 tests/fixtures/source-config/expected/metatag.metatag_defaults.canvas_page.yml create mode 100644 tests/fixtures/source-config/expected/metatag.metatag_defaults.global.yml create mode 100644 tests/fixtures/source-config/expected/metatag.metatag_defaults.node.yml create mode 100644 tests/fixtures/source-config/expected/metatag.metatag_defaults.taxonomy_term.yml create mode 100644 tests/fixtures/source-config/expected/metatag.settings.yml create mode 100644 tests/fixtures/source-config/expected/node.type.article.yml create mode 100644 tests/fixtures/source-config/expected/node.type.person.yml create mode 100644 tests/fixtures/source-config/expected/system.menu.account.yml create mode 100644 tests/fixtures/source-config/expected/system.menu.admin.yml create mode 100644 tests/fixtures/source-config/expected/system.menu.footer.yml create mode 100644 tests/fixtures/source-config/expected/system.menu.main.yml create mode 100644 tests/fixtures/source-config/expected/system.menu.social-media.yml create mode 100644 tests/fixtures/source-config/expected/system.menu.tools.yml create mode 100644 tests/fixtures/source-config/expected/system.site.yml create mode 100644 tests/fixtures/source-config/expected/taxonomy.vocabulary.categories.yml create mode 100644 tests/fixtures/source-config/expected/taxonomy.vocabulary.tags.yml create mode 100644 tests/fixtures/source-config/expected/user.role.member.yml create mode 100644 tests/fixtures/source-config/expected/workflows.workflow.editorial.yml create mode 100644 tests/phpunit/src/Misc/SourceConfigDocumentTest.php diff --git a/src/Helpers/SourceConfigDocument.php b/src/Helpers/SourceConfigDocument.php new file mode 100644 index 000000000..c5f76da8a --- /dev/null +++ b/src/Helpers/SourceConfigDocument.php @@ -0,0 +1,93 @@ + config name => values. The + * default collection is keyed '' and maps to the directory root; any other + * collection maps to a subdirectory with its dots turned into slashes + * (language.nl => language/nl). Each config object is one .yml file. + * Files are encoded and decoded exactly like Drupal core does, so a pull + * followed by a push reproduces the document byte for byte. + */ +final class SourceConfigDocument +{ + /** + * @return array + * Relative file path => file contents. + */ + public static function toFiles(string $yaml): array + { + $document = self::decode($yaml); + if (!is_array($document) || $document === []) { + throw new AcquiaCliException('The configuration document is empty or not a map of collections.'); + } + $files = []; + foreach ($document as $collection => $objects) { + if (!is_array($objects)) { + throw new AcquiaCliException('Collection "{collection}" is not a map of configuration objects.', ['collection' => $collection]); + } + $dir = $collection === '' ? '' : str_replace('.', '/', self::pathSegment($collection)) . '/'; + foreach ($objects as $name => $values) { + $files[$dir . self::pathSegment($name) . '.yml'] = self::encode($values); + } + } + return $files; + } + + /** + * @param array $files + * Relative file path => file contents. + */ + public static function fromFiles(array $files): string + { + $document = []; + foreach ($files as $path => $yaml) { + $path = str_replace('\\', '/', $path); + $dir = dirname($path); + $collection = $dir === '.' ? '' : str_replace('/', '.', $dir); + try { + $document[$collection][basename($path, '.yml')] = self::decode($yaml); + } catch (ParseException $e) { + throw new AcquiaCliException('{file} is not valid YAML: {error}', ['file' => $path, 'error' => $e->getMessage()]); + } + } + ksort($document); + foreach ($document as &$objects) { + ksort($objects); + } + return self::encode($document); + } + + /** + * Rejects collection and config names that could escape .acquia/config. + */ + private static function pathSegment(string|int $segment): string + { + $segment = (string) $segment; + if ($segment === '' || preg_match('#[/\\\\]|\.\.#', $segment)) { + throw new AcquiaCliException('"{segment}" is not a valid configuration name.', ['segment' => $segment]); + } + return $segment; + } + + private static function decode(string $yaml): mixed + { + return (new Parser())->parse($yaml, Yaml::PARSE_EXCEPTION_ON_INVALID_TYPE | Yaml::PARSE_CUSTOM_TAGS); + } + + private static function encode(mixed $data): string + { + return (new Dumper(2))->dump($data, PHP_INT_MAX, 0, Yaml::DUMP_EXCEPTION_ON_INVALID_TYPE | Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK); + } +} diff --git a/tests/fixtures/source-config/README.md b/tests/fixtures/source-config/README.md new file mode 100644 index 000000000..c774b85eb --- /dev/null +++ b/tests/fixtures/source-config/README.md @@ -0,0 +1,31 @@ +# Source configuration fixture + +`document.yml` is the configuration document a Source site returns, and +`expected/` is that same document written out as `.acquia/config` files. +Both were produced by the site itself (Drupal core's YAML encoder), not by +Acquia CLI, so the tests prove byte-for-byte fidelity in both directions. + +To regenerate from a haas-drupal DDEV checkout (fresh `standard` install, +Dutch added, with one or two `language.nl` overrides so that +`language/nl/` is exercised): + +```shell +cd ~/Work/haas-drupal +ddev exec 'vendor/bin/dr source:config:dump --single-yaml > /tmp/doc.yml' +ddev exec 'cat > /tmp/write-expected.php' <<'PHP' + $objects) { + $dir = '/tmp/expected' . ($collection === '' ? '' : '/' . str_replace('.', '/', $collection)); + mkdir($dir, 0777, TRUE); + foreach ($objects as $name => $values) { + file_put_contents("$dir/$name.yml", Yaml::encode($values)); + } +} +PHP +ddev exec 'rm -rf /tmp/expected' && ddev drush php:script /tmp/write-expected.php +cd ~/Work/cli/tests/fixtures/source-config && rm -rf expected && mkdir expected +(cd ~/Work/haas-drupal && ddev exec 'cd /tmp/expected && tar -cf - .') | tar -xf - -C expected +(cd ~/Work/haas-drupal && ddev exec 'cat /tmp/doc.yml') > document.yml +``` diff --git a/tests/fixtures/source-config/document.yml b/tests/fixtures/source-config/document.yml new file mode 100644 index 000000000..649c418be --- /dev/null +++ b/tests/fixtures/source-config/document.yml @@ -0,0 +1,5916 @@ +'': + core.base_field_override.node.article.promote: + uuid: cf936d55-e374-43fb-a904-dada6a58afd2 + langcode: en + status: true + dependencies: + config: + - node.type.article + _core: + default_config_hash: GrzL4TIZFEHW2o6Qr_sTIT2DWt88DwkgC2NWj3xE46I + id: node.article.promote + field_name: promote + entity_type: node + bundle: article + label: 'Promoted to front page' + description: '' + required: false + translatable: true + default_value: + - + value: false + default_value_callback: '' + settings: + on_label: 'On' + off_label: 'Off' + field_type: boolean + core.base_field_override.node.person.status: + uuid: 9cfb97b6-b6f5-40dc-bd3b-546cba522619 + langcode: en + status: true + dependencies: + config: + - node.type.person + _core: + default_config_hash: y-kCVhlvg7OvtzBrI0hCYIcduf55A5oRXl9bDGjHSLc + id: node.person.status + field_name: status + entity_type: node + bundle: person + label: Published + description: '' + required: false + translatable: true + default_value: + - + value: false + default_value_callback: '' + settings: + on_label: 'On' + off_label: 'Off' + field_type: boolean + core.base_field_override.node.person.title: + uuid: 37ce7281-601d-4fc1-aa6b-d9a0a9bd97d8 + langcode: en + status: true + dependencies: + config: + - node.type.person + _core: + default_config_hash: ppJksNIlmg7WiVfHydPDROhK9ceGLvo7Lm1ejQfbmQE + id: node.person.title + field_name: title + entity_type: node + bundle: person + label: 'Display name' + description: '' + required: true + translatable: true + default_value: { } + default_value_callback: '' + settings: { } + field_type: string + core.entity_form_display.media.document.default: + uuid: a28bd6b1-bc3d-4b6f-82a1-56b99fdfdada + langcode: en + status: true + dependencies: + config: + - field.field.media.document.categories + - field.field.media.document.media_file + - field.field.media.document.tags + - media.type.document + module: + - field_group + - file + - haas_content + - metatag + - path + third_party_settings: + field_group: + group_taxonomy: + children: + - categories + - tags + label: Taxonomy + region: content + parent_name: '' + weight: 9 + format_type: fieldset + format_settings: + classes: '' + show_empty_fields: false + id: '' + description: '' + required_fields: false + id: media.document.default + targetEntityType: media + bundle: document + mode: default + content: + categories: + type: entity_reference_tagify_hierarchical + weight: 27 + region: content + settings: + match_operator: CONTAINS + match_limit: 20 + placeholder: 'Choose categories' + suggestions_dropdown: 0 + show_entity_id: 0 + third_party_settings: { } + created: + type: datetime_timestamp + weight: 4 + region: content + settings: { } + third_party_settings: { } + media_file: + type: file_generic + weight: 1 + region: content + settings: + progress_indicator: throbber + third_party_settings: { } + metatags: + type: metatag_firehose + weight: 26 + region: content + settings: + sidebar: true + use_details: true + third_party_settings: { } + name: + type: string_textfield + weight: 0 + region: content + settings: + size: 60 + placeholder: '' + third_party_settings: { } + path: + type: path + weight: 6 + region: content + settings: { } + third_party_settings: { } + replace_file: + weight: 2 + region: content + settings: { } + third_party_settings: { } + status: + type: boolean_checkbox + weight: 7 + region: content + settings: + display_label: true + third_party_settings: { } + tags: + type: entity_reference_tagify + weight: 28 + region: content + settings: + match_operator: CONTAINS + match_limit: 20 + placeholder: 'Add tags' + suggestions_dropdown: 0 + show_entity_id: 0 + third_party_settings: { } + translation: + weight: 5 + region: content + settings: { } + third_party_settings: { } + uid: + type: entity_reference_autocomplete + weight: 3 + region: content + settings: + match_operator: CONTAINS + match_limit: 10 + size: 60 + placeholder: '' + third_party_settings: { } + hidden: + categories: true + langcode: true + tags: true + core.entity_form_display.media.document.media_library: + uuid: 2e9313c0-f149-45a0-99ea-02a7f9370491 + langcode: en + status: true + dependencies: + config: + - core.entity_form_mode.media.media_library + - field.field.media.document.categories + - field.field.media.document.media_file + - field.field.media.document.tags + - media.type.document + module: + - metatag + id: media.document.media_library + targetEntityType: media + bundle: document + mode: media_library + content: + metatags: + type: metatag_firehose + weight: 26 + region: content + settings: + sidebar: true + use_details: true + third_party_settings: { } + name: + type: string_textfield + weight: 0 + region: content + settings: + size: 60 + placeholder: '' + third_party_settings: { } + hidden: + categories: true + created: true + langcode: true + media_file: true + path: true + replace_file: true + status: true + tags: true + translation: true + uid: true + core.entity_form_display.media.image.default: + uuid: 834789b9-8f75-415c-8d3d-83f5848a2418 + langcode: en + status: true + dependencies: + config: + - field.field.media.image.categories + - field.field.media.image.media_image + - field.field.media.image.tags + - image.style.media_library + - media.type.image + module: + - field_group + - focal_point + - haas_content + - metatag + - path + third_party_settings: + field_group: + group_taxonomy: + children: + - categories + - tags + label: Taxonomy + region: content + parent_name: '' + weight: 10 + format_type: fieldset + format_settings: + classes: '' + show_empty_fields: false + id: '' + description: '' + required_fields: false + id: media.image.default + targetEntityType: media + bundle: image + mode: default + content: + categories: + type: entity_reference_tagify_hierarchical + weight: 27 + region: content + settings: + match_operator: CONTAINS + match_limit: 20 + placeholder: 'Choose categories' + suggestions_dropdown: 0 + show_entity_id: 0 + third_party_settings: { } + created: + type: datetime_timestamp + weight: 6 + region: content + settings: { } + third_party_settings: { } + media_image: + type: image_focal_point + weight: 1 + region: content + settings: + progress_indicator: throbber + preview_image_style: media_library + preview_link: true + offsets: '50,50' + third_party_settings: { } + metatags: + type: metatag_firehose + weight: 26 + region: content + settings: + sidebar: true + use_details: true + third_party_settings: { } + name: + type: string_textfield + weight: 0 + region: content + settings: + size: 60 + placeholder: '' + third_party_settings: { } + path: + type: path + weight: 8 + region: content + settings: { } + third_party_settings: { } + replace_file: + weight: 2 + region: content + settings: { } + third_party_settings: { } + status: + type: boolean_checkbox + weight: 9 + region: content + settings: + display_label: true + third_party_settings: { } + tags: + type: entity_reference_tagify + weight: 28 + region: content + settings: + match_operator: CONTAINS + match_limit: 20 + placeholder: 'Add tags' + suggestions_dropdown: 0 + show_entity_id: 0 + third_party_settings: { } + translation: + weight: 7 + region: content + settings: { } + third_party_settings: { } + uid: + type: entity_reference_autocomplete + weight: 3 + region: content + settings: + match_operator: CONTAINS + match_limit: 10 + size: 60 + placeholder: '' + third_party_settings: { } + hidden: + categories: true + langcode: true + tags: true + core.entity_form_display.media.image.media_library: + uuid: 964d313a-3fe4-4398-8959-73258556ef11 + langcode: en + status: true + dependencies: + config: + - core.entity_form_mode.media.media_library + - field.field.media.image.categories + - field.field.media.image.media_image + - field.field.media.image.tags + - image.style.media_library + - media.type.image + module: + - image + - metatag + id: media.image.media_library + targetEntityType: media + bundle: image + mode: media_library + content: + media_image: + type: image_image + weight: 5 + region: content + settings: + progress_indicator: throbber + preview_image_style: media_library + third_party_settings: { } + metatags: + type: metatag_firehose + weight: 26 + region: content + settings: + sidebar: true + use_details: true + third_party_settings: { } + name: + type: string_textfield + weight: 0 + region: content + settings: + size: 60 + placeholder: '' + third_party_settings: { } + hidden: + categories: true + created: true + langcode: true + path: true + replace_file: true + status: true + tags: true + translation: true + uid: true + core.entity_form_display.media.remote_video.default: + uuid: e785e143-ba3c-4c6f-baa8-4756b8f507b2 + langcode: en + status: true + dependencies: + config: + - field.field.media.remote_video.categories + - field.field.media.remote_video.media_oembed_video + - field.field.media.remote_video.tags + - media.type.remote_video + module: + - field_group + - haas_content + - media + - metatag + - path + third_party_settings: + field_group: + group_taxonomy: + children: + - categories + - tags + label: Taxonomy + region: content + parent_name: '' + weight: 9 + format_type: fieldset + format_settings: + classes: '' + show_empty_fields: false + id: '' + description: '' + required_fields: false + id: media.remote_video.default + targetEntityType: media + bundle: remote_video + mode: default + content: + categories: + type: entity_reference_tagify_hierarchical + weight: 27 + region: content + settings: + match_operator: CONTAINS + match_limit: 20 + placeholder: 'Choose categories' + suggestions_dropdown: 0 + show_entity_id: 0 + third_party_settings: { } + created: + type: datetime_timestamp + weight: 5 + region: content + settings: { } + third_party_settings: { } + media_oembed_video: + type: oembed_textfield + weight: 0 + region: content + settings: + size: 60 + placeholder: '' + third_party_settings: { } + metatags: + type: metatag_firehose + weight: 26 + region: content + settings: + sidebar: true + use_details: true + third_party_settings: { } + path: + type: path + weight: 7 + region: content + settings: { } + third_party_settings: { } + status: + type: boolean_checkbox + weight: 8 + region: content + settings: + display_label: true + third_party_settings: { } + tags: + type: entity_reference_tagify + weight: 28 + region: content + settings: + match_operator: CONTAINS + match_limit: 20 + placeholder: 'Add tags' + suggestions_dropdown: 0 + show_entity_id: 0 + third_party_settings: { } + translation: + weight: 6 + region: content + settings: { } + third_party_settings: { } + uid: + type: entity_reference_autocomplete + weight: 2 + region: content + settings: + match_operator: CONTAINS + match_limit: 10 + size: 60 + placeholder: '' + third_party_settings: { } + hidden: + categories: true + langcode: true + name: true + tags: true + core.entity_form_display.media.remote_video.media_library: + uuid: 44a32894-5844-4d96-8667-ccd987cf8fbf + langcode: en + status: true + dependencies: + config: + - core.entity_form_mode.media.media_library + - field.field.media.remote_video.categories + - field.field.media.remote_video.media_oembed_video + - field.field.media.remote_video.tags + - media.type.remote_video + module: + - metatag + id: media.remote_video.media_library + targetEntityType: media + bundle: remote_video + mode: media_library + content: + metatags: + type: metatag_firehose + weight: 26 + region: content + settings: + sidebar: true + use_details: true + third_party_settings: { } + name: + type: string_textfield + weight: 0 + region: content + settings: + size: 60 + placeholder: '' + third_party_settings: { } + hidden: + categories: true + created: true + langcode: true + media_oembed_video: true + path: true + scheduler_settings: true + status: true + tags: true + translation: true + uid: true + core.entity_form_display.media.video.default: + uuid: 5264cca0-f083-456b-8107-a31532ebd404 + langcode: en + status: true + dependencies: + config: + - field.field.media.video.categories + - field.field.media.video.media_video_file + - field.field.media.video.tags + - media.type.video + module: + - field_group + - file + - haas_content + - metatag + - path + third_party_settings: + field_group: + group_taxonomy: + children: + - categories + - tags + label: Taxonomy + region: content + parent_name: '' + weight: 10 + format_type: fieldset + format_settings: + classes: '' + show_empty_fields: false + id: '' + description: '' + required_fields: false + id: media.video.default + targetEntityType: media + bundle: video + mode: default + content: + categories: + type: entity_reference_tagify_hierarchical + weight: 27 + region: content + settings: + match_operator: CONTAINS + match_limit: 20 + placeholder: 'Choose categories' + suggestions_dropdown: 0 + show_entity_id: 0 + third_party_settings: { } + created: + type: datetime_timestamp + weight: 10 + region: content + settings: { } + third_party_settings: { } + media_video_file: + type: file_generic + weight: 0 + region: content + settings: + progress_indicator: throbber + third_party_settings: { } + metatags: + type: metatag_firehose + weight: 26 + region: content + settings: + sidebar: true + use_details: true + third_party_settings: { } + name: + type: string_textfield + weight: -5 + region: content + settings: + size: 60 + placeholder: '' + third_party_settings: { } + path: + type: path + weight: 30 + region: content + settings: { } + third_party_settings: { } + replace_file: + weight: 1 + region: content + settings: { } + third_party_settings: { } + status: + type: boolean_checkbox + weight: 100 + region: content + settings: + display_label: true + third_party_settings: { } + tags: + type: entity_reference_tagify + weight: 28 + region: content + settings: + match_operator: CONTAINS + match_limit: 20 + placeholder: 'Add tags' + suggestions_dropdown: 0 + show_entity_id: 0 + third_party_settings: { } + translation: + weight: 5 + region: content + settings: { } + third_party_settings: { } + uid: + type: entity_reference_autocomplete + weight: 5 + region: content + settings: + match_operator: CONTAINS + match_limit: 10 + size: 60 + placeholder: '' + third_party_settings: { } + hidden: + categories: true + langcode: true + tags: true + core.entity_form_display.media.video.media_library: + uuid: c226360d-9d96-4673-965b-ecef5954f9e3 + langcode: en + status: true + dependencies: + config: + - core.entity_form_mode.media.media_library + - field.field.media.video.categories + - field.field.media.video.media_video_file + - field.field.media.video.tags + - media.type.video + module: + - file + - metatag + id: media.video.media_library + targetEntityType: media + bundle: video + mode: media_library + content: + media_video_file: + type: file_generic + weight: 0 + region: content + settings: + progress_indicator: throbber + third_party_settings: { } + metatags: + type: metatag_firehose + weight: 26 + region: content + settings: + sidebar: true + use_details: true + third_party_settings: { } + name: + type: string_textfield + weight: -5 + region: content + settings: + size: 60 + placeholder: '' + third_party_settings: { } + hidden: + categories: true + created: true + langcode: true + metatag: true + path: true + replace_file: true + status: true + tags: true + uid: true + core.entity_form_display.node.article.default: + uuid: ae410a68-db84-4959-94d5-28881553eee5 + langcode: en + status: true + dependencies: + config: + - field.field.node.article.author_profile + - field.field.node.article.body + - field.field.node.article.categories + - field.field.node.article.cover_image + - field.field.node.article.media + - field.field.node.article.related_articles + - field.field.node.article.tags + - node.type.article + - workflows.workflow.editorial + module: + - content_moderation + - field_group + - haas_content + - media_library + - metatag + - path + - scheduler + - scheduler_content_moderation_integration + - text + third_party_settings: + field_group: + group_taxonomy: + children: + - categories + - tags + label: Taxonomy + region: content + parent_name: '' + weight: 8 + format_type: fieldset + format_settings: + classes: '' + show_empty_fields: false + id: '' + description: '' + required_fields: false + group_media: + children: + - cover_image + - media + label: Media + region: content + parent_name: '' + weight: 10 + format_type: fieldset + format_settings: + classes: '' + show_empty_fields: false + id: '' + description: '' + required_fields: false + _core: + default_config_hash: cOT_33fZCrMJzsUb_qmwzIS0bhCLp-VAGFdGOsoC65c + id: node.article.default + targetEntityType: node + bundle: article + mode: default + content: + author_profile: + type: entity_reference_tagify + weight: 101 + region: content + settings: + match_operator: CONTAINS + match_limit: 20 + placeholder: 'Choose author profile' + suggestions_dropdown: 0 + show_entity_id: 0 + show_stacked: false + third_party_settings: { } + body: + type: text_textarea_with_summary + weight: 7 + region: content + settings: + rows: 9 + summary_rows: 3 + placeholder: '' + show_summary: false + third_party_settings: { } + categories: + type: entity_reference_tagify_hierarchical + weight: 13 + region: content + settings: + match_operator: CONTAINS + match_limit: 20 + placeholder: 'Choose categories' + suggestions_dropdown: 0 + show_entity_id: 0 + show_stacked: false + third_party_settings: { } + cover_image: + type: media_library_widget + weight: 14 + region: content + settings: + media_types: { } + third_party_settings: { } + created: + type: datetime_timestamp + weight: 2 + region: content + settings: { } + third_party_settings: { } + media: + type: media_library_widget + weight: 15 + region: content + settings: + media_types: { } + third_party_settings: { } + metatags: + type: metatag_firehose + weight: 11 + region: content + settings: + sidebar: true + use_details: true + third_party_settings: { } + path: + type: path + weight: 3 + region: content + settings: { } + third_party_settings: { } + related_articles: + type: entity_reference_tagify + weight: 12 + region: content + settings: + match_operator: CONTAINS + match_limit: 20 + placeholder: 'Choose related articles' + suggestions_dropdown: 0 + show_entity_id: 0 + show_stacked: false + third_party_settings: { } + scheduler_settings: + weight: 5 + region: content + settings: { } + third_party_settings: { } + simple_sitemap: + weight: 10 + region: content + settings: { } + third_party_settings: { } + status: + type: boolean_checkbox + weight: 6 + region: content + settings: + display_label: true + third_party_settings: { } + tags: + type: entity_reference_tagify + weight: 15 + region: content + settings: + match_operator: CONTAINS + match_limit: 20 + placeholder: 'Add tags' + suggestions_dropdown: 0 + show_entity_id: 0 + show_stacked: false + third_party_settings: { } + title: + type: string_textfield + weight: 0 + region: content + settings: + size: 60 + placeholder: '' + third_party_settings: { } + translation: + weight: 4 + region: content + settings: { } + third_party_settings: { } + uid: + type: entity_reference_autocomplete + weight: 1 + region: content + settings: + match_operator: CONTAINS + match_limit: 10 + size: 60 + placeholder: '' + third_party_settings: { } + url_redirects: + weight: 4 + region: content + settings: { } + third_party_settings: { } + hidden: { } + core.entity_form_display.node.person.default: + uuid: 3b1e48e8-d086-4ce1-ae8d-bbb2324f9fd9 + langcode: en + status: true + dependencies: + config: + - field.field.node.person.bio + - field.field.node.person.first_name + - field.field.node.person.job_title + - field.field.node.person.last_name + - field.field.node.person.profile_image + - node.type.person + - workflows.workflow.editorial + module: + - content_moderation + - media_library + - metatag + - path + - scheduler + - scheduler_content_moderation_integration + - text + _core: + default_config_hash: zHl0kW2YFAD4ks6O9qVmSfYQ8uMGh6H4ORrzGrUtMDc + id: node.person.default + targetEntityType: node + bundle: person + mode: default + content: + bio: + type: text_textarea + weight: 125 + region: content + settings: + rows: 5 + placeholder: '' + third_party_settings: { } + created: + type: datetime_timestamp + weight: 10 + region: content + settings: { } + third_party_settings: { } + first_name: + type: string_textfield + weight: 122 + region: content + settings: + size: 60 + placeholder: '' + third_party_settings: { } + job_title: + type: string_textfield + weight: 124 + region: content + settings: + size: 60 + placeholder: '' + third_party_settings: { } + last_name: + type: string_textfield + weight: 123 + region: content + settings: + size: 60 + placeholder: '' + third_party_settings: { } + metatags: + type: metatag_firehose + weight: 121 + region: content + settings: + sidebar: true + use_details: true + third_party_settings: { } + path: + type: path + weight: 30 + region: content + settings: { } + third_party_settings: { } + profile_image: + type: media_library_widget + weight: 126 + region: content + settings: + media_types: { } + third_party_settings: { } + simple_sitemap: + weight: 10 + region: content + settings: { } + third_party_settings: { } + status: + type: boolean_checkbox + weight: 120 + region: content + settings: + display_label: true + third_party_settings: { } + title: + type: string_textfield + weight: -5 + region: content + settings: + size: 60 + placeholder: '' + third_party_settings: { } + uid: + type: entity_reference_autocomplete + weight: 5 + region: content + settings: + match_operator: CONTAINS + match_limit: 10 + size: 60 + placeholder: '' + third_party_settings: { } + url_redirects: + weight: 50 + region: content + settings: { } + third_party_settings: { } + hidden: { } + core.entity_form_display.taxonomy_term.categories.default: + uuid: c0dd9b7b-2ce7-4987-af08-b3b749739676 + langcode: en + status: true + dependencies: + config: + - taxonomy.vocabulary.categories + module: + - metatag + - path + - text + id: taxonomy_term.categories.default + targetEntityType: taxonomy_term + bundle: categories + mode: default + content: + description: + type: text_textfield + weight: 0 + region: content + settings: + size: 60 + placeholder: '' + third_party_settings: { } + metatags: + type: metatag_firehose + weight: 101 + region: content + settings: + sidebar: true + use_details: true + third_party_settings: { } + name: + type: string_textfield + weight: -5 + region: content + settings: + size: 60 + placeholder: '' + third_party_settings: { } + path: + type: path + weight: 30 + region: content + settings: { } + third_party_settings: { } + status: + type: boolean_checkbox + weight: 100 + region: content + settings: + display_label: true + third_party_settings: { } + translation: + weight: 10 + region: content + settings: { } + third_party_settings: { } + hidden: + langcode: true + core.entity_form_display.taxonomy_term.tags.default: + uuid: 7331f039-8381-412e-be3c-b616ddfc4e8f + langcode: en + status: true + dependencies: + config: + - taxonomy.vocabulary.tags + module: + - metatag + - path + - text + id: taxonomy_term.tags.default + targetEntityType: taxonomy_term + bundle: tags + mode: default + content: + description: + type: text_textfield + weight: 0 + region: content + settings: + size: 60 + placeholder: '' + third_party_settings: { } + metatags: + type: metatag_firehose + weight: 101 + region: content + settings: + sidebar: true + use_details: true + third_party_settings: { } + name: + type: string_textfield + weight: -5 + region: content + settings: + size: 60 + placeholder: '' + third_party_settings: { } + path: + type: path + weight: 30 + region: content + settings: { } + third_party_settings: { } + status: + type: boolean_checkbox + weight: 100 + region: content + settings: + display_label: true + third_party_settings: { } + translation: + weight: 10 + region: content + settings: { } + third_party_settings: { } + hidden: + langcode: true + cors_ui.configuration: + enabled: true + allowedHeaders: + - Authorization + - X-Consumer-ID + allowedMethods: + - OPTIONS + - GET + allowedOrigins: + - '*' + exposedHeaders: { } + maxAge: 5 + supportsCredentials: false + field.field.media.document.categories: + uuid: 4b35b9eb-3991-4686-b717-814599528fc6 + langcode: en + status: true + dependencies: + config: + - field.storage.media.categories + - media.type.document + - taxonomy.vocabulary.categories + id: media.document.categories + field_name: categories + entity_type: media + bundle: document + label: Categories + description: 'Categorize this media item.' + required: false + translatable: false + default_value: { } + default_value_callback: '' + settings: + handler: 'default:taxonomy_term' + handler_settings: + target_bundles: + categories: categories + sort: + field: name + direction: asc + auto_create: true + auto_create_bundle: categories + field_type: entity_reference + field.field.media.document.media_file: + uuid: 5d71e168-7bbc-4317-a5f0-63af2daf3818 + langcode: en + status: true + dependencies: + config: + - field.storage.media.media_file + - media.type.document + module: + - file + id: media.document.media_file + field_name: media_file + entity_type: media + bundle: document + label: File + description: '' + required: true + translatable: true + default_value: { } + default_value_callback: '' + settings: + handler: 'default:file' + handler_settings: { } + file_directory: '[date:custom:Y]-[date:custom:m]' + file_extensions: 'txt doc docx pdf' + max_filesize: '' + description_field: false + field_type: file + field.field.media.document.tags: + uuid: 10716007-318f-42e5-898e-2e7e31aedaa9 + langcode: en + status: true + dependencies: + config: + - field.storage.media.tags + - media.type.document + - taxonomy.vocabulary.tags + id: media.document.tags + field_name: tags + entity_type: media + bundle: document + label: Tags + description: '' + required: false + translatable: false + default_value: { } + default_value_callback: '' + settings: + handler: 'default:taxonomy_term' + handler_settings: + target_bundles: + tags: tags + sort: + field: name + direction: asc + auto_create: true + auto_create_bundle: '' + field_type: entity_reference + field.field.media.image.categories: + uuid: 3b25b8ea-2890-4585-a606-703499427fb5 + langcode: en + status: true + dependencies: + config: + - field.storage.media.categories + - media.type.image + - taxonomy.vocabulary.categories + id: media.image.categories + field_name: categories + entity_type: media + bundle: image + label: Categories + description: '' + required: false + translatable: false + default_value: { } + default_value_callback: '' + settings: + handler: 'default:taxonomy_term' + handler_settings: + target_bundles: + categories: categories + sort: + field: name + direction: asc + auto_create: false + auto_create_bundle: '' + field_type: entity_reference + field.field.media.image.media_image: + uuid: c6acba7d-93e7-4eaf-9a57-3f4066cbf2be + langcode: en + status: true + dependencies: + config: + - field.storage.media.media_image + - media.type.image + module: + - image + id: media.image.media_image + field_name: media_image + entity_type: media + bundle: image + label: Image + description: '' + required: true + translatable: true + default_value: { } + default_value_callback: '' + settings: + handler: 'default:file' + handler_settings: { } + file_directory: '[date:custom:Y]-[date:custom:m]' + file_extensions: 'png gif jpg jpeg' + max_filesize: '5 MB' + max_resolution: '' + min_resolution: '' + alt_field: true + alt_field_required: true + title_field: false + title_field_required: false + default_image: + uuid: null + alt: '' + title: '' + width: null + height: null + field_type: image + field.field.media.image.tags: + uuid: 18850dbd-8d73-4316-a59e-74ad80b5cded + langcode: en + status: true + dependencies: + config: + - field.storage.media.tags + - media.type.image + - taxonomy.vocabulary.tags + id: media.image.tags + field_name: tags + entity_type: media + bundle: image + label: Tags + description: '' + required: false + translatable: false + default_value: { } + default_value_callback: '' + settings: + handler: 'default:taxonomy_term' + handler_settings: + target_bundles: + tags: tags + sort: + field: name + direction: asc + auto_create: true + auto_create_bundle: '' + field_type: entity_reference + field.field.media.remote_video.categories: + uuid: 0a76c8ff-04aa-4b44-995f-44317c17a749 + langcode: en + status: true + dependencies: + config: + - field.storage.media.categories + - media.type.remote_video + - taxonomy.vocabulary.categories + id: media.remote_video.categories + field_name: categories + entity_type: media + bundle: remote_video + label: Categories + description: '' + required: false + translatable: false + default_value: { } + default_value_callback: '' + settings: + handler: 'default:taxonomy_term' + handler_settings: + target_bundles: + categories: categories + sort: + field: name + direction: asc + auto_create: false + auto_create_bundle: '' + field_type: entity_reference + field.field.media.remote_video.media_oembed_video: + uuid: 4fbb19e5-e680-4f44-9ebe-16d5dc0d8b3b + langcode: en + status: true + dependencies: + config: + - field.storage.media.media_oembed_video + - media.type.remote_video + id: media.remote_video.media_oembed_video + field_name: media_oembed_video + entity_type: media + bundle: remote_video + label: 'Remote video URL' + description: '' + required: true + translatable: true + default_value: { } + default_value_callback: '' + settings: { } + field_type: string + field.field.media.remote_video.tags: + uuid: 56fe693a-f53e-4115-afda-76e3c9582f4c + langcode: en + status: true + dependencies: + config: + - field.storage.media.tags + - media.type.remote_video + - taxonomy.vocabulary.tags + id: media.remote_video.tags + field_name: tags + entity_type: media + bundle: remote_video + label: Tags + description: '' + required: false + translatable: false + default_value: { } + default_value_callback: '' + settings: + handler: 'default:taxonomy_term' + handler_settings: + target_bundles: + tags: tags + sort: + field: name + direction: asc + auto_create: true + auto_create_bundle: '' + field_type: entity_reference + field.field.media.video.categories: + uuid: 16d504e1-c6c6-4708-8aa5-5cd43f5ca5f5 + langcode: en + status: true + dependencies: + config: + - field.storage.media.categories + - media.type.video + - taxonomy.vocabulary.categories + id: media.video.categories + field_name: categories + entity_type: media + bundle: video + label: Categories + description: '' + required: false + translatable: false + default_value: { } + default_value_callback: '' + settings: + handler: 'default:taxonomy_term' + handler_settings: + target_bundles: + categories: categories + sort: + field: name + direction: asc + auto_create: false + auto_create_bundle: '' + field_type: entity_reference + field.field.media.video.media_video_file: + uuid: 0349bb02-418c-4e89-8071-dbca654ab025 + langcode: en + status: true + dependencies: + config: + - field.storage.media.media_video_file + - media.type.video + module: + - file + id: media.video.media_video_file + field_name: media_video_file + entity_type: media + bundle: video + label: 'Video file' + description: '' + required: true + translatable: true + default_value: { } + default_value_callback: '' + settings: + handler: 'default:file' + handler_settings: { } + file_directory: '[date:custom:Y]-[date:custom:m]' + file_extensions: 'mp4 webm' + max_filesize: '' + description_field: false + field_type: file + field.field.media.video.tags: + uuid: 626f4fbf-0f3d-4ea9-bccc-7bb31d7fc5c3 + langcode: en + status: true + dependencies: + config: + - field.storage.media.tags + - media.type.video + - taxonomy.vocabulary.tags + id: media.video.tags + field_name: tags + entity_type: media + bundle: video + label: Tags + description: '' + required: false + translatable: false + default_value: { } + default_value_callback: '' + settings: + handler: 'default:taxonomy_term' + handler_settings: + target_bundles: + tags: tags + sort: + field: name + direction: asc + auto_create: true + auto_create_bundle: '' + field_type: entity_reference + field.field.node.article.author_profile: + uuid: fc92655c-12ab-4ff9-89a3-249f3a301ca5 + langcode: en + status: true + dependencies: + config: + - field.storage.node.author_profile + - node.type.article + - node.type.person + _core: + default_config_hash: Ty2qU2W4G9orxERW-YGWOFpG99QRetfhcW0Nz_zyA6g + id: node.article.author_profile + field_name: author_profile + entity_type: node + bundle: article + label: 'Author profile' + description: '' + required: false + translatable: false + default_value: { } + default_value_callback: '' + settings: + handler: 'default:node' + handler_settings: + target_bundles: + person: person + sort: + field: _none + direction: ASC + auto_create: false + auto_create_bundle: '' + field_type: entity_reference + field.field.node.article.body: + uuid: 66b76963-a33f-4b51-83b1-bf1626b0e270 + langcode: en + status: true + dependencies: + config: + - field.storage.node.body + - node.type.article + module: + - text + _core: + default_config_hash: fEIT1MRCWfrspUK3KUN7kZgMo5HCXRGz1l1xcEPueoI + id: node.article.body + field_name: body + entity_type: node + bundle: article + label: Body + description: '' + required: false + translatable: true + default_value: { } + default_value_callback: '' + settings: + display_summary: true + required_summary: false + allowed_formats: { } + field_type: text_with_summary + field.field.node.article.categories: + uuid: c4587ecd-f4c2-4284-a6fe-f91e75ab3324 + langcode: en + status: true + dependencies: + config: + - field.storage.node.categories + - node.type.article + - taxonomy.vocabulary.categories + _core: + default_config_hash: Kez1LIlwG0qMCz8l7_1PCPiqwdLA3UTXdvY7kIuCDYQ + id: node.article.categories + field_name: categories + entity_type: node + bundle: article + label: Categories + description: '' + required: false + translatable: false + default_value: { } + default_value_callback: '' + settings: + handler: 'default:taxonomy_term' + handler_settings: + target_bundles: + categories: categories + sort: + field: name + direction: asc + auto_create: false + auto_create_bundle: '' + field_type: entity_reference + field.field.node.article.cover_image: + uuid: a80e408f-75d9-4237-93ef-2d8033fb7eba + langcode: en + status: true + dependencies: + config: + - field.storage.node.cover_image + - media.type.image + - node.type.article + _core: + default_config_hash: XGSMo9v2c3WJIdt8MUxqH_y2Ea5WlgExYE07q2D6NTE + id: node.article.cover_image + field_name: cover_image + entity_type: node + bundle: article + label: 'Cover Image' + description: '' + required: false + translatable: false + default_value: { } + default_value_callback: '' + settings: + handler: 'default:media' + handler_settings: + target_bundles: + image: image + sort: + field: _none + direction: ASC + auto_create: false + auto_create_bundle: '' + field_type: entity_reference + field.field.node.article.media: + uuid: fffffa10-b9c7-490e-bcc1-e7c951d0b6f3 + langcode: en + status: true + dependencies: + config: + - field.storage.node.media + - media.type.document + - media.type.image + - media.type.remote_video + - media.type.video + - node.type.article + _core: + default_config_hash: drZbG6MSHksx6F7mhTYkCYn_Qr8Mmfg4Odu7lC2XDQ0 + id: node.article.media + field_name: media + entity_type: node + bundle: article + label: Media + description: '' + required: false + translatable: false + default_value: { } + default_value_callback: '' + settings: + handler: 'default:media' + handler_settings: + target_bundles: + document: document + image: image + remote_video: remote_video + video: video + sort: + field: _none + direction: ASC + auto_create: false + auto_create_bundle: document + field_type: entity_reference + field.field.node.article.related_articles: + uuid: 22043365-10fa-491b-82ac-ae598f255055 + langcode: en + status: true + dependencies: + config: + - field.storage.node.related_articles + - node.type.article + _core: + default_config_hash: CfDI8wI2SVzG-SaJSGPrk2xofxa7CNZqvUUlpfy7_0s + id: node.article.related_articles + field_name: related_articles + entity_type: node + bundle: article + label: 'Related articles' + description: '' + required: false + translatable: false + default_value: { } + default_value_callback: '' + settings: + handler: 'default:node' + handler_settings: + target_bundles: + article: article + sort: + field: _none + direction: ASC + auto_create: false + auto_create_bundle: '' + field_type: entity_reference + field.field.node.article.tags: + uuid: ea9b5a2c-9373-431f-a705-54063c206984 + langcode: en + status: true + dependencies: + config: + - field.storage.node.tags + - node.type.article + - taxonomy.vocabulary.tags + _core: + default_config_hash: JQMY1oW3WFdyPXlo94jyczyGwYJqvkKhKmprchmnM5w + id: node.article.tags + field_name: tags + entity_type: node + bundle: article + label: Tags + description: '' + required: false + translatable: false + default_value: { } + default_value_callback: '' + settings: + handler: 'default:taxonomy_term' + handler_settings: + target_bundles: + tags: tags + sort: + field: name + direction: asc + auto_create: true + auto_create_bundle: '' + field_type: entity_reference + field.field.node.person.bio: + uuid: 05d4ad42-d287-47a9-b5a1-bbbd8e06ae5d + langcode: en + status: true + dependencies: + config: + - field.storage.node.bio + - node.type.person + module: + - text + _core: + default_config_hash: 7l84MnU9HwIC5EsqNNiorKGAnoeIYpfZmu8NRuPu9bc + id: node.person.bio + field_name: bio + entity_type: node + bundle: person + label: Bio + description: '' + required: false + translatable: false + default_value: { } + default_value_callback: '' + settings: + allowed_formats: { } + field_type: text_long + field.field.node.person.first_name: + uuid: e4c205e0-6c5f-445d-8f3f-753600f0632c + langcode: en + status: true + dependencies: + config: + - field.storage.node.first_name + - node.type.person + _core: + default_config_hash: TxsRv0JCOkhhHlfNBBAQdLD2Cz13Q6n5gb5Jtb3fQbA + id: node.person.first_name + field_name: first_name + entity_type: node + bundle: person + label: 'First name' + description: '' + required: false + translatable: false + default_value: { } + default_value_callback: '' + settings: { } + field_type: string + field.field.node.person.job_title: + uuid: b5302b8e-e4bc-41fb-9da5-4e6c5392fb38 + langcode: en + status: true + dependencies: + config: + - field.storage.node.job_title + - node.type.person + _core: + default_config_hash: 6E9egi3l_sG2vypsaF3wsNizzes8Tniv7O9YiP_50mM + id: node.person.job_title + field_name: job_title + entity_type: node + bundle: person + label: 'Job title' + description: '' + required: false + translatable: false + default_value: { } + default_value_callback: '' + settings: { } + field_type: string + field.field.node.person.last_name: + uuid: eefce936-4e7a-4d99-bbe4-830b56da5853 + langcode: en + status: true + dependencies: + config: + - field.storage.node.last_name + - node.type.person + _core: + default_config_hash: J_Dxi1m4ypCIQDm7rGRyGHMZEKlpH71lMBwQujdsbUI + id: node.person.last_name + field_name: last_name + entity_type: node + bundle: person + label: 'Last name' + description: '' + required: false + translatable: false + default_value: { } + default_value_callback: '' + settings: { } + field_type: string + field.field.node.person.profile_image: + uuid: 1b277342-55f9-4a53-8389-c4f05570e4b0 + langcode: en + status: true + dependencies: + config: + - field.storage.node.profile_image + - media.type.image + - node.type.person + _core: + default_config_hash: pYYEZRKCY_nAsQDyVZWbbEdl3FxXXSEiQM3ztrdPrHM + id: node.person.profile_image + field_name: profile_image + entity_type: node + bundle: person + label: 'Profile image' + description: '' + required: false + translatable: false + default_value: { } + default_value_callback: '' + settings: + handler: 'default:media' + handler_settings: + target_bundles: + image: image + sort: + field: _none + direction: ASC + auto_create: false + auto_create_bundle: '' + field_type: entity_reference + field.storage.media.categories: + uuid: 33c3323c-cc16-4f3e-9ca1-4148e24d7c8c + langcode: en + status: true + dependencies: + module: + - media + - taxonomy + _core: + default_config_hash: EMuVBQ75PY6QWCLAWkLxUnNUyR_J6hcSyz6dnEGLmYU + id: media.categories + field_name: categories + entity_type: media + type: entity_reference + settings: + target_type: taxonomy_term + module: core + locked: false + cardinality: -1 + translatable: true + indexes: { } + persist_with_no_fields: false + custom_storage: false + field.storage.media.media_file: + uuid: 134fb0bd-9f5b-4cf4-93e4-dea848ee6c4c + langcode: en + status: true + dependencies: + module: + - file + - media + id: media.media_file + field_name: media_file + entity_type: media + type: file + settings: + target_type: file + display_field: false + display_default: false + uri_scheme: public + module: file + locked: false + cardinality: 1 + translatable: true + indexes: { } + persist_with_no_fields: false + custom_storage: false + field.storage.media.media_image: + uuid: 5812e4c8-7d68-475a-9a9b-d0636484dc7c + langcode: en + status: true + dependencies: + module: + - file + - image + - media + id: media.media_image + field_name: media_image + entity_type: media + type: image + settings: + target_type: file + display_field: false + display_default: false + uri_scheme: public + default_image: + uuid: null + alt: '' + title: '' + width: null + height: null + module: image + locked: false + cardinality: 1 + translatable: true + indexes: { } + persist_with_no_fields: false + custom_storage: false + field.storage.media.media_oembed_video: + uuid: ba80e088-b394-4540-ab44-2f825727584a + langcode: en + status: true + dependencies: + module: + - media + id: media.media_oembed_video + field_name: media_oembed_video + entity_type: media + type: string + settings: + max_length: 255 + case_sensitive: false + is_ascii: false + module: core + locked: false + cardinality: 1 + translatable: true + indexes: { } + persist_with_no_fields: false + custom_storage: false + field.storage.media.media_video_file: + uuid: 16bbd58a-f1ed-4857-8c25-66ad4690bc2f + langcode: en + status: true + dependencies: + module: + - file + - media + id: media.media_video_file + field_name: media_video_file + entity_type: media + type: file + settings: + target_type: file + display_field: false + display_default: false + uri_scheme: public + module: file + locked: false + cardinality: 1 + translatable: true + indexes: { } + persist_with_no_fields: false + custom_storage: false + field.storage.media.tags: + uuid: eb2e25c2-1bd9-43c1-b55f-d9fe812c4e51 + langcode: en + status: true + dependencies: + module: + - media + - taxonomy + _core: + default_config_hash: nsDHegRPxHC91wiJ5U-5EJHt7DPaSRwfi_ASFXLmKJ0 + id: media.tags + field_name: tags + entity_type: media + type: entity_reference + settings: + target_type: taxonomy_term + module: core + locked: false + cardinality: -1 + translatable: true + indexes: { } + persist_with_no_fields: false + custom_storage: false + field.storage.node.author_profile: + uuid: 0b176e07-472d-48c4-b85c-dc6eaf5f4418 + langcode: en + status: true + dependencies: + module: + - node + _core: + default_config_hash: j6F8e6ZXfehSgEtIgt5RlqvE2u3wEGrQzsePMnFmCJY + id: node.author_profile + field_name: author_profile + entity_type: node + type: entity_reference + settings: + target_type: node + module: core + locked: false + cardinality: 1 + translatable: true + indexes: { } + persist_with_no_fields: false + custom_storage: false + field.storage.node.bio: + uuid: 26e37c7e-a149-4806-b76a-accd26fb4313 + langcode: en + status: true + dependencies: + module: + - node + - text + _core: + default_config_hash: cjeVsWv_HNS6yv-LIYKE9GnyDAZn4h9IHSyREZMSnJA + id: node.bio + field_name: bio + entity_type: node + type: text_long + settings: { } + module: text + locked: false + cardinality: 1 + translatable: true + indexes: { } + persist_with_no_fields: false + custom_storage: false + field.storage.node.body: + uuid: 4f7870f6-0e18-4a78-8768-2c5b5a979855 + langcode: en + status: true + dependencies: + module: + - node + - text + _core: + default_config_hash: MUIKkjeexJ6Ditr19_dwVpmPBXBMpqyId3UXHlqrlXA + id: node.body + field_name: body + entity_type: node + type: text_with_summary + settings: { } + module: text + locked: false + cardinality: 1 + translatable: true + indexes: { } + persist_with_no_fields: true + custom_storage: false + field.storage.node.categories: + uuid: 70f9a5f7-ba7f-4b38-b5a9-be475b029982 + langcode: en + status: true + dependencies: + module: + - node + - taxonomy + _core: + default_config_hash: _vzK8VMUJt-ef0dvEzZL8ePpFHT2XHkk7PkVH5gvNOE + id: node.categories + field_name: categories + entity_type: node + type: entity_reference + settings: + target_type: taxonomy_term + module: core + locked: false + cardinality: -1 + translatable: true + indexes: { } + persist_with_no_fields: false + custom_storage: false + field.storage.node.cover_image: + uuid: 5ab86e70-c3a4-4f64-8a49-6f670d976aea + langcode: en + status: true + dependencies: + module: + - media + - node + _core: + default_config_hash: Peu21t69lDVGV9QEO06-eV0n8cg9N6EzyRTQ7KZ4Vd4 + id: node.cover_image + field_name: cover_image + entity_type: node + type: entity_reference + settings: + target_type: media + module: core + locked: false + cardinality: 1 + translatable: true + indexes: { } + persist_with_no_fields: false + custom_storage: false + field.storage.node.first_name: + uuid: a9e1f580-9e28-45aa-b032-92fe41a19132 + langcode: en + status: true + dependencies: + module: + - node + _core: + default_config_hash: N4idHxzebWKnBwWxyzfl50iY4ptpCXfsMLibUlCXiDw + id: node.first_name + field_name: first_name + entity_type: node + type: string + settings: + max_length: 255 + case_sensitive: false + is_ascii: false + module: core + locked: false + cardinality: 1 + translatable: true + indexes: { } + persist_with_no_fields: false + custom_storage: false + field.storage.node.job_title: + uuid: c6ad9cf4-f05f-4cd8-99e6-664c6ce0ed5b + langcode: en + status: true + dependencies: + module: + - node + _core: + default_config_hash: Fy4RJGjHzrbwmCsymSvEN0m-28d_Nggwq7dTFOoceBw + id: node.job_title + field_name: job_title + entity_type: node + type: string + settings: + max_length: 255 + case_sensitive: false + is_ascii: false + module: core + locked: false + cardinality: 1 + translatable: true + indexes: { } + persist_with_no_fields: false + custom_storage: false + field.storage.node.last_name: + uuid: 85f0c7f6-4ec1-4aff-a365-799d88baea43 + langcode: en + status: true + dependencies: + module: + - node + _core: + default_config_hash: 16tNQBFfe4YcFj4IYhB8W9tXxWV5DlzLXokG1SZim38 + id: node.last_name + field_name: last_name + entity_type: node + type: string + settings: + max_length: 255 + case_sensitive: false + is_ascii: false + module: core + locked: false + cardinality: 1 + translatable: true + indexes: { } + persist_with_no_fields: false + custom_storage: false + field.storage.node.media: + uuid: 323235ac-c805-41fc-885a-3f52b60adcc8 + langcode: en + status: true + dependencies: + module: + - media + - node + _core: + default_config_hash: azt40w38SQY5-AE1YBcqUXi4EMrKmLUJJ915h9NM9iY + id: node.media + field_name: media + entity_type: node + type: entity_reference + settings: + target_type: media + module: core + locked: false + cardinality: -1 + translatable: true + indexes: { } + persist_with_no_fields: false + custom_storage: false + field.storage.node.profile_image: + uuid: 31ca357c-141a-41c8-b017-300590cf7649 + langcode: en + status: true + dependencies: + module: + - media + - node + _core: + default_config_hash: FWYb-Gltcu5bYj1Jc5hc_V5jsIlEJVEPfjogRDMTR38 + id: node.profile_image + field_name: profile_image + entity_type: node + type: entity_reference + settings: + target_type: media + module: core + locked: false + cardinality: 1 + translatable: true + indexes: { } + persist_with_no_fields: false + custom_storage: false + field.storage.node.related_articles: + uuid: a6ffa49b-11bf-4d0e-86a0-61846fd10d8e + langcode: en + status: true + dependencies: + module: + - node + _core: + default_config_hash: tJ5vXWhmoQkCF_9tfIBzesrfEf8QY_WI2wQiZn8wuzk + id: node.related_articles + field_name: related_articles + entity_type: node + type: entity_reference + settings: + target_type: node + module: core + locked: false + cardinality: -1 + translatable: true + indexes: { } + persist_with_no_fields: false + custom_storage: false + field.storage.node.tags: + uuid: d8473663-8d69-4a2b-a456-bfbd777b20b7 + langcode: en + status: true + dependencies: + module: + - node + - taxonomy + _core: + default_config_hash: 7GUCylaSVOHvvTEKJQjhPmxYisi6rljPaBEw-dfGeg4 + id: node.tags + field_name: tags + entity_type: node + type: entity_reference + settings: + target_type: taxonomy_term + module: core + locked: false + cardinality: -1 + translatable: true + indexes: { } + persist_with_no_fields: false + custom_storage: false + filter.format.canvas_html_block: + uuid: 37f4f0ab-15b2-4ba2-9304-83aa106bedab + langcode: en + status: true + dependencies: + enforced: + module: + - canvas + name: 'Drupal Canvas — Block HTML (locked)' + format: canvas_html_block + weight: 1000 + filters: + filter_html: + id: filter_html + provider: filter + status: true + weight: 0 + settings: + allowed_html: '