From 47db092c6f985e7c0a08cd0e3bc90c08fa8ac225 Mon Sep 17 00:00:00 2001 From: K Widholm <279278+apotek@users.noreply.github.com> Date: Thu, 31 Oct 2024 17:26:51 -0400 Subject: [PATCH] Allow aws commands to use aws environment variables for auth (#223) * Allow aws commands to use aws environment variables for auth Do not assume credential file creation will have valid credentials. Begin to move away from deprecated ->io constructs. Improve flow of event signaling in refresh tasks Streamline s3 request configuration; remove duplicate calls Don't run cron or cache clears on a site with no data Persist credentials through restarts Resolves #222 --- phpstan.neon | 2 +- .../Commands/DevelopmentModeCommands.php | 138 +++++++++------ src/Robo/Plugin/Commands/ThemeCommands.php | 2 +- .../Plugin/Traits/DatabaseDownloadTrait.php | 166 ++++++++++++------ src/Robo/Plugin/Traits/SitesConfigTrait.php | 61 +++++-- 5 files changed, 239 insertions(+), 130 deletions(-) diff --git a/phpstan.neon b/phpstan.neon index 3a7a0b1..db94037 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,7 +1,7 @@ parameters: - checkMissingIterableValueType: false customRulesetUsed: true ignoreErrors: + - identifier: missingType.iterableValue - message: '#\Call to deprecated method io\(\) of class Robo\\Tasks#' paths: - 'src/' diff --git a/src/Robo/Plugin/Commands/DevelopmentModeCommands.php b/src/Robo/Plugin/Commands/DevelopmentModeCommands.php index 4c329bb..8f0d288 100644 --- a/src/Robo/Plugin/Commands/DevelopmentModeCommands.php +++ b/src/Robo/Plugin/Commands/DevelopmentModeCommands.php @@ -6,6 +6,7 @@ use Robo\Exception\TaskException; use Robo\Result; use Robo\ResultData; +use Robo\Symfony\ConsoleIO; use Robo\Tasks; use Symfony\Component\Yaml\Yaml; use Usher\Robo\Plugin\Enums\LocalDevEnvironmentTypes; @@ -76,11 +77,13 @@ public function __construct() * @aliases magic */ public function devRefresh( + ConsoleIO $io, string $siteName = 'default', array $options = ['db' => '', 'environment-type' => 'ddev'], - ): Result { + ): Result|ResultData { ['db' => $dbPath, 'environment-type' => $environmentType] = $options; return $this->devRefreshDrupal( + io: $io, environmentType: LocalDevEnvironmentTypes::from($environmentType), siteName: $siteName, databasePath: $dbPath, @@ -103,20 +106,28 @@ public function devRefresh( * Specify alternative (supported) environment type. See LocalDevEnvironmentTypes enum. */ public function devRefreshAll( + ConsoleIO $io, array $options = ['skip-sites' => '', 'environment-type' => 'ddev'] - ): Result { + ): Result|ResultData { ['skip-sites' => $skipSites, 'environment-type' => $environmentType] = $options; $siteNames = $this->getAllSiteNames(); - $result = null; + $result = ResultData::message('No sites were refreshed because all sites were configured to be skipped.'); foreach ($siteNames as $siteName) { if (in_array($siteName, explode(separator: ',', string: (string) $skipSites), true)) { continue; } + /** @var Result|ResultData $result */ $result = $this->devRefreshDrupal( + $io, environmentType: LocalDevEnvironmentTypes::from($environmentType), siteName: $siteName, ); + if ($result->wasCancelled()) { + $io->say("Cancelling the refresh for all sites."); + return $result; + } } + return $result; } @@ -128,27 +139,42 @@ public function devRefreshAll( * @option db * Provide a path to a database dump to be used instead of downloading the latest dump. */ - public function databaseRefreshDdev(string $siteName = 'default', array $options = ['db' => '']): Result - { - // @todo: Update this method to not be DDEV specific. - $this->io()->title('DDEV database refresh.'); + public function databaseRefreshDdev( + ConsoleIO $io, + string $siteName = 'default', + array $options = ['db' => ''] + ): Result|ResultData { + $io->title('DDEV database refresh.'); ['db' => $dbPath] = $options; // Track whether a database path was provided by the user or not. $dbPathProvidedByUser = $dbPath !== ''; if (!$dbPathProvidedByUser) { - $dbPath = $this->databaseDownload($siteName); + try { + $dbPath = $this->databaseDownload($io, $siteName); + } catch (TaskException $e) { + $resultData = new ResultData(ResultData::EXITCODE_ERROR, $e->getMessage()); + $io->yell("$siteName: No database configured. Download/import skipped."); + // @todo: Should we run a site-install by default? The "common" + // site ends up here, but we could change that. + return $resultData; + } + // If we don't have a file path string here, the action was + // cancelled and we should respond with the cancellation. + if (!is_string($dbPath)) { + return $dbPath; + } } - $this->io()->section("refreshing $siteName database."); - $this->say("Dropping existing database for $siteName"); + $io->section("refreshing $siteName database."); + $io->say("Dropping existing database for $siteName"); $this->taskExec('drush') ->arg('sql:drop') ->option('uri', $siteName) ->option('yes') ->run(); - $this->say("Importing $dbPath"); + $io->say("Importing $dbPath"); $this->_exec("zcat '$dbPath' | drush sql:cli --uri=$siteName"); // If a database was downloaded as part of this process, delete it. if (!$dbPathProvidedByUser) { @@ -156,6 +182,7 @@ public function databaseRefreshDdev(string $siteName = 'default', array $options } return $this->drushDeployWith( + io: $io, localEnvironmentType: LocalDevEnvironmentTypes::DDEV, siteDir: $siteName, ); @@ -164,23 +191,24 @@ public function databaseRefreshDdev(string $siteName = 'default', array $options /** * Refresh database on Tugboat. */ - public function databaseRefreshTugboat(): ResultData + public function databaseRefreshTugboat(ConsoleIO $io): ResultData { - $this->io()->title('refresh tugboat databases.'); + $io->title('refresh tugboat databases.'); $resultData = new ResultData(); foreach (array_keys($this->getAllSitesConfig()) as $siteName) { $dbPath = ''; try { - $dbPath = $this->databaseDownload($siteName); + $dbPath = $this->databaseDownload($io, $siteName); } catch (TaskException $e) { - $this->yell("$siteName: No database configured. Download/import skipped."); + $io->yell("$siteName: No database configured. Download/import skipped."); $resultData->append($e->getMessage()); - // @todo: Should we run a site-install by default? + // @todo: Should we run a site-install by default? The "common" + // site ends up here, but we could change that. continue; } if (!is_string($dbPath) || $dbPath === '') { - $this->yell("'$siteName' database path not found."); + $io->yell("'$siteName' database path not found."); $resultData->append("'$siteName' database path not found."); continue; } @@ -198,7 +226,7 @@ public function databaseRefreshTugboat(): ResultData ->option('-e', "drop database if exists $dbName; create database $dbName;") ->run(); $resultData->append($taskResult); - $this->io()->section("import $siteName database."); + $io->section("import $siteName database."); $taskResult = $this->taskExec("zcat $dbPath | $dbDriver -h mariadb -u tugboat -ptugboat $dbName") ->run(); $resultData->append($taskResult); @@ -216,16 +244,17 @@ public function databaseRefreshTugboat(): ResultData * @aliases uli * * @param string $environmentType - * Specify local development enviroment: ddev. This value is a string instead of LocalDevEnvironmentTypes since + * Specify local development environment: ddev. This value is a string instead of LocalDevEnvironmentTypes since * it is a public command that can be called from the command line. * @param string $siteDir * The Drupal site directory name. */ public function drupalLoginLink( + ConsoleIO $io, string $environmentType, string $siteDir = 'default', ): Result { - $this->io()->section("create login link."); + $io->section("create login link."); $uid = $this->getDrupalSiteAdminUid(siteName: $siteDir); if ($environmentType === 'ddev') { return $this->taskExec('drush') @@ -254,22 +283,22 @@ public function drupalLoginLink( * * @option boolean $yes Default answers to yes. * @aliases fedd - * - * @return \Robo\Result - * The result of the set of tasks. */ - public function frontendDevDisable(string $siteDir = 'default', array $opts = ['yes|y' => false]) - { + public function frontendDevDisable( + ConsoleIO $io, + string $siteDir = 'default', + array $opts = ['yes|y' => false] + ): Result|ResultData { $devSettingsPath = "$this->drupalRoot/sites/$siteDir/settings.local.php"; if (!$opts['yes']) { - $this->yell("This command will overwrite any customizations you have made to $devSettingsPath and + $io->yell("This command will overwrite any customizations you have made to $devSettingsPath and $this->devServicesPath."); - $yes = $this->io()->confirm('This command is destructive. Do you wish to continue?'); + $yes = $io->confirm('This command is destructive. Do you wish to continue?'); if (!$yes) { return Result::cancelled(); } } - $this->io()->title('disabling front-end development mode.'); + $io->title('disabling front-end development mode.'); return $this->collectionBuilder() ->taskFilesystemStack() ->remove($devSettingsPath) @@ -281,23 +310,26 @@ public function frontendDevDisable(string $siteDir = 'default', array $opts = [' * Refreshes a development environment based upon the Drupal version. */ protected function devRefreshDrupal( + ConsoleIO $io, LocalDevEnvironmentTypes $environmentType, string $siteName = 'default', string $databasePath = '', - ): Result { - $this->io()->title('development environment refresh. 🦄✨'); - $result = $this->taskComposerInstall()->run(); - + ): Result|ResultData { + $io->title('development environment refresh. 🦄✨'); + $this->taskComposerInstall()->run(); // There isn't a great way to call a command in one class from another. // https://github.com/consolidation/Robo/issues/743 // For now, it seems like calling robo from within robo works. - $result = $this->taskExec("composer robo theme:build $siteName") + $this->taskExec("composer robo theme:build $siteName") ->run(); - $result = $this->frontendDevEnable($siteName, ['yes' => true]); - - $result = $this->databaseRefreshDdev(siteName: $siteName, options: ['db' => $databasePath]); + $this->frontendDevEnable($io, $siteName, ['yes' => true]); + /** @var Result|ResultData $result */ + $result = $this->databaseRefreshDdev($io, siteName: $siteName, options: ['db' => $databasePath]); + if ($result->wasCancelled() || $result->getExitCode() !== ResultData::EXITCODE_OK) { + return $result; + } - return $this->drupalLoginLink($environmentType->value, $siteName); + return $this->drupalLoginLink($io, $environmentType->value, $siteName); } /** @@ -306,10 +338,11 @@ protected function devRefreshDrupal( * @see https://www.drush.org/deploycommand */ protected function drushDeployWith( + ConsoleIO $io, LocalDevEnvironmentTypes $localEnvironmentType, string $siteDir = 'default', ): Result { - $this->io()->section('drush deploy.'); + $io->section('drush deploy.'); if (!class_exists(\Drush\Commands\core\DeployCommands::class)) { throw new TaskException( $this, @@ -341,30 +374,30 @@ protected function drushDeployWith( * * @option boolean $yes Default answers to yes. * @aliases fede - * - * @return \Robo\Result - * The result of the set of tasks. */ - public function frontendDevEnable(string $siteDir = 'default', array $opts = ['yes|y' => false]) - { + public function frontendDevEnable( + ConsoleIO $io, + string $siteDir = 'default', + array $opts = ['yes|y' => false], + ): Result|ResultData { $devSettingsPath = "$this->drupalRoot/sites/$siteDir/settings.local.php"; if (!$opts['yes']) { - $this->yell("This command will overwrite any customizations you have made to $devSettingsPath and + $io->yell("This command will overwrite any customizations you have made to $devSettingsPath and $this->devServicesPath."); - $yes = $this->io()->confirm('This command is destructive. Do you wish to continue?'); + $yes = $io->confirm('This command is destructive. Do you wish to continue?'); if (!$yes) { return Result::cancelled(); } } - $this->io()->title('enabling front-end development mode.'); - $this->say("copying settings.local.php and development.services.yml into sites/$siteDir."); + $io->title('enabling front-end development mode.'); + $io->say("copying settings.local.php and development.services.yml into sites/$siteDir."); // Copy the example local settings file. $example_local_settings_file = "$this->drupalRoot/sites/example.settings.local.php"; if (file_exists($example_local_settings_file)) { - $result = $this->taskFilesystemStack() + $this->taskFilesystemStack() ->copy($example_local_settings_file, $devSettingsPath) ->run(); } else { @@ -376,7 +409,7 @@ public function frontendDevEnable(string $siteDir = 'default', array $opts = ['y // Copy the development services file. $development_services_file = "$this->drupalRoot/sites/development.services.yml"; if (file_exists($development_services_file)) { - $result = $this->taskFilesystemStack() + $this->taskFilesystemStack() ->copy($development_services_file, $this->devServicesPath, true) ->run(); } else { @@ -386,7 +419,7 @@ public function frontendDevEnable(string $siteDir = 'default', array $opts = ['y ); } - $this->say("optimizing twig for front-end development in development services yml config."); + $io->say("optimizing twig for front-end development in development services yml config."); $devServices = Yaml::parseFile($this->devServicesPath); $devServices['parameters']['twig.config'] = [ 'debug' => true, @@ -394,9 +427,9 @@ public function frontendDevEnable(string $siteDir = 'default', array $opts = ['y 'cache' => false, ]; $this->writeYaml($this->devServicesPath, $devServices); - $this->say("disabling render and dynamic_page_cache in settings.local.php."); + $io->say("disabling render and dynamic_page_cache in settings.local.php."); // https://github.com/consolidation/robo/issues/1059#issuecomment-967732068 - $result = $this->collectionBuilder() + return $this->collectionBuilder() ->taskReplaceInFile($devSettingsPath) ->from('/sites/development.services.yml') ->to("/sites/fe.development.services.yml") @@ -417,6 +450,5 @@ public function frontendDevEnable(string $siteDir = 'default', array $opts = ['y ->line(' */') ->line('$config[\'advagg.settings\'][\'enabled\'] = FALSE;') ->run(); - return $result; } } diff --git a/src/Robo/Plugin/Commands/ThemeCommands.php b/src/Robo/Plugin/Commands/ThemeCommands.php index 407c2a1..5c8291c 100644 --- a/src/Robo/Plugin/Commands/ThemeCommands.php +++ b/src/Robo/Plugin/Commands/ThemeCommands.php @@ -39,7 +39,7 @@ public function themeBuild(string $siteName = 'default'): Result try { $themeBuildConfiguration = $this->getSiteConfigItem('theme_build', $siteName); } catch (TaskException) { - $this->say("'$siteName' theme_build confguration not set."); + $this->say("'$siteName' theme_build configuration not set."); return $this->taskExec('echo skipping')->run(); } foreach ($themeBuildConfiguration as $themeConfig) { diff --git a/src/Robo/Plugin/Traits/DatabaseDownloadTrait.php b/src/Robo/Plugin/Traits/DatabaseDownloadTrait.php index 945b610..58711d2 100644 --- a/src/Robo/Plugin/Traits/DatabaseDownloadTrait.php +++ b/src/Robo/Plugin/Traits/DatabaseDownloadTrait.php @@ -2,9 +2,15 @@ namespace Usher\Robo\Plugin\Traits; +use AsyncAws\Core\Exception\Http\ClientException; +use AsyncAws\Core\Sts\StsClient; use AsyncAws\S3\S3Client; +use AsyncAws\S3\ValueObject\AwsObject; +use Robo\Exception\AbortTasksException; use Robo\Exception\TaskException; use Robo\Result; +use Robo\ResultData; +use Robo\Symfony\ConsoleIO; /** * Trait to provide database download functionality to Robo commands. @@ -13,10 +19,8 @@ trait DatabaseDownloadTrait { /** * Default S3 region. - * - * @var string */ - protected $s3DefaultRegion = 'us-east-1'; + protected string $s3DefaultRegion = 'us-east-1'; /** * Download the latest database dump for the site. @@ -26,25 +30,54 @@ trait DatabaseDownloadTrait * * @aliases dbdl * - * @throws \Robo\Exception\TaskException + * @throws \Robo\Exception\TaskException|\Robo\Exception\AbortTasksException */ - public function databaseDownload(string $siteName = 'default'): string|\Robo\Result + public function databaseDownload(ConsoleIO $io, string $siteName = 'default'): string|ResultData { - $this->io()->title('database download.'); - - $awsConfigDirPath = getenv('HOME') . '/.aws'; - $awsConfigFilePath = "$awsConfigDirPath/credentials"; - if (!is_dir($awsConfigDirPath) || !file_exists($awsConfigFilePath)) { - $result = $this->configureAwsCredentials($awsConfigDirPath, $awsConfigFilePath); - if ($result->wasCancelled()) { + $io->title('database download.'); + + $authenticated = false; + $objects = null; + $region = $this->s3RegionForSite($siteName); + $s3Bucket = $this->s3BucketForSite($siteName); + $s3Prefix = $this->s3PrefixForSite($siteName); + $requestConfig = ['Bucket' => $s3Bucket]; + if ($s3Prefix !== '') { + $requestConfig[] = ['Prefix' => $s3Prefix]; + } + $s3 = new S3Client(['region' => $region]); + try { + $io->say("Connecting to S3..."); + $objects = $s3->listObjectsV2($requestConfig); + $objects->resolve(); + $authenticated = $objects->info()['status'] === 200; + } catch (ClientException $e) { + $io->error($e->getMessage()); + } + if (!$authenticated) { + if (getenv('AWS_SECRET_ACCESS_KEY')) { + $io->error([ + 'Cannot authenticate to AWS S3. Please fix your AWS environment', + 'variables in .env, or remove them completely and try again.', + ]); return Result::cancelled(); } + $io->say("Unable to authenticate to AWS S3. + You can either set your credentials as environment variables and try again, + or you can continue by configuring your AWS credentials file."); + $result = $this->configureAwsCredentials($io); + if ($result->wasCancelled()) { + return $result; + } + $s3 = new S3Client(['region' => $region]); + try { + $objects = $s3->listObjectsV2($requestConfig); + } catch (\Exception $e) { + $io->error($e->getMessage()); + throw new AbortTasksException('Unable to access AWS S3. Giving up.'); + } } - $s3 = new S3Client([ - 'region' => $this->s3RegionForSite($siteName), - ]); - $objects = $s3->listObjectsV2($this->s3BucketRequestConfig($siteName)); $objects = iterator_to_array($objects); if (count($objects) == 0) { throw new TaskException($this, "No database dumps found for '$siteName'."); @@ -52,8 +85,8 @@ public function databaseDownload(string $siteName = 'default'): string|\Robo\Res // Ensure objects are sorted by last modified date. usort( array: $objects, - /** @var \AsyncAws\S3\ValueObject\AwsObject $a */ - callback: fn($a, $b) => $a->getLastModified()->getTimestamp() <=> $b->getLastModified()->getTimestamp(), + callback: fn(AwsObject $a, AwsObject $b) => + $a->getLastModified()->getTimestamp() <=> $b->getLastModified()->getTimestamp(), ); /** @var \AsyncAws\S3\ValueObject\AwsObject $latestDatabaseDump */ $latestDatabaseDump = array_pop(array: $objects); @@ -64,7 +97,7 @@ public function databaseDownload(string $siteName = 'default'): string|\Robo\Res $this->say("Skipping download. Latest database dump file exists >>> $downloadFileName"); } else { $result = $s3->getObject([ - 'Bucket' => $this->s3BucketForSite($siteName), + 'Bucket' => $s3Bucket, 'Key' => $dbFilename, ]); stream_copy_to_stream( @@ -78,70 +111,87 @@ public function databaseDownload(string $siteName = 'default'): string|\Robo\Res /** * Configure AWS credentials. - * - * @param string $awsConfigDirPath - * Path to the AWS configuration directory. - * @param string $awsConfigFilePath - * Path to the AWS configuration file. */ - protected function configureAwsCredentials(string $awsConfigDirPath, string $awsConfigFilePath): Result + protected function configureAwsCredentials(ConsoleIO $io): Result|ResultData { - $yes = $this->io()->confirm('AWS S3 credentials not detected. Do you wish to configure them?'); - if (!$yes) { - return Result::cancelled(); - } - - if (!is_dir($awsConfigDirPath)) { - $this->_mkdir($awsConfigDirPath); - } + $awsConfigDirPath = getenv('HOME') . '/.aws'; + $awsConfigFilePath = "$awsConfigDirPath/credentials"; + $persistentCredentialsPath = '.ddev/homeadditions/.aws'; + $authenticated = false; - if (!file_exists($awsConfigFilePath)) { - $this->_touch($awsConfigFilePath); + while ($authenticated === false) { + $yes = $io->confirm('Do you wish to configure your AWS S3 credentials file?'); + if (!$yes) { + return Result::cancelled(); + } + if (!is_dir($awsConfigDirPath)) { + $this->_mkdir($awsConfigDirPath); + } + if (!file_exists($awsConfigFilePath)) { + $this->_touch($awsConfigFilePath); + } + $awsKeyId = $io->ask("AWS Access Key ID:"); + $awsSecretKey = $io->askHidden("AWS Secret Access Key:"); + $collection = $this->collectionBuilder($io); + $collection->taskWriteToFile($awsConfigFilePath) + ->line('[default]') + ->line("aws_access_key_id = $awsKeyId") + ->line("aws_secret_access_key = $awsSecretKey"); + $collection->taskCopyDir([$awsConfigDirPath => $persistentCredentialsPath]) + ->overwrite(true); + $writeResult = $collection->run(); + try { + $sts = new StsClient(); + $stsResponse = $sts->getCallerIdentity(); + // Ensure the request is completed. + if ($stsResponse->resolve()) { + $authenticated = $stsResponse->info()['status'] === 200; + } + } catch (\Exception $e) { + $io->error($e->getMessage()); + } } - $awsKeyId = $this->ask("AWS Access Key ID:"); - $awsSecretKey = $this->askHidden("AWS Secret Access Key:"); - return $this->taskWriteToFile($awsConfigFilePath) - ->line('[default]') - ->line("aws_access_key_id = $awsKeyId") - ->line("aws_secret_access_key = $awsSecretKey") - ->run(); + return $writeResult; } /** - * Build S3 request configuration from sites config. + * Get S3 Bucket for site. * * @param string $siteName * The site name. + * + * @throws \Robo\Exception\TaskException */ - protected function s3BucketRequestConfig(string $siteName): array + protected function s3BucketForSite(string $siteName): string { - $s3ConfigArray = ['Bucket' => $this->s3BucketForSite($siteName)]; - try { - $s3KeyPrefix = $this->getSiteConfigItem('database_s3_key_prefix_string', $siteName); - $this->say("'$siteName' S3 Key prefix: '$s3KeyPrefix'"); - $s3ConfigArray['Prefix'] = $s3KeyPrefix; - } catch (TaskException) { - $this->say("No S3 Key prefix found for $siteName."); + if (!is_string($bucket = $this->getSiteConfigItem('database_s3_bucket', $siteName, true))) { + throw new TaskException($this, "database_s3_bucket value not set for '$siteName'."); } - return $s3ConfigArray; + $this->say("'$siteName' S3 bucket: $bucket"); + return $bucket; } /** - * Get S3 Bucket for site. + * Get S3 Prefix from sites config. * * @param string $siteName * The site name. * * @throws \Robo\Exception\TaskException */ - protected function s3BucketForSite(string $siteName): string + protected function s3PrefixForSite(string $siteName): string { - if (!is_string($bucket = $this->getSiteConfigItem('database_s3_bucket', $siteName))) { - throw new TaskException($this, "database_s3_bucket value not set for '$siteName'."); + try { + $s3KeyPrefix = $this->getSiteConfigItem('database_s3_key_prefix_string', $siteName); + } catch (TaskException) { + $this->say("No S3 Key prefix found for $siteName."); } - $this->say("'$siteName' S3 bucket: $bucket"); - return $bucket; + if (isset($s3KeyPrefix) && is_string($s3KeyPrefix) && $s3KeyPrefix !== '') { + $this->say("'$siteName' S3 Key prefix: '$s3KeyPrefix'"); + return $s3KeyPrefix; + } + return ''; } /** diff --git a/src/Robo/Plugin/Traits/SitesConfigTrait.php b/src/Robo/Plugin/Traits/SitesConfigTrait.php index 098f28b..d8ccfd2 100644 --- a/src/Robo/Plugin/Traits/SitesConfigTrait.php +++ b/src/Robo/Plugin/Traits/SitesConfigTrait.php @@ -17,20 +17,23 @@ trait SitesConfigTrait * * @var string */ - protected $sitesConfigFile = '.sites.config.yml'; + protected string $sitesConfigFile = '.sites.config.yml'; /** * Load configuration for all sites. * - * @return mixed[] - * A configuration array for all sites. + * @throws \Robo\Exception\TaskException */ - public function getAllSitesConfig(): array + public function getAllSitesConfig(): mixed { - if (!file_exists($this->sitesConfigFile)) { - throw new TaskException($this, "$this->sitesConfigFile not found."); + static $allSitesConfig = null; + if (is_null($allSitesConfig)) { + if (!file_exists($this->sitesConfigFile)) { + throw new TaskException($this, "$this->sitesConfigFile not found."); + } + $allSitesConfig = Yaml::parseFile($this->sitesConfigFile); } - return Yaml::parseFile($this->sitesConfigFile); + return $allSitesConfig; } /** @@ -38,14 +41,18 @@ public function getAllSitesConfig(): array * * @return string[] * An array of all site names. + * @throws \Robo\Exception\TaskException */ public function getAllSiteNames(): array { + // @todo Fix assumption that Yaml::parseFile always returns an array. return array_keys($this->getAllSitesConfig()); } /** * Determine how many sites are included in sites config. + * + * @throws \Robo\Exception\TaskException */ public function getSitesCount(): int { @@ -58,18 +65,26 @@ public function getSitesCount(): int * @param string $siteName * The site name. * - * @return mixed[] - * The specified site configuration array. + * @throws \Robo\Exception\TaskException */ - protected function getSiteConfig(string $siteName = 'default'): array + protected function getSiteConfig(string $siteName = 'default'): mixed { $allSitesConfig = $this->getAllSitesConfig(); - if (!is_array($allSitesConfig[$siteName])) { + if (!is_array($allSitesConfig)) { + throw new TaskException( + $this, + "Sites configuration in $this->sitesConfigFile is missing or malformed." + ); + } elseif ( + !array_key_exists($siteName, $allSitesConfig) + || !is_array($allSitesConfig[$siteName]) + ) { throw new TaskException( $this, - "Configuration for '$siteName' missing or malformed in $this->sitesConfigFile." + "Sites configuration for '$siteName' in $this->sitesConfigFile is missing or malformed." ); } + return $allSitesConfig[$siteName]; } @@ -82,6 +97,8 @@ protected function getSiteConfig(string $siteName = 'default'): array * The site name. * @param bool $required * Whether the config item is expected to always be present. + * + * @throws \Robo\Exception\TaskException */ public function getSiteConfigItem(string $key, string $siteName = 'default', bool $required = true): mixed { @@ -95,6 +112,20 @@ public function getSiteConfigItem(string $key, string $siteName = 'default', boo return $siteConfig[$key]; } + /** + * Check if a site has an S3 bucket/has data. + * + * @throws \Robo\Exception\TaskException + */ + protected function hasData(string $siteName = 'default'): bool + { + $db = $this->getSiteConfigItem( + key: 'database_s3_bucket', + siteName: $siteName + ); + return (is_string($db) && (bool) mb_strlen($db)); + } + /** * Write sites configuration file. * @@ -110,11 +141,7 @@ protected function writeSitesConfig(array $sitesConfig): void /** * Get the Drupal site admin user ID. * - * @param string $siteName - * The site name. - * - * @return int - * The Drupal admin user ID. + * @throws \Robo\Exception\TaskException */ protected function getDrupalSiteAdminUid(string $siteName = 'default'): int {