diff --git a/app/Commands/DatabaseClusterCreate.php b/app/Commands/DatabaseClusterCreate.php index 411edf1..e09198e 100644 --- a/app/Commands/DatabaseClusterCreate.php +++ b/app/Commands/DatabaseClusterCreate.php @@ -21,7 +21,21 @@ class DatabaseClusterCreate extends BaseCommand {--engine-version= : Database engine version (e.g. 18, 8.4). Default: newest available} {--region= : Database region} {--cluster= : Dedicated cluster ID to create the database cluster on} - {--create-default-database= : Whether to create a default database (true or false; Neon clusters cannot skip it). Default: true}'; + {--create-default-database= : Whether to create a default database (true or false; Neon clusters cannot skip it). Default: true} + {--preset= : Preset to start from, as listed by the API for the type and region (e.g. dev, prod; case-insensitive). Default: the first preset offered} + {--size= : Instance size (Laravel MySQL, AWS RDS)} + {--storage= : Storage in GB (Laravel MySQL, AWS RDS)} + {--storage-autoscale-max-gb= : Largest storage in GB the cluster may grow to on its own, or "null" to turn it off (Laravel MySQL)} + {--is-public= : Whether publicly accessible (Laravel MySQL, AWS RDS)} + {--uses-scheduled-snapshots= : Whether scheduled backups are enabled (Laravel MySQL)} + {--retention-days= : Days to retain backups} + {--maintenance-window= : UTC maintenance window, e.g. sun:06:00-sun:06:30 (Laravel MySQL, AWS RDS)} + {--suspend-seconds= : Seconds of inactivity before scale to zero, 0 for never (Laravel MySQL, Neon)} + {--cu-min= : Minimum compute units (Neon)} + {--cu-max= : Maximum compute units (Neon)} + {--uses-pitr= : Whether point-in-time recovery is enabled (AWS RDS)} + {--deployment-option= : single-az, single-az-with-read-replicas, or multi-az (AWS RDS)} + {--read-replicas= : Number of read replicas (AWS RDS)}'; protected $description = 'Create a new database cluster'; diff --git a/app/Commands/DatabaseClusterUpdate.php b/app/Commands/DatabaseClusterUpdate.php index dde362a..9a45109 100644 --- a/app/Commands/DatabaseClusterUpdate.php +++ b/app/Commands/DatabaseClusterUpdate.php @@ -3,6 +3,7 @@ namespace App\Commands; use App\Client\Requests\UpdateDatabaseClusterRequestData; +use App\Concerns\CoercesDatabaseClusterConfig; use App\Dto\DatabaseCluster; use App\Dto\DatabaseType; use App\Exceptions\CommandExitException; @@ -17,6 +18,8 @@ class DatabaseClusterUpdate extends BaseCommand { + use CoercesDatabaseClusterConfig; + protected ?string $jsonDataClass = DatabaseCluster::class; protected $signature = 'database-cluster:update @@ -217,28 +220,4 @@ protected function collectDataAndUpdate(DatabaseCluster $database, DatabaseType return $this->updateCluster($database, $type); } - - protected function coerceConfigValue(string $key, mixed $value, DatabaseType $type): mixed - { - $schemaField = collect($type->configSchema)->firstWhere('name', $key); - - if (! $schemaField) { - return $value; - } - - $schema = is_array($schemaField) ? $schemaField : $schemaField->toArray(); - $fieldType = $schema['type'] ?? 'string'; - - // Nullable fields such as storage_autoscale_max_gb use null to turn the setting off. - if (is_string($value) && strtolower($value) === 'null' && in_array($fieldType, ['integer', 'number'], true)) { - return null; - } - - return match ($fieldType) { - 'integer' => (int) $value, - 'number' => (float) $value, - 'boolean' => filter_var($value, FILTER_VALIDATE_BOOLEAN), - default => $value, - }; - } } diff --git a/app/Concerns/CoercesDatabaseClusterConfig.php b/app/Concerns/CoercesDatabaseClusterConfig.php new file mode 100644 index 0000000..bfe2519 --- /dev/null +++ b/app/Concerns/CoercesDatabaseClusterConfig.php @@ -0,0 +1,45 @@ +configSchema)->firstWhere('name', $key); + + if (! $schemaField) { + return $value; + } + + $schema = is_array($schemaField) ? $schemaField : $schemaField->toArray(); + $fieldType = $schema['type'] ?? 'string'; + + // Nullable fields such as storage_autoscale_max_gb use null to turn the setting off. + if (is_string($value) && strtolower($value) === 'null' && in_array($fieldType, ['integer', 'number'], true)) { + return null; + } + + [$filter, $expected] = match ($fieldType) { + 'integer' => [FILTER_VALIDATE_INT, 'a whole number'], + 'number' => [FILTER_VALIDATE_FLOAT, 'a number'], + 'boolean' => [FILTER_VALIDATE_BOOLEAN, 'true or false'], + default => [null, null], + }; + + if ($filter === null) { + return $value; + } + + $parsed = filter_var($value, $filter, FILTER_NULL_ON_FAILURE); + + if ($parsed === null) { + throw new RuntimeException(sprintf('Invalid --%s value "%s". Must be %s.', str_replace('_', '-', $key), $value, $expected)); + } + + return $parsed; + } +} diff --git a/app/Concerns/CreatesDatabaseCluster.php b/app/Concerns/CreatesDatabaseCluster.php index 77120b6..e5e79c7 100644 --- a/app/Concerns/CreatesDatabaseCluster.php +++ b/app/Concerns/CreatesDatabaseCluster.php @@ -18,6 +18,24 @@ trait CreatesDatabaseCluster { + use CoercesDatabaseClusterConfig; + + protected const DATABASE_CLUSTER_CONFIG_FIELDS = [ + 'size', + 'storage', + 'storage_autoscale_max_gb', + 'is_public', + 'uses_scheduled_snapshots', + 'retention_days', + 'maintenance_window', + 'suspend_seconds', + 'cu_min', + 'cu_max', + 'uses_pitr', + 'deployment_option', + 'read_replicas', + ]; + protected ?string $databaseClusterPreset = null; protected function createDatabaseCluster(array $defaults = []): DatabaseCluster @@ -110,7 +128,10 @@ protected function createDatabaseCluster(array $defaults = []): DatabaseCluster ), ); - $config = $this->databaseClusterConfigFromPreset($selectedType) ?? $this->promptForDatabaseClusterConfig($selectedType); + $config = [ + ...$this->databaseClusterConfigFromPreset($selectedType) ?? $this->promptForDatabaseClusterConfig($selectedType), + ...$this->databaseClusterConfigFromOptions($selectedType), + ]; $clusterId = $this->hasOption('cluster') ? $this->option('cluster') : null; $createDefaultDatabase = $this->createDefaultDatabaseOption(); @@ -165,6 +186,26 @@ protected function databaseClusterConfigFromPreset(DatabaseType $type): ?array return null; } + $requested = $this->hasOption('preset') ? $this->option('preset') : null; + + if ($requested !== null) { + $preset = $presets->first(fn (DatabasePreset $preset) => strcasecmp($preset->key, $requested) === 0); + + if ($preset === null) { + throw new RuntimeException(sprintf( + 'Invalid --preset value "%s" for database type "%s" in region "%s". Must be one of: %s (case-insensitive)', + $requested, + $type->type, + $region, + $presets->keys()->implode(', '), + )); + } + + $this->databaseClusterPreset = $preset->key; + + return $preset->config; + } + // The cached choice may not exist for a region picked on a later attempt. if ($this->databaseClusterPreset !== null && $presets->has($this->databaseClusterPreset)) { return $presets->get($this->databaseClusterPreset)->config; @@ -187,6 +228,33 @@ protected function databaseClusterConfigFromPreset(DatabaseType $type): ?array return $presets->get($this->databaseClusterPreset)->config; } + /** + * Config fields passed as options, which win over the preset's values. + */ + protected function databaseClusterConfigFromOptions(DatabaseType $type): array + { + $fields = collect($type->configSchema)->map(fn ($field) => is_array($field) ? $field['name'] : $field->name); + $config = []; + + foreach (self::DATABASE_CLUSTER_CONFIG_FIELDS as $name) { + $option = str_replace('_', '-', $name); + // The custom config prompts start from the flag, so their answer wins once the API has asked for a new one. + $value = $this->form()->get('config.'.$name, $this->hasOption($option) ? $this->option($option) : null); + + if ($value === null) { + continue; + } + + if (! $fields->contains($name)) { + throw new RuntimeException(sprintf('The --%s option does not apply to %s clusters.', $option, $type->label)); + } + + $config[$name] = $this->coerceConfigValue($name, $value, $type); + } + + return $config; + } + protected function describeDatabasePreset(DatabaseType $type, DatabasePreset $preset): string { // Only some types have a hand-written summary; the rest (such as RDS) get their config listed as-is. @@ -210,12 +278,13 @@ protected function promptForDatabaseClusterConfig(DatabaseType $type): array $name = $schema['name']; $fieldType = $schema['type'] ?? 'string'; $required = $schema['required'] ?? false; - $description = $schema['description'] ?? null; + $description = $schema['description'] ?? ''; $min = $schema['min'] ?? null; $max = $schema['max'] ?? null; $enum = $schema['enum'] ?? []; $example = $schema['example'] ?? null; $key = 'config.'.$name; + $optionName = str_replace('_', '-', $name); $label = match ($name) { 'cu_min' => 'CU min', @@ -235,6 +304,7 @@ protected function promptForDatabaseClusterConfig(DatabaseType $type): array required: $required, ), ), + $optionName, ); } elseif ($fieldType === 'boolean') { $this->form()->prompt( @@ -246,6 +316,7 @@ protected function promptForDatabaseClusterConfig(DatabaseType $type): array hint: $description, ), ), + $optionName, ); } elseif ($fieldType === 'integer') { $this->form()->prompt( @@ -260,6 +331,7 @@ protected function promptForDatabaseClusterConfig(DatabaseType $type): array max: $max, ), ), + $optionName, ); } else { $this->form()->prompt( @@ -272,6 +344,7 @@ protected function promptForDatabaseClusterConfig(DatabaseType $type): array hint: $description, ), ), + $optionName, ); } } diff --git a/tests/Feature/DatabaseClusterCreateTest.php b/tests/Feature/DatabaseClusterCreateTest.php index 4316b5f..6872369 100644 --- a/tests/Feature/DatabaseClusterCreateTest.php +++ b/tests/Feature/DatabaseClusterCreateTest.php @@ -11,10 +11,14 @@ use App\Dto\DatabasePreset; use App\Dto\DatabaseType; use App\Enums\DatabaseClusterPreset; +use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Sleep; +use Laravel\Prompts\Key; +use Laravel\Prompts\Prompt; use Saloon\Http\Faking\MockClient; use Saloon\Http\Faking\MockResponse; use Saloon\Http\PendingRequest; +use Tests\Fixtures\InteractiveDatabaseClusterCreateCommand; beforeEach(function () { Sleep::fake(); @@ -205,6 +209,148 @@ function setupDatabaseClusterCreateMocks(): Closure expect($sentBody())->toBeNull(); }); +it('uses the requested preset when not interactive', function () { + $sentBody = setupDatabaseClusterCreateMocks(); + + $this->artisan('database-cluster:create', [ + '--name' => 'my-cluster', + '--type' => 'laravel_mysql', + '--region' => 'us-east-1', + '--preset' => 'PROD', + '--no-interaction' => true, + ])->assertSuccessful(); + + expect($sentBody()['config'])->toBe(databasePresetsResponse('laravel_mysql')['data'][1]['config']); +}); + +it('rejects a preset the API does not offer', function () { + $sentBody = setupDatabaseClusterCreateMocks(); + + $this->artisan('database-cluster:create', [ + '--name' => 'my-cluster', + '--type' => 'laravel_mysql', + '--region' => 'us-east-1', + '--preset' => 'huge', + '--no-interaction' => true, + ])->assertFailed(); + + expect($sentBody())->toBeNull(); +}); + +it('lets config flags override the preset', function () { + $sentBody = setupDatabaseClusterCreateMocks(); + + $this->artisan('database-cluster:create', [ + '--name' => 'my-cluster', + '--type' => 'laravel_mysql', + '--region' => 'us-east-1', + '--size' => 'mysql-pro-4gb', + '--storage' => '50', + '--is-public' => 'true', + '--maintenance-window' => 'sun:06:00-sun:06:30', + '--no-interaction' => true, + ])->assertSuccessful(); + + expect($sentBody()['config'])->toBe([ + 'size' => 'mysql-pro-4gb', + 'storage' => 50, + 'retention_days' => 1, + 'uses_scheduled_snapshots' => false, + 'is_public' => true, + 'maintenance_window' => 'sun:06:00-sun:06:30', + ]); +}); + +it('sends Neon compute flags as numbers', function () { + $sentBody = setupDatabaseClusterCreateMocks(); + + $this->artisan('database-cluster:create', [ + '--name' => 'my-cluster', + '--type' => 'neon_serverless_postgres', + '--region' => 'us-east-1', + '--cu-max' => '2', + '--suspend-seconds' => '0', + '--no-interaction' => true, + ])->assertSuccessful(); + + expect($sentBody()['config'])->toBe([ + 'cu_min' => 0.25, + 'cu_max' => 2.0, + 'suspend_seconds' => 0, + 'retention_days' => 0, + ]); +}); + +it('rejects a config flag that does not apply to the type', function () { + $sentBody = setupDatabaseClusterCreateMocks(); + + $this->artisan('database-cluster:create', [ + '--name' => 'my-cluster', + '--type' => 'laravel_mysql', + '--region' => 'us-east-1', + '--cu-min' => '1', + '--no-interaction' => true, + ])->assertFailed(); + + expect($sentBody())->toBeNull(); +}); + +it('rejects a config flag value that does not parse', function (string $option, string $value) { + $sentBody = setupDatabaseClusterCreateMocks(); + + $this->artisan('database-cluster:create', [ + '--name' => 'my-cluster', + '--type' => 'laravel_mysql', + '--region' => 'us-east-1', + $option => $value, + '--no-interaction' => true, + ])->assertFailed(); + + expect($sentBody())->toBeNull(); +})->with([ + 'boolean' => ['--is-public', 'ture'], + 'integer' => ['--storage', 'abc'], +]); + +it('sends the corrected custom value after the API rejects a flag', function () { + Artisan::registerCommand(new InteractiveDatabaseClusterCreateCommand); + + setupDatabaseClusterCreateMocks(); + + $bodies = []; + + MockClient::global()->addResponses([ + CreateDatabaseClusterRequest::class => function (PendingRequest $request) use (&$bodies) { + $bodies[] = $request->body()->all(); + + return count($bodies) === 1 + ? MockResponse::make(['message' => 'Invalid size.', 'errors' => ['config.size' => ['The selected size is invalid.']]], 422) + : MockResponse::make(databaseClusterResponse(), 201); + }, + ]); + + Prompt::fake([ + // Custom is the last configuration option. + Key::UP, Key::ENTER, + // Accept the defaults for storage, retention days, scheduled snapshots, public and maintenance window. + Key::ENTER, Key::ENTER, Key::ENTER, Key::ENTER, Key::ENTER, + // Replace the rejected size. + ...array_fill(0, strlen('bogus'), Key::BACKSPACE), ...str_split('db-flex.m-1vcpu-1gb'), Key::ENTER, + ]); + + $this->artisan('test:interactive-database-cluster-create', [ + '--name' => 'my-cluster', + '--type' => 'laravel_mysql', + '--engine-version' => '8.4', + '--region' => 'us-east-1', + '--size' => 'bogus', + ])->assertSuccessful(); + + expect($bodies)->toHaveCount(2) + ->and($bodies[0]['config']['size'])->toBe('bogus') + ->and($bodies[1]['config']['size'])->toBe('db-flex.m-1vcpu-1gb'); +}); + it('summarises a MySQL preset from its API size', function () { $summary = DatabaseClusterPreset::LaravelMysql->description()(databasePresetsResponse('laravel_mysql')['data'][0]['config']); diff --git a/tests/Fixtures/InteractiveDatabaseClusterCreateCommand.php b/tests/Fixtures/InteractiveDatabaseClusterCreateCommand.php new file mode 100644 index 0000000..d55cc50 --- /dev/null +++ b/tests/Fixtures/InteractiveDatabaseClusterCreateCommand.php @@ -0,0 +1,32 @@ +signature = str_replace('database-cluster:create', 'test:interactive-database-cluster-create', $this->signature); + + parent::__construct(); + } + + protected function isInteractive(): bool + { + return true; + } + + protected function configurePrompts(InputInterface $input): void + { + parent::configurePrompts($input); + + Prompt::interactive(true); + } +} diff --git a/tests/Helpers.php b/tests/Helpers.php index 446fb4b..bebce3a 100644 --- a/tests/Helpers.php +++ b/tests/Helpers.php @@ -420,6 +420,7 @@ function versionlessDatabaseTypesResponse(): array ['name' => 'retention_days', 'type' => 'integer', 'required' => true, 'example' => '1'], ['name' => 'uses_scheduled_snapshots', 'type' => 'boolean', 'required' => true, 'example' => 'false'], ['name' => 'is_public', 'type' => 'boolean', 'required' => true, 'example' => 'false'], + ['name' => 'maintenance_window', 'type' => 'string', 'required' => false, 'nullable' => true, 'example' => 'sun:06:00-sun:06:30'], ], ], [