From a6151c5f3507f477e772e99d66ce315bc478d9b5 Mon Sep 17 00:00:00 2001 From: Matthieu Napoli Date: Wed, 4 Nov 2026 19:06:58 +0100 Subject: [PATCH 1/2] secret:create: add --aws-account and --region options for environments not deployed yet Default to the region of the config file and to the team's only AWS account, and fail with a clear error under --no-interaction when a value cannot be resolved. The AWS accounts are now filtered on the team. --- src/BrefCloudClient.php | 2 +- src/Commands/ApplicationCommand.php | 16 +++-- src/Commands/SecretCreate.php | 101 +++++++++++++++++++------- src/Config.php | 6 +- tests/Commands/SecretCreateTest.php | 108 ++++++++++++++++++++++++++++ 5 files changed, 197 insertions(+), 36 deletions(-) create mode 100644 tests/Commands/SecretCreateTest.php diff --git a/src/BrefCloudClient.php b/src/BrefCloudClient.php index 7fab979..193c0a8 100644 --- a/src/BrefCloudClient.php +++ b/src/BrefCloudClient.php @@ -103,7 +103,7 @@ public function getUserInfo(): array } /** - * @param array $config + * @param array $config * @return array{ * deploymentId: int, * message: string, diff --git a/src/Commands/ApplicationCommand.php b/src/Commands/ApplicationCommand.php index 2ac59f1..5510698 100644 --- a/src/Commands/ApplicationCommand.php +++ b/src/Commands/ApplicationCommand.php @@ -23,7 +23,7 @@ protected function configure(): void * appName: string, * environmentName: string, * team: string, - * config: array{name: string, team: string, type: string}, + * config: array{name: string, team: string, type: string, region?: string|null}, * configFileName: string|null, * } */ @@ -51,7 +51,7 @@ protected function parseStandardOptions(InputInterface $input): array * * With `--app` and `--team`, no config file is needed: the command works outside the project directory. * - * @return array{appName: string, environmentName: string, team: string} + * @return array{appName: string, environmentName: string, team: string, region: string|null} The region is the one set in the config file, if any. */ protected function parseEnvironmentOptions(InputInterface $input): array { @@ -63,14 +63,20 @@ protected function parseEnvironmentOptions(InputInterface $input): array $team = is_string($team) && $team !== '' ? $team : null; if ($app && $team) { - return ['appName' => $app, 'environmentName' => $environment, 'team' => $team]; + return ['appName' => $app, 'environmentName' => $environment, 'team' => $team, 'region' => null]; } if (! $input->getOption('config') && ! is_file('bref.php') && ! is_file('serverless.yml')) { throw new Exception('No "bref.php" or "serverless.yml" file in the current directory: run the command in the project directory, or set the application with the --app and --team options.'); } - ['appName' => $appName, 'team' => $configTeam] = $this->parseStandardOptions($input); + ['appName' => $appName, 'team' => $configTeam, 'config' => $config] = $this->parseStandardOptions($input); + $region = $config['region'] ?? null; - return ['appName' => $app ?? $appName, 'environmentName' => $environment, 'team' => $configTeam]; + return [ + 'appName' => $app ?? $appName, + 'environmentName' => $environment, + 'team' => $configTeam, + 'region' => is_string($region) && $region !== '' ? $region : null, + ]; } } diff --git a/src/Commands/SecretCreate.php b/src/Commands/SecretCreate.php index 88c88b3..83998b9 100644 --- a/src/Commands/SecretCreate.php +++ b/src/Commands/SecretCreate.php @@ -15,6 +15,12 @@ class SecretCreate extends ApplicationCommand { + public function __construct( + private readonly ?BrefCloudClient $brefCloud = null, + ) { + parent::__construct(); + } + protected function configure(): void { $this @@ -22,7 +28,9 @@ protected function configure(): void ->setDescription('Create a secret for an environment') ->addArgument('name', InputArgument::OPTIONAL, 'The secret name') ->addArgument('value', InputArgument::OPTIONAL, 'The secret value') - ->addOption('app', null, InputOption::VALUE_REQUIRED, 'The app name (if no config file exists)'); + ->addOption('app', null, InputOption::VALUE_REQUIRED, 'The app name (if no config file exists)') + ->addOption('aws-account', null, InputOption::VALUE_REQUIRED, 'The name of the AWS account in Bref Cloud, if the environment is not deployed yet (default: the team\'s only AWS account)') + ->addOption('region', null, InputOption::VALUE_REQUIRED, 'The AWS region, if the environment is not deployed yet (default: the region in the config file)'); parent::configure(); } @@ -33,6 +41,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int 'appName' => $appName, 'environmentName' => $environment, 'team' => $team, + 'region' => $configRegion, ] = $this->parseEnvironmentOptions($input); // Get secret name (from argument or prompt) @@ -62,7 +71,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int throw new Exception('Secret value cannot be empty'); } - $brefCloud = new BrefCloudClient; + $brefCloud = $this->brefCloud ?? new BrefCloudClient; // Resolve team slug to team ID $teams = $brefCloud->listTeams(); @@ -96,31 +105,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int } if ($awsAccountId === null) { - // Environment doesn't exist or isn't deployed, need to select AWS account and region - $awsAccounts = $brefCloud->listAwsAccounts(); - if (empty($awsAccounts)) { - throw new Exception('No AWS accounts found. Please connect an AWS account first using "bref connect".'); - } - $awsAccountsByName = []; - foreach ($awsAccounts as $account) { - $awsAccountsByName[$account['name']] = $account['id']; - } - $awsAccountName = IO::ask(new ChoiceQuestion( - 'Select the AWS account:', - array_keys($awsAccountsByName), - )); - if (! is_string($awsAccountName)) { - throw new Exception('No AWS account selected'); - } - $awsAccountId = $awsAccountsByName[$awsAccountName]; - - $region = IO::ask(new ChoiceQuestion( - 'Select the AWS region:', - BrefCloudClient::AWS_REGIONS, - )); - if (! is_string($region)) { - throw new Exception('No region selected'); - } + // The environment doesn't exist or isn't deployed: the secret is created in the AWS account and region it will be deployed to + $awsAccountId = $this->selectAwsAccount($input, $brefCloud, $teamId); + $region = $this->selectRegion($input, $configRegion); } IO::spin('Creating secret'); @@ -129,4 +116,64 @@ protected function execute(InputInterface $input, OutputInterface $output): int return 0; } + + private function selectAwsAccount(InputInterface $input, BrefCloudClient $brefCloud, int $teamId): int + { + // The API returns the AWS accounts of all the user's teams + $awsAccountsByName = []; + foreach ($brefCloud->listAwsAccounts() as $account) { + if ($account['team_id'] === $teamId) { + $awsAccountsByName[$account['name']] = $account['id']; + } + } + if (empty($awsAccountsByName)) { + throw new Exception('No AWS accounts found. Please connect an AWS account first using "bref connect".'); + } + + $awsAccountName = $input->getOption('aws-account'); + if (is_string($awsAccountName) && $awsAccountName !== '') { + if (! isset($awsAccountsByName[$awsAccountName])) { + throw new Exception("AWS account '$awsAccountName' not found in the team, the AWS accounts are: " . implode(', ', array_keys($awsAccountsByName))); + } + return $awsAccountsByName[$awsAccountName]; + } + if (count($awsAccountsByName) === 1) { + return reset($awsAccountsByName); + } + if (! $input->isInteractive()) { + throw new Exception('The environment is not deployed yet and the team has several AWS accounts: set the AWS account to use with the --aws-account option (' . implode(', ', array_keys($awsAccountsByName)) . ')'); + } + + $awsAccountName = IO::ask(new ChoiceQuestion( + 'Select the AWS account:', + array_keys($awsAccountsByName), + )); + if (! is_string($awsAccountName)) { + throw new Exception('No AWS account selected'); + } + return $awsAccountsByName[$awsAccountName]; + } + + private function selectRegion(InputInterface $input, ?string $configRegion): string + { + $region = $input->getOption('region'); + if (is_string($region) && $region !== '') { + return $region; + } + if ($configRegion) { + return $configRegion; + } + if (! $input->isInteractive()) { + throw new Exception('The environment is not deployed yet: set the AWS region with the --region option'); + } + + $region = IO::ask(new ChoiceQuestion( + 'Select the AWS region:', + BrefCloudClient::AWS_REGIONS, + )); + if (! is_string($region)) { + throw new Exception('No region selected'); + } + return $region; + } } diff --git a/src/Config.php b/src/Config.php index 1ce3ed0..50d44e9 100644 --- a/src/Config.php +++ b/src/Config.php @@ -10,7 +10,7 @@ class Config { /** - * @return array{name: string, team: string, type: string} + * @return array{name: string, team: string, type: string, region?: string|null} * @throws Exception */ public static function loadConfig(?string $fileName, ?string $environment, ?string $overrideTeam): array @@ -35,7 +35,7 @@ public static function loadConfig(?string $fileName, ?string $environment, ?stri } /** - * @return array{name: string, team: string, type: string} + * @return array{name: string, team: string, type: string, region?: string|null} */ private static function loadServerlessConfig(string $fileName, ?string $overrideTeam): array { @@ -86,7 +86,7 @@ private static function loadServerlessConfig(string $fileName, ?string $override } /** - * @return array{name: string, team: string, type: string} + * @return array{name: string, team: string, type: string, region?: string|null} */ private static function loadBrefConfig(string $fileName, ?string $environment, ?string $overrideTeam): array { diff --git a/tests/Commands/SecretCreateTest.php b/tests/Commands/SecretCreateTest.php new file mode 100644 index 0000000..2b2d0f3 --- /dev/null +++ b/tests/Commands/SecretCreateTest.php @@ -0,0 +1,108 @@ + 7, 'name' => 'Acme', 'slug' => 'acme']]; + private const AWS_ACCOUNTS = [ + ['id' => 1, 'name' => 'production', 'role_arn' => 'arn:aws:iam::123456789012:role/bref-cloud', 'team_id' => 7], + ['id' => 2, 'name' => 'other-team', 'role_arn' => 'arn:aws:iam::210987654321:role/bref-cloud', 'team_id' => 8], + ]; + + public function test_a_deployed_environment_uses_its_aws_account_and_region(): void + { + $this->createSecret(['--app' => 'shop', '--team' => 'acme', '--env' => 'prod'], [ + '/api/v1/environments/find' => $this->environment(), + ]); + + $this->assertSecretCreatedIn(1, 'us-east-1'); + } + + public function test_an_environment_not_deployed_yet_uses_the_options(): void + { + $this->createSecret(['--app' => 'shop', '--team' => 'acme', '--env' => 'prod', '--aws-account' => 'production', '--region' => 'eu-west-3'], [ + '/api/v1/environments/find' => $this->json(['message' => 'Not found'], 404), + '/api/v1/aws-accounts' => [...self::AWS_ACCOUNTS, ['id' => 3, 'name' => 'staging', 'role_arn' => 'arn:aws:iam::111111111111:role/bref-cloud', 'team_id' => 7]], + ]); + + $this->assertSecretCreatedIn(1, 'eu-west-3'); + } + + public function test_an_environment_not_deployed_yet_defaults_to_the_only_aws_account_and_the_region_of_serverless_yml(): void + { + $configFile = sys_get_temp_dir() . '/bref-cli-test-' . bin2hex(random_bytes(4)) . '.yml'; + file_put_contents($configFile, "service: shop\nbref:\n team: acme\nprovider:\n region: eu-west-3\n"); + try { + $this->createSecret(['--config' => $configFile, '--env' => 'prod'], [ + '/api/v1/environments/find' => $this->json(['message' => 'Not found'], 404), + '/api/v1/aws-accounts' => self::AWS_ACCOUNTS, + ]); + } finally { + unlink($configFile); + } + + $this->assertSecretCreatedIn(1, 'eu-west-3'); + } + + public function test_an_environment_not_deployed_yet_without_region_asks_for_the_option(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage('The environment is not deployed yet: set the AWS region with the --region option'); + + $this->createSecret(['--app' => 'shop', '--team' => 'acme', '--env' => 'prod'], [ + '/api/v1/environments/find' => $this->json(['message' => 'Not found'], 404), + '/api/v1/aws-accounts' => self::AWS_ACCOUNTS, + ]); + } + + public function test_an_environment_not_deployed_yet_with_several_aws_accounts_asks_for_the_option(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage('The environment is not deployed yet and the team has several AWS accounts: set the AWS account to use with the --aws-account option (production, staging)'); + + $this->createSecret(['--app' => 'shop', '--team' => 'acme', '--env' => 'prod', '--region' => 'eu-west-3'], [ + '/api/v1/environments/find' => $this->json(['message' => 'Not found'], 404), + '/api/v1/aws-accounts' => [...self::AWS_ACCOUNTS, ['id' => 3, 'name' => 'staging', 'role_arn' => 'arn:aws:iam::111111111111:role/bref-cloud', 'team_id' => 7]], + ]); + } + + /** + * @param array $options + * @param array $routes + */ + private function createSecret(array $options, array $routes): void + { + $input = new ArrayInput(['name' => 'APP_KEY', 'value' => 'secret', ...$options]); + $input->setInteractive(false); + $output = new BufferedOutput; + IO::init($input, $output); + + $command = new SecretCreate($this->brefCloud([ + '/api/v1/teams' => self::TEAMS, + '/api/v1/secrets' => $this->json(['secret_path' => '/shop/prod/APP_KEY'], 201), + ...$routes, + ])); + $this->assertSame(0, $command->run($input, $output), $output->fetch()); + } + + private function assertSecretCreatedIn(int $awsAccountId, string $region): void + { + $this->assertSame([ + 'team_id' => 7, + 'app_name' => 'shop', + 'environment_name' => 'prod', + 'name' => 'APP_KEY', + 'value' => 'secret', + 'aws_account_id' => $awsAccountId, + 'region' => $region, + ], $this->requestBodies['/api/v1/secrets']); + } +} From 65c9876b13621afa2a80396658026ee4667d1f30 Mon Sep 17 00:00:00 2001 From: Matthieu Napoli Date: Wed, 4 Nov 2026 20:18:43 +0100 Subject: [PATCH 2/2] secret:create: warn when --region or --aws-account differ from the deployed environment --- src/Commands/SecretCreate.php | 25 +++++++++++++++++++++++ tests/Commands/SecretCreateTest.php | 31 +++++++++++++++++++++++++++-- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/src/Commands/SecretCreate.php b/src/Commands/SecretCreate.php index 83998b9..f8b0414 100644 --- a/src/Commands/SecretCreate.php +++ b/src/Commands/SecretCreate.php @@ -95,6 +95,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int // Environment is deployed, use its aws_account_id and region $awsAccountId = $existingEnv['aws_account_id']; $region = $existingEnv['region']; + $this->warnAboutIgnoredOptions($input, $brefCloud, $awsAccountId, $region); } } catch (HttpExceptionInterface $e) { if ($e->getResponse()->getStatusCode() !== 404) { @@ -117,6 +118,30 @@ protected function execute(InputInterface $input, OutputInterface $output): int return 0; } + /** + * The secret is created where the environment is deployed: warn if the options point elsewhere. + */ + private function warnAboutIgnoredOptions(InputInterface $input, BrefCloudClient $brefCloud, ?int $awsAccountId, string $region): void + { + $regionOption = $input->getOption('region'); + if (is_string($regionOption) && $regionOption !== '' && $regionOption !== $region) { + IO::warning("The environment is deployed in $region: the --region option is ignored, the secret is created in $region"); + } + + $awsAccountOption = $input->getOption('aws-account'); + if (is_string($awsAccountOption) && $awsAccountOption !== '') { + $awsAccountName = null; + foreach ($brefCloud->listAwsAccounts() as $account) { + if ($account['id'] === $awsAccountId) { + $awsAccountName = $account['name']; + } + } + if ($awsAccountName !== $awsAccountOption) { + IO::warning("The environment is deployed in the AWS account '$awsAccountName': the --aws-account option is ignored, the secret is created in that account"); + } + } + } + private function selectAwsAccount(InputInterface $input, BrefCloudClient $brefCloud, int $teamId): int { // The API returns the AWS accounts of all the user's teams diff --git a/tests/Commands/SecretCreateTest.php b/tests/Commands/SecretCreateTest.php index 2b2d0f3..cd9ca80 100644 --- a/tests/Commands/SecretCreateTest.php +++ b/tests/Commands/SecretCreateTest.php @@ -26,6 +26,29 @@ public function test_a_deployed_environment_uses_its_aws_account_and_region(): v $this->assertSecretCreatedIn(1, 'us-east-1'); } + public function test_options_pointing_elsewhere_than_the_deployed_environment_are_ignored_with_a_warning(): void + { + $output = $this->createSecret(['--app' => 'shop', '--team' => 'acme', '--env' => 'prod', '--aws-account' => 'staging', '--region' => 'eu-west-3'], [ + '/api/v1/environments/find' => $this->environment(), + '/api/v1/aws-accounts' => self::AWS_ACCOUNTS, + ]); + + $this->assertSecretCreatedIn(1, 'us-east-1'); + $this->assertStringContainsString('The environment is deployed in us-east-1: the --region option is ignored, the secret is created in us-east-1', $output); + $this->assertStringContainsString("The environment is deployed in the AWS account 'production': the --aws-account option is ignored, the secret is created in that account", $output); + } + + public function test_options_matching_the_deployed_environment_do_not_warn(): void + { + $output = $this->createSecret(['--app' => 'shop', '--team' => 'acme', '--env' => 'prod', '--aws-account' => 'production', '--region' => 'us-east-1'], [ + '/api/v1/environments/find' => $this->environment(), + '/api/v1/aws-accounts' => self::AWS_ACCOUNTS, + ]); + + $this->assertSecretCreatedIn(1, 'us-east-1'); + $this->assertStringNotContainsString('ignored', $output); + } + public function test_an_environment_not_deployed_yet_uses_the_options(): void { $this->createSecret(['--app' => 'shop', '--team' => 'acme', '--env' => 'prod', '--aws-account' => 'production', '--region' => 'eu-west-3'], [ @@ -78,7 +101,7 @@ public function test_an_environment_not_deployed_yet_with_several_aws_accounts_a * @param array $options * @param array $routes */ - private function createSecret(array $options, array $routes): void + private function createSecret(array $options, array $routes): string { $input = new ArrayInput(['name' => 'APP_KEY', 'value' => 'secret', ...$options]); $input->setInteractive(false); @@ -90,7 +113,11 @@ private function createSecret(array $options, array $routes): void '/api/v1/secrets' => $this->json(['secret_path' => '/shop/prod/APP_KEY'], 201), ...$routes, ])); - $this->assertSame(0, $command->run($input, $output), $output->fetch()); + $status = $command->run($input, $output); + $display = $output->fetch(); + $this->assertSame(0, $status, $display); + + return $display; } private function assertSecretCreatedIn(int $awsAccountId, string $region): void