Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/BrefCloudClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ public function getUserInfo(): array
}

/**
* @param array<string, string> $config
* @param array<string, mixed> $config
* @return array{
* deploymentId: int,
* message: string,
Expand Down
16 changes: 11 additions & 5 deletions src/Commands/ApplicationCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
* }
*/
Expand Down Expand Up @@ -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
{
Expand All @@ -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,
];
}
}
126 changes: 99 additions & 27 deletions src/Commands/SecretCreate.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,22 @@

class SecretCreate extends ApplicationCommand
{
public function __construct(
private readonly ?BrefCloudClient $brefCloud = null,
) {
parent::__construct();
}

protected function configure(): void
{
$this
->setName('secret:create')
->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();
}
Expand All @@ -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)
Expand Down Expand Up @@ -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();
Expand All @@ -86,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) {
Expand All @@ -96,31 +106,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');
Expand All @@ -129,4 +117,88 @@ 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
$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;
}
}
6 changes: 3 additions & 3 deletions src/Config.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
{
Expand Down Expand Up @@ -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
{
Expand Down
135 changes: 135 additions & 0 deletions tests/Commands/SecretCreateTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
<?php declare(strict_types=1);

namespace Bref\Cli\Test\Commands;

use Bref\Cli\BrefCloudClient;
use Bref\Cli\Cli\IO;
use Bref\Cli\Commands\SecretCreate;
use Exception;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\BufferedOutput;

class SecretCreateTest extends CommandTestCase
{
private const TEAMS = [['id' => 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_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'], [
'/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<string, mixed> $options
* @param array<string, mixed> $routes
*/
private function createSecret(array $options, array $routes): string
{
$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,
]));
$status = $command->run($input, $output);
$display = $output->fetch();
$this->assertSame(0, $status, $display);

return $display;
}

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']);
}
}
Loading