From 01ffd4bceec54c100a5c30065c426068c64d23f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Thu, 30 Oct 2025 16:14:04 +0200 Subject: [PATCH 1/4] feat: provision DigitalOcean droplets --- app/Console/Server/ServerAddCommand.php | 27 +- app/Console/Server/ServerDeleteCommand.php | 110 ++++- app/Console/Server/ServerListCommand.php | 33 +- .../ServerProvisionDigitalOceanCommand.php | 395 ++++++++++++++++++ app/DTOs/ServerDTO.php | 2 + app/Repositories/ServerRepository.php | 6 + app/SymfonyApp.php | 16 + app/Traits/DigitalOceanCommandTrait.php | 4 +- app/Traits/DigitalOceanValidationTrait.php | 170 ++++++++ app/Traits/KeyHelpersTrait.php | 4 +- app/Traits/ServerHelpersTrait.php | 72 +++- app/Traits/ServerValidationTrait.php | 6 + 12 files changed, 765 insertions(+), 80 deletions(-) create mode 100644 app/Console/Server/ServerProvisionDigitalOceanCommand.php create mode 100644 app/Traits/DigitalOceanValidationTrait.php diff --git a/app/Console/Server/ServerAddCommand.php b/app/Console/Server/ServerAddCommand.php index 18eda33c..fb70179b 100644 --- a/app/Console/Server/ServerAddCommand.php +++ b/app/Console/Server/ServerAddCommand.php @@ -6,6 +6,8 @@ use Bigpixelrocket\DeployerPHP\Contracts\BaseCommand; use Bigpixelrocket\DeployerPHP\DTOs\ServerDTO; +use Bigpixelrocket\DeployerPHP\Traits\KeyHelpersTrait; +use Bigpixelrocket\DeployerPHP\Traits\KeyValidationTrait; use Bigpixelrocket\DeployerPHP\Traits\ServerHelpersTrait; use Bigpixelrocket\DeployerPHP\Traits\ServerValidationTrait; use Symfony\Component\Console\Attribute\AsCommand; @@ -14,14 +16,11 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -/** - * Add and register a new server to the inventory. - * - * Prompts for server details and saves to inventory. - */ #[AsCommand(name: 'server:add', description: 'Add a new server to the inventory')] class ServerAddCommand extends BaseCommand { + use KeyHelpersTrait; + use KeyValidationTrait; use ServerHelpersTrait; use ServerValidationTrait; @@ -37,8 +36,8 @@ protected function configure(): void ->addOption('name', null, InputOption::VALUE_REQUIRED, 'Server name') ->addOption('host', null, InputOption::VALUE_REQUIRED, 'Host/IP address') ->addOption('port', null, InputOption::VALUE_REQUIRED, 'SSH port (default: 22)') - ->addOption('username', null, InputOption::VALUE_REQUIRED, 'SSH username (default: root)') - ->addOption('private-key-path', null, InputOption::VALUE_REQUIRED, 'SSH private key path'); + ->addOption('private-key-path', null, InputOption::VALUE_REQUIRED, 'SSH private key path') + ->addOption('username', null, InputOption::VALUE_REQUIRED, 'SSH username (default: root)'); } // @@ -50,7 +49,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int parent::execute($input, $output); $this->io->hr(); - $this->io->h1('Add New Server'); // @@ -120,14 +118,21 @@ protected function execute(InputInterface $input, OutputInterface $output): int $privateKeyPathRaw = $this->io->getOptionOrPrompt( 'private-key-path', fn (): string => $this->io->promptText( - label: 'SSH private key path (leave empty for default ~/.ssh/id_ed25519 or ~/.ssh/id_rsa):', + label: 'Path to SSH private key (leave empty for default ~/.ssh/id_ed25519 or ~/.ssh/id_rsa):', default: '', - required: false + required: false, + hint: 'Used to connect to the server' ) ); /** @var ?string $privateKeyPath */ - $privateKeyPath = $privateKeyPathRaw !== '' ? $privateKeyPathRaw : null; + $privateKeyPath = $this->resolvePrivateKeyPath($privateKeyPathRaw); + + if ($privateKeyPath === null) { + $this->io->error('SSH private key not found.'); + + return Command::FAILURE; + } // // Create DTO and display server info diff --git a/app/Console/Server/ServerDeleteCommand.php b/app/Console/Server/ServerDeleteCommand.php index 409ecf01..7bdf8008 100644 --- a/app/Console/Server/ServerDeleteCommand.php +++ b/app/Console/Server/ServerDeleteCommand.php @@ -5,6 +5,7 @@ namespace Bigpixelrocket\DeployerPHP\Console\Server; use Bigpixelrocket\DeployerPHP\Contracts\BaseCommand; +use Bigpixelrocket\DeployerPHP\Traits\DigitalOceanCommandTrait; use Bigpixelrocket\DeployerPHP\Traits\ServerHelpersTrait; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -12,12 +13,10 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -/** - * Delete a server from the inventory. - */ #[AsCommand(name: 'server:delete', description: 'Delete a server from the inventory')] class ServerDeleteCommand extends BaseCommand { + use DigitalOceanCommandTrait; use ServerHelpersTrait; // @@ -30,6 +29,7 @@ protected function configure(): void $this ->addOption('server', null, InputOption::VALUE_REQUIRED, 'Server name') + ->addOption('force', null, InputOption::VALUE_NONE, 'Skip typing server name (use with caution)') ->addOption('yes', 'y', InputOption::VALUE_NONE, 'Skip confirmation prompt'); } @@ -42,49 +42,91 @@ protected function execute(InputInterface $input, OutputInterface $output): int parent::execute($input, $output); $this->io->hr(); - $this->io->h1('Delete Server'); // // Select server - $selection = $this->selectServer(); + $server = $this->selectServer(); - if ($selection['server'] === null) { - return $selection['exit_code']; + if (is_int($server)) { + return $server; } - $server = $selection['server']; - $this->displayServerDeets($server); - // Get sites for this server $serverSites = $this->sites->findByServer($server->name); - if (count($serverSites) > 0) { - $this->io->writeln([' Sites:']); - foreach ($serverSites as $site) { - $this->io->writeln([" • {$site->domain}"]); - } + // + // Display server details - $this->io->writeln(''); + $this->io->hr(); + + $this->displayServerDeets($server, $serverSites); + if (count($serverSites) > 0) { $this->io->error("Cannot delete server '{$server->name}' because it has one or more sites."); return Command::FAILURE; + } + + // + // Check if DigitalOcean server and initialize API + $isDigitalOceanServer = $this->isDigitalOceanServer($server); + + if ($isDigitalOceanServer) { + if ($this->initializeDigitalOceanAPI() === Command::FAILURE) { + $this->io->error('Cannot delete server: DigitalOcean API authentication failed.'); + $this->io->writeln([ + '', + 'You must authenticate with DigitalOcean to delete provisioned servers.', + 'The server will not be removed from inventory to prevent orphaned cloud resources.', + '', + ]); + + return Command::FAILURE; + } } // - // Confirm deletion + // Display warning for cloud provider servers - $this->io->writeln(''); + if ($isDigitalOceanServer) { + $this->io->writeln('⚠ This is a DigitalOcean server.'); + $this->io->writeln(" Droplet ID: {$server->dropletId}"); + $this->io->writeln(''); + $this->io->warning('This will:'); + $this->io->writeln(' • Destroy the droplet on DigitalOcean'); + $this->io->writeln(' • Remove the server from inventory'); + $this->io->writeln(''); + } + + // + // Confirm deletion with extra safety + + /** @var bool $forceSkip */ + $forceSkip = $input->getOption('force') ?? false; + + if (!$forceSkip) { + $typedName = $this->io->promptText( + label: "Type the server name '{$server->name}' to confirm deletion:", + required: true + ); + + if ($typedName !== $server->name) { + $this->io->error('Server name does not match. Deletion cancelled.'); + $this->io->writeln(''); + + return Command::FAILURE; + } + } /** @var bool $confirmed */ $confirmed = $this->io->getOptionOrPrompt( 'yes', fn (): bool => $this->io->promptConfirm( - label: 'Are you sure you want to delete this server?', - default: true + label: 'Are you absolutely sure?', + default: false ) ); @@ -96,7 +138,32 @@ protected function execute(InputInterface $input, OutputInterface $output): int } // - // Delete server + // Destroy cloud provider resources + + if ($isDigitalOceanServer && $server->dropletId !== null) { + try { + $this->io->promptSpin( + fn () => $this->digitalOcean->droplet->destroyDroplet($server->dropletId), + "Destroying droplet (ID: {$server->dropletId})" + ); + $this->io->success('Droplet destroyed'); + } catch (\RuntimeException $e) { + $this->io->error($e->getMessage()); + $this->io->writeln(''); + + $continueAnyway = $this->io->promptConfirm( + label: 'Remove from inventory anyway?', + default: true + ); + + if (!$continueAnyway) { + return Command::FAILURE; + } + } + } + + // + // Delete server from inventory $this->servers->delete($server->name); @@ -109,6 +176,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->io->showCommandHint('server:delete', [ 'server' => $server->name, 'yes' => $confirmed, + 'force' => true, ]); return Command::SUCCESS; diff --git a/app/Console/Server/ServerListCommand.php b/app/Console/Server/ServerListCommand.php index 8930991e..53326c30 100644 --- a/app/Console/Server/ServerListCommand.php +++ b/app/Console/Server/ServerListCommand.php @@ -12,10 +12,7 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -/** - * List all servers in the inventory. - */ -#[AsCommand(name: 'server:list', description: 'List all servers in the inventory')] +#[AsCommand(name: 'server:list', description: 'List servers in the inventory')] class ServerListCommand extends BaseCommand { use ServerHelpersTrait; @@ -30,40 +27,24 @@ protected function execute(InputInterface $input, OutputInterface $output): int parent::execute($input, $output); $this->io->hr(); + $this->io->h1('List Servers'); // // Get all servers - $allServers = $this->servers->all(); - if (count($allServers) === 0) { - $this->io->warning('No servers found in inventory'); - $this->io->writeln([ - '', - 'Use server:add to add a server', - '', - ]); + $allServers = $this->ensureServersAvailable(); - return Command::SUCCESS; + if (is_int($allServers)) { + return $allServers; } // // Display servers with their sites - $this->io->h1('All Servers'); - foreach ($allServers as $count => $server) { - $this->displayServerDeets($server); - - // Get sites for this server + // Display server with sites $serverSites = $this->sites->findByServer($server->name); - - if (count($serverSites) > 0) { - $this->io->writeln([' Sites:']); - foreach ($serverSites as $site) { - $this->io->writeln([" • {$site->domain}"]); - } - $this->io->writeln(''); - } + $this->displayServerDeets($server, $serverSites); if ($count < count($allServers) - 1) { $this->io->writeln([ diff --git a/app/Console/Server/ServerProvisionDigitalOceanCommand.php b/app/Console/Server/ServerProvisionDigitalOceanCommand.php new file mode 100644 index 00000000..7d3843b6 --- /dev/null +++ b/app/Console/Server/ServerProvisionDigitalOceanCommand.php @@ -0,0 +1,395 @@ +addOption('name', null, InputOption::VALUE_REQUIRED, 'Server name for inventory') + ->addOption('region', null, InputOption::VALUE_REQUIRED, 'DigitalOcean region (e.g., nyc3, sfo3)') + ->addOption('image', null, InputOption::VALUE_REQUIRED, 'OS image (e.g., ubuntu-22-04-x64)') + ->addOption('private-key-path', null, InputOption::VALUE_REQUIRED, 'SSH private key path') + ->addOption('size', null, InputOption::VALUE_REQUIRED, 'Droplet size (e.g., s-1vcpu-1gb)') + ->addOption('ssh-key', null, InputOption::VALUE_REQUIRED, 'SSH key ID') + ->addOption('vpc-uuid', null, InputOption::VALUE_REQUIRED, 'VPC UUID (default: use default VPC)') + ->addOption('backups', null, InputOption::VALUE_NONE, 'Enable backups') + ->addOption('ipv6', null, InputOption::VALUE_NONE, 'Enable IPv6') + ->addOption('monitoring', null, InputOption::VALUE_NONE, 'Enable monitoring'); + } + + // + // Execution + // ------------------------------------------------------------------------------- + + protected function execute(InputInterface $input, OutputInterface $output): int + { + parent::execute($input, $output); + + $this->io->hr(); + $this->io->h1('Provision DigitalOcean Droplet'); + + if ($this->initializeDigitalOceanAPI() === Command::FAILURE) { + return Command::FAILURE; + } + + // + // Retrieve DigitalOcean data + // ------------------------------------------------------------------------------- + + $accountData = $this->io->promptSpin( + fn () => [ + 'keys' => $this->digitalOcean->account->getUserSshKeys(), + 'regions' => $this->digitalOcean->account->getAvailableRegions(), + 'sizes' => $this->digitalOcean->account->getAvailableSizes(), + 'images' => $this->digitalOcean->account->getAvailableImages(), + ], + 'Retrieving account information...' + ); + + if (count($accountData['keys']) === 0) { + $this->io->warning('No SSH keys found in your DigitalOcean account'); + $this->io->writeln([ + '', + 'You must add at least one SSH key before provisioning a server.', + 'Run key:add:digitalocean to add an SSH key.', + '', + ]); + + return Command::FAILURE; + } + + // + // Gather droplet configuration + // ------------------------------------------------------------------------------- + + /** @var string|null $name */ + $name = $this->io->getValidatedOptionOrPrompt( + 'name', + fn ($validate) => $this->io->promptText( + label: 'Server name:', + placeholder: 'web1', + required: true, + validate: $validate + ), + fn ($value) => $this->validateNameInput($value) + ); + + if ($name === null) { + return Command::FAILURE; + } + + /** @var string|null $region */ + $region = $this->io->getValidatedOptionOrPrompt( + 'region', + fn ($validate) => $this->io->promptSelect( + label: 'Select region:', + options: $accountData['regions'], + hint: 'Choose the datacenter location' + ), + fn ($value) => $this->validateRegionInput($value, $accountData['regions']) + ); + + if ($region === null) { + return Command::FAILURE; + } + + /** @var string|null $size */ + $size = $this->io->getValidatedOptionOrPrompt( + 'size', + fn ($validate) => $this->io->promptSelect( + label: 'Select droplet size:', + options: $accountData['sizes'], + hint: 'Choose CPU, RAM, and storage' + ), + fn ($value) => $this->validateSizeInput($value, $accountData['sizes']) + ); + + if ($size === null) { + return Command::FAILURE; + } + + /** @var string|null $image */ + $image = $this->io->getValidatedOptionOrPrompt( + 'image', + fn ($validate) => $this->io->promptSelect( + label: 'Select OS image:', + options: $accountData['images'], + hint: 'Ubuntu and Debian only' + ), + fn ($value) => $this->validateImageInput($value, $accountData['images']) + ); + + if ($image === null) { + return Command::FAILURE; + } + + // + // Select SSH key + // ------------------------------------------------------------------------------- + + /** @var int|string|null $selectedKey */ + $selectedKey = $this->io->getValidatedOptionOrPrompt( + 'ssh-key', + fn ($validate) => $this->io->promptSelect( + label: 'Select SSH key for droplet access:', + options: $accountData['keys'], + validate: $validate + ), + fn (mixed $value): ?string => $this->validateSshKeyInput($value, $accountData['keys']) + ); + + if ($selectedKey === null) { + return Command::FAILURE; + } + + // Convert to integer if string was provided + $sshKeyId = is_int($selectedKey) ? $selectedKey : (int) $selectedKey; + + // API expects array of SSH key IDs + /** @var array $sshKeyIds */ + $sshKeyIds = [$sshKeyId]; + + // + // Prompt for local private key path + // ------------------------------------------------------------------------------- + + /** @var string $privateKeyPathRaw */ + $privateKeyPathRaw = $this->io->getOptionOrPrompt( + 'private-key-path', + fn (): string => $this->io->promptText( + label: 'Path to SSH private key (leave empty for default ~/.ssh/id_ed25519 or ~/.ssh/id_rsa):', + default: '', + required: false, + hint: 'Used to connect to the server' + ) + ); + + /** @var ?string $privateKeyPath */ + $privateKeyPath = $this->resolvePrivateKeyPath($privateKeyPathRaw); + + if ($privateKeyPath === null) { + $this->io->error('SSH private key not found.'); + + return Command::FAILURE; + } + + // + // Gather optional parameters + // ------------------------------------------------------------------------------- + + /** @var bool $backups */ + $backups = $this->io->getOptionOrPrompt( + 'backups', + fn () => $this->io->promptConfirm( + label: 'Enable automatic backups?', + default: false, + hint: 'Costs extra if enabled' + ) + ); + + /** @var bool $monitoring */ + $monitoring = $this->io->getOptionOrPrompt( + 'monitoring', + fn () => $this->io->promptConfirm( + label: 'Enable monitoring?', + default: true, + hint: 'Free - shows CPU, memory, disk metrics' + ) + ); + + /** @var bool $ipv6 */ + $ipv6 = $this->io->getOptionOrPrompt( + 'ipv6', + fn () => $this->io->promptConfirm( + label: 'Enable IPv6?', + default: true, + hint: 'Free - provides an IPv6 address' + ) + ); + + /** @var string|null $vpcUuid */ + $vpcUuid = $this->io->getValidatedOptionOrPrompt( + 'vpc-uuid', + fn ($validate) => $this->io->promptSelect( + label: 'Select VPC:', + options: $this->digitalOcean->account->getUserVpcs($region), + hint: 'Virtual Private Cloud for network isolation' + ), + fn ($value) => $this->validateVpcUuidInput($value) + ); + + if ($vpcUuid === null) { + return Command::FAILURE; + } + + // Convert "default" to null for API + if ($vpcUuid === 'default') { + $vpcUuid = null; + } + + // + // Display provisioning summary + // ------------------------------------------------------------------------------- + + $this->io->hr(); + + $this->io->writeln([ + " Name: {$name}", + " Region: {$region}", + " Size: {$size}", + " Image: {$image}", + " SSH Key: {$accountData['keys'][$sshKeyId]}", + ' Backups: ' . ($backups ? 'enabled' : 'disabled') . '', + ' Monitoring: ' . ($monitoring ? 'enabled' : 'disabled') . '', + ' IPv6: ' . ($ipv6 ? 'enabled' : 'disabled') . '', + ' VPC: ' . ($vpcUuid ?? 'default') . '', + '', + ]); + + // + // Create droplet + // ------------------------------------------------------------------------------- + + try { + $dropletData = $this->io->promptSpin( + fn () => $this->digitalOcean->droplet->createDroplet( + name: $name, + region: $region, + size: $size, + image: $image, + sshKeys: $sshKeyIds, + backups: $backups, + monitoring: $monitoring, + ipv6: $ipv6, + vpcUuid: $vpcUuid + ), + 'Creating droplet...' + ); + + $dropletId = $dropletData['id']; + $this->io->success("Droplet created (ID: {$dropletId})"); + } catch (\RuntimeException $e) { + $this->io->error('Failed to create droplet: ' . $e->getMessage()); + + return Command::FAILURE; + } + + // + // Wait for droplet to become active + // ------------------------------------------------------------------------------- + + $this->io->writeln(''); + + try { + $this->io->promptSpin( + fn () => $this->digitalOcean->droplet->waitForDropletReady($dropletId), + 'Waiting for droplet to become active...' + ); + + $this->io->success('Droplet is now active'); + } catch (\RuntimeException $e) { + $this->io->error($e->getMessage()); + + return Command::FAILURE; + } + + // + // Get droplet IP address + // ------------------------------------------------------------------------------- + + try { + $ipAddress = $this->digitalOcean->droplet->getDropletIp($dropletId); + $this->io->writeln(''); + $this->io->writeln(" Public IP: {$ipAddress}"); + } catch (\RuntimeException $e) { + $this->io->error('Failed to retrieve IP address: ' . $e->getMessage()); + + return Command::FAILURE; + } + + // + // Add to inventory + // ------------------------------------------------------------------------------- + + $server = new ServerDTO( + name: $name, + host: $ipAddress, + port: 22, + username: 'root', + privateKeyPath: $privateKeyPath, + provider: 'digitalocean', + dropletId: $dropletId + ); + + try { + $this->servers->create($server); + } catch (\RuntimeException $e) { + $this->io->error('Failed to add server to inventory: ' . $e->getMessage()); + + return Command::FAILURE; + } + + $this->io->writeln(''); + $this->io->success('Server added to inventory'); + $this->io->writeln(''); + + // + // Display server details + // ------------------------------------------------------------------------------- + + $this->displayServerDeets($server); + + // + // Show command hint + // ------------------------------------------------------------------------------- + + $this->io->showCommandHint('server:provision:digitalocean', [ + 'name' => $name, + 'region' => $region, + 'size' => $size, + 'image' => $image, + 'ssh-key' => $sshKeyId, + 'backups' => $backups, + 'monitoring' => $monitoring, + 'ipv6' => $ipv6, + 'vpc-uuid' => $vpcUuid, + ]); + + return Command::SUCCESS; + } +} diff --git a/app/DTOs/ServerDTO.php b/app/DTOs/ServerDTO.php index 10c75a26..fe56d040 100644 --- a/app/DTOs/ServerDTO.php +++ b/app/DTOs/ServerDTO.php @@ -12,6 +12,8 @@ public function __construct( public int $port = 22, public string $username = 'root', public ?string $privateKeyPath = null, + public ?string $provider = null, + public ?int $dropletId = null, // DigitalOcean droplet ID ) { } } diff --git a/app/Repositories/ServerRepository.php b/app/Repositories/ServerRepository.php index d9278762..8af12af4 100644 --- a/app/Repositories/ServerRepository.php +++ b/app/Repositories/ServerRepository.php @@ -162,6 +162,8 @@ private function dehydrateServerDTO(ServerDTO $server): array 'port' => $server->port, 'username' => $server->username, 'privateKeyPath' => $server->privateKeyPath, + 'provider' => $server->provider, + 'dropletId' => $server->dropletId, ]; } @@ -177,6 +179,8 @@ private function hydrateServerDTO(array $data): ServerDTO $port = $data['port'] ?? 22; $username = $data['username'] ?? 'root'; $privateKeyPath = $data['privateKeyPath'] ?? null; + $provider = $data['provider'] ?? null; + $dropletId = $data['dropletId'] ?? null; return new ServerDTO( name: is_string($name) ? $name : '', @@ -184,6 +188,8 @@ private function hydrateServerDTO(array $data): ServerDTO port: is_int($port) ? $port : 22, username: is_string($username) ? $username : 'root', privateKeyPath: is_string($privateKeyPath) ? $privateKeyPath : null, + provider: is_string($provider) ? $provider : null, + dropletId: is_int($dropletId) ? $dropletId : null, ); } } diff --git a/app/SymfonyApp.php b/app/SymfonyApp.php index 93f4ee76..5dd6fd5c 100644 --- a/app/SymfonyApp.php +++ b/app/SymfonyApp.php @@ -11,6 +11,7 @@ use Bigpixelrocket\DeployerPHP\Console\Server\ServerAddCommand; use Bigpixelrocket\DeployerPHP\Console\Server\ServerDeleteCommand; use Bigpixelrocket\DeployerPHP\Console\Server\ServerListCommand; +use Bigpixelrocket\DeployerPHP\Console\Server\ServerProvisionDigitalOceanCommand; use Bigpixelrocket\DeployerPHP\Console\Site\SiteAddCommand; use Bigpixelrocket\DeployerPHP\Console\Site\SiteDeleteCommand; use Bigpixelrocket\DeployerPHP\Console\Site\SiteListCommand; @@ -123,12 +124,27 @@ private function registerCommands(): void { $commands = [ HelloCommand::class, + + // + // Key management + KeyAddDigitalOceanCommand::class, KeyDeleteDigitalOceanCommand::class, KeyListDigitalOceanCommand::class, + + // + // Server management + ServerAddCommand::class, ServerDeleteCommand::class, ServerListCommand::class, + + // Providers + ServerProvisionDigitalOceanCommand::class, + + // + // Site management + SiteAddCommand::class, SiteDeleteCommand::class, SiteListCommand::class, diff --git a/app/Traits/DigitalOceanCommandTrait.php b/app/Traits/DigitalOceanCommandTrait.php index 2fa839d8..674bb9e8 100644 --- a/app/Traits/DigitalOceanCommandTrait.php +++ b/app/Traits/DigitalOceanCommandTrait.php @@ -12,11 +12,11 @@ /** * Common DigitalOcean actions trait for commands. * - * Requires classes using this trait to have EnvService, IOService, and DigitalOceanService properties. + * Requires classes using this trait to have DigitalOceanService, EnvService, and IOService properties. * + * @property DigitalOceanService $digitalOcean * @property EnvService $env * @property IOService $io - * @property DigitalOceanService $digitalOcean */ trait DigitalOceanCommandTrait { diff --git a/app/Traits/DigitalOceanValidationTrait.php b/app/Traits/DigitalOceanValidationTrait.php new file mode 100644 index 00000000..62701977 --- /dev/null +++ b/app/Traits/DigitalOceanValidationTrait.php @@ -0,0 +1,170 @@ + $validRegions Available regions from account + * + * @return string|null Error message if invalid, null if valid + */ + protected function validateRegionInput(mixed $region, array $validRegions): ?string + { + if (!is_string($region)) { + return 'Region must be a string'; + } + + if (trim($region) === '') { + return 'Region cannot be empty'; + } + + // Check if region exists in account's available regions + if (!isset($validRegions[$region])) { + return "Invalid region: '{$region}' is not available in your DigitalOcean account"; + } + + return null; + } + + /** + * Validate size against available droplet sizes. + * + * @param array $validSizes Available sizes from account + * + * @return string|null Error message if invalid, null if valid + */ + protected function validateSizeInput(mixed $size, array $validSizes): ?string + { + if (!is_string($size)) { + return 'Size must be a string'; + } + + if (trim($size) === '') { + return 'Size cannot be empty'; + } + + // Check if size exists in account's available sizes + if (!isset($validSizes[$size])) { + return "Invalid size: '{$size}' is not available in your DigitalOcean account"; + } + + return null; + } + + /** + * Validate image against available images. + * + * @param array $validImages Available images from account + * + * @return string|null Error message if invalid, null if valid + */ + protected function validateImageInput(mixed $image, array $validImages): ?string + { + if (!is_string($image)) { + return 'Image must be a string'; + } + + if (trim($image) === '') { + return 'Image cannot be empty'; + } + + // Check if image exists in account's available images + if (!isset($validImages[$image])) { + return "Invalid image: '{$image}' is not available in your DigitalOcean account"; + } + + return null; + } + + /** + * Validate VPC UUID format. + * + * @return string|null Error message if invalid, null if valid + */ + protected function validateVpcUuidInput(mixed $uuid): ?string + { + if (!is_string($uuid)) { + return 'VPC UUID must be a string'; + } + + // Empty is allowed (optional) - will use default VPC + if (trim($uuid) === '' || $uuid === 'default') { + return null; + } + + // Validate RFC 4122 UUID format + $uuidPattern = '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i'; + if (!preg_match($uuidPattern, $uuid)) { + return 'VPC UUID must be a valid UUID (e.g., 12345678-1234-1234-1234-123456789abc) or "default"'; + } + + return null; + } + + /** + * Validate single SSH key ID against available keys. + * + * @param array $validKeys Available SSH keys from account (key ID => description) + * + * @return string|null Error message if invalid, null if valid + */ + protected function validateSshKeyInput(mixed $keyId, array $validKeys): ?string + { + if (!is_string($keyId) && !is_int($keyId)) { + return 'SSH key ID must be a string or integer'; + } + + // Convert to integer for validation + $keyIdInt = is_int($keyId) ? $keyId : (int) $keyId; + + // Check if key exists in account's available keys + if (!isset($validKeys[$keyIdInt])) { + return "Invalid SSH key: ID {$keyIdInt} is not available in your DigitalOcean account"; + } + + return null; + } + + /** + * Validate comma-separated SSH key IDs. + * + * @return string|null Error message if invalid, null if valid + */ + protected function validateSshKeysInput(mixed $keys): ?string + { + if (!is_string($keys)) { + return 'SSH keys must be a string'; + } + + // Empty is allowed (optional) + if (trim($keys) === '') { + return null; + } + + // Split and validate each key ID + $keyArray = array_map('trim', explode(',', $keys)); + foreach ($keyArray as $key) { + if ($key === '') { + return 'SSH key IDs cannot be empty (remove extra commas)'; + } + + // Keys should be numeric IDs or fingerprints + $isNumeric = ctype_digit($key); + $isFingerprint = preg_match('/^[a-f0-9:]{47,95}$/i', $key) === 1; + + if (!$isNumeric && !$isFingerprint) { + return "Invalid SSH key format: '{$key}' (must be numeric ID or fingerprint)"; + } + } + + return null; + } +} diff --git a/app/Traits/KeyHelpersTrait.php b/app/Traits/KeyHelpersTrait.php index 4c14726a..4e497148 100644 --- a/app/Traits/KeyHelpersTrait.php +++ b/app/Traits/KeyHelpersTrait.php @@ -11,10 +11,10 @@ /** * Common SSH key helpers trait for commands. * - * Requires classes using this trait to have IOService and FilesystemService properties. + * Requires classes using this trait to have FilesystemService and IOService properties. * - * @property IOService $io * @property FilesystemService $fs + * @property IOService $io */ trait KeyHelpersTrait { diff --git a/app/Traits/ServerHelpersTrait.php b/app/Traits/ServerHelpersTrait.php index dd7eafc8..54914d07 100644 --- a/app/Traits/ServerHelpersTrait.php +++ b/app/Traits/ServerHelpersTrait.php @@ -6,39 +6,58 @@ use Bigpixelrocket\DeployerPHP\DTOs\ServerDTO; use Bigpixelrocket\DeployerPHP\Repositories\ServerRepository; -use Bigpixelrocket\DeployerPHP\Services\SSHService; +use Bigpixelrocket\DeployerPHP\Services\IOService; use Symfony\Component\Console\Command\Command; /** * Reusable server-related helpers for commands. * - * Requires the using class to have: - * - protected IOService $io - * - protected ServerRepository $servers - * - protected SSHService $ssh + * Requires classes using this trait to have IOService and ServerRepository properties. + * + * @property IOService $io + * @property ServerRepository $servers */ trait ServerHelpersTrait { /** - * Select a server from inventory by name option or interactive prompt. + * Display a warning to add a server if no servers are available. Otherwise, return all servers. * - * @return array{server: ServerDTO|null, exit_code: int} Server DTO and exit code (SUCCESS if empty inventory, FAILURE if not found) + * @return array|int Returns array of servers or Command::SUCCESS if no servers available */ - protected function selectServer(string $optionName = 'server', string $promptLabel = 'Select server:'): array + protected function ensureServersAvailable(): array|int { - // // Get all servers - $allServers = $this->servers->all(); + + // Check if no servers are available if (count($allServers) === 0) { - $this->io->warning('No servers found in inventory'); + $this->io->warning('No servers available'); $this->io->writeln([ '', - 'Use server:add to add a server', + 'Run server:provision to provision your first server,', + 'or run server:add to add an existing server.', '', ]); - return ['server' => null, 'exit_code' => Command::SUCCESS]; + return Command::SUCCESS; + } + + return $allServers; + } + + /** + * Select a server from inventory by name option or interactive prompt. + * + * @return ServerDTO|int Returns ServerDTO on success, or Command::SUCCESS if empty inventory, or Command::FAILURE if not found + */ + protected function selectServer(string $optionName = 'server', string $promptLabel = 'Select server:'): ServerDTO|int + { + // + // Get all servers + + $allServers = $this->ensureServersAvailable(); + if (is_int($allServers)) { + return $allServers; } // @@ -62,16 +81,18 @@ protected function selectServer(string $optionName = 'server', string $promptLab if ($server === null) { $this->io->error("Server '{$name}' not found in inventory"); - return ['server' => null, 'exit_code' => Command::FAILURE]; + return Command::FAILURE; } - return ['server' => $server, 'exit_code' => Command::SUCCESS]; + return $server; } /** - * Display server details. + * Display server details including optional sites. + * + * @param array $sites */ - protected function displayServerDeets(ServerDTO $server): void + protected function displayServerDeets(ServerDTO $server, array $sites = []): void { $this->io->writeln([ " Name: {$server->name}", @@ -79,8 +100,23 @@ protected function displayServerDeets(ServerDTO $server): void " Port: {$server->port}", " User: {$server->username}", ' Key: '.($server->privateKeyPath ?? 'default (~/.ssh/id_ed25519 or ~/.ssh/id_rsa)').'', - ' ' ]); + + if (count($sites) > 0) { + $this->io->writeln([' Sites:']); + foreach ($sites as $site) { + $this->io->writeln([" • {$site->domain}"]); + } + } + + $this->io->writeln(''); } + /** + * Check if a server is provisioned on DigitalOcean. + */ + protected function isDigitalOceanServer(ServerDTO $server): bool + { + return $server->provider === 'digitalocean' && $server->dropletId !== null; + } } diff --git a/app/Traits/ServerValidationTrait.php b/app/Traits/ServerValidationTrait.php index c45a0ac5..9a0a7539 100644 --- a/app/Traits/ServerValidationTrait.php +++ b/app/Traits/ServerValidationTrait.php @@ -4,8 +4,14 @@ namespace Bigpixelrocket\DeployerPHP\Traits; +use Bigpixelrocket\DeployerPHP\Repositories\ServerRepository; + /** * Validation helpers for server configuration. + * + * Requires classes using this trait to have a ServerRepository property. + * + * @property ServerRepository $servers */ trait ServerValidationTrait { From 00dc6de11342a12b0cb4a79b5d19d8e944c9ed29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Thu, 30 Oct 2025 18:27:46 +0200 Subject: [PATCH 2/4] feat(server): add droplet rollback on provisioning failure Automatically destroy droplets when provisioning fails after creation. Handles failures during IP retrieval and inventory addition by cleaning up the created droplet to prevent orphaned resources. --- .../ServerProvisionDigitalOceanCommand.php | 44 +++++++++++++------ 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/app/Console/Server/ServerProvisionDigitalOceanCommand.php b/app/Console/Server/ServerProvisionDigitalOceanCommand.php index 7d3843b6..8f10513e 100644 --- a/app/Console/Server/ServerProvisionDigitalOceanCommand.php +++ b/app/Console/Server/ServerProvisionDigitalOceanCommand.php @@ -45,7 +45,7 @@ protected function configure(): void ->addOption('image', null, InputOption::VALUE_REQUIRED, 'OS image (e.g., ubuntu-22-04-x64)') ->addOption('private-key-path', null, InputOption::VALUE_REQUIRED, 'SSH private key path') ->addOption('size', null, InputOption::VALUE_REQUIRED, 'Droplet size (e.g., s-1vcpu-1gb)') - ->addOption('ssh-key', null, InputOption::VALUE_REQUIRED, 'SSH key ID') + ->addOption('ssh-key-id', null, InputOption::VALUE_REQUIRED, 'SSH key ID') ->addOption('vpc-uuid', null, InputOption::VALUE_REQUIRED, 'VPC UUID (default: use default VPC)') ->addOption('backups', null, InputOption::VALUE_NONE, 'Enable backups') ->addOption('ipv6', null, InputOption::VALUE_NONE, 'Enable IPv6') @@ -69,7 +69,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int // // Retrieve DigitalOcean data - // ------------------------------------------------------------------------------- $accountData = $this->io->promptSpin( fn () => [ @@ -95,7 +94,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int // // Gather droplet configuration - // ------------------------------------------------------------------------------- /** @var string|null $name */ $name = $this->io->getValidatedOptionOrPrompt( @@ -160,11 +158,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int // // Select SSH key - // ------------------------------------------------------------------------------- /** @var int|string|null $selectedKey */ $selectedKey = $this->io->getValidatedOptionOrPrompt( - 'ssh-key', + 'ssh-key-id', fn ($validate) => $this->io->promptSelect( label: 'Select SSH key for droplet access:', options: $accountData['keys'], @@ -186,7 +183,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int // // Prompt for local private key path - // ------------------------------------------------------------------------------- /** @var string $privateKeyPathRaw */ $privateKeyPathRaw = $this->io->getOptionOrPrompt( @@ -204,13 +200,13 @@ protected function execute(InputInterface $input, OutputInterface $output): int if ($privateKeyPath === null) { $this->io->error('SSH private key not found.'); + $this->io->writeln(''); return Command::FAILURE; } // // Gather optional parameters - // ------------------------------------------------------------------------------- /** @var bool $backups */ $backups = $this->io->getOptionOrPrompt( @@ -264,7 +260,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int // // Display provisioning summary - // ------------------------------------------------------------------------------- $this->io->hr(); @@ -283,7 +278,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int // // Create droplet - // ------------------------------------------------------------------------------- try { $dropletData = $this->io->promptSpin( @@ -311,7 +305,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int // // Wait for droplet to become active - // ------------------------------------------------------------------------------- $this->io->writeln(''); @@ -324,13 +317,13 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->io->success('Droplet is now active'); } catch (\RuntimeException $e) { $this->io->error($e->getMessage()); + $this->rollbackDroplet($dropletId); return Command::FAILURE; } // // Get droplet IP address - // ------------------------------------------------------------------------------- try { $ipAddress = $this->digitalOcean->droplet->getDropletIp($dropletId); @@ -338,13 +331,13 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->io->writeln(" Public IP: {$ipAddress}"); } catch (\RuntimeException $e) { $this->io->error('Failed to retrieve IP address: ' . $e->getMessage()); + $this->rollbackDroplet($dropletId); return Command::FAILURE; } // // Add to inventory - // ------------------------------------------------------------------------------- $server = new ServerDTO( name: $name, @@ -360,6 +353,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->servers->create($server); } catch (\RuntimeException $e) { $this->io->error('Failed to add server to inventory: ' . $e->getMessage()); + $this->rollbackDroplet($dropletId); return Command::FAILURE; } @@ -383,7 +377,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int 'region' => $region, 'size' => $size, 'image' => $image, - 'ssh-key' => $sshKeyId, + 'ssh-key-id' => $sshKeyId, 'backups' => $backups, 'monitoring' => $monitoring, 'ipv6' => $ipv6, @@ -392,4 +386,28 @@ protected function execute(InputInterface $input, OutputInterface $output): int return Command::SUCCESS; } + + // + // Rollback + // ------------------------------------------------------------------------------- + + /** + * Destroy a droplet after failed provisioning. + * + * @param int $dropletId The droplet ID to destroy + */ + private function rollbackDroplet(int $dropletId): void + { + try { + $this->io->writeln(''); + $this->io->promptSpin( + fn () => $this->digitalOcean->droplet->destroyDroplet($dropletId), + 'Destroying droplet...' + ); + + $this->io->warning('Rolled back provisioning of droplet.'); + } catch (\Throwable $cleanupError) { + $this->io->warning($cleanupError->getMessage()); + } + } } From 595eaf9ce34e661eeb65b490084610cccbd5fb44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Thu, 30 Oct 2025 18:27:47 +0200 Subject: [PATCH 3/4] refactor: modernize callable syntax to first-class callables Replace string callables with first-class callable syntax for PHP 8.1+ compatibility and improved type safety. --- app/Repositories/SiteRepository.php | 2 +- app/Traits/DigitalOceanValidationTrait.php | 2 +- app/Traits/SiteHelpersTrait.php | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/Repositories/SiteRepository.php b/app/Repositories/SiteRepository.php index eba0e79a..fff516e8 100644 --- a/app/Repositories/SiteRepository.php +++ b/app/Repositories/SiteRepository.php @@ -202,7 +202,7 @@ private function hydrateSiteDTO(array $data): SiteDTO domain: is_string($domain) ? $domain : '', repo: is_string($repo) ? $repo : null, branch: is_string($branch) ? $branch : null, - servers: is_array($servers) ? array_values(array_filter($servers, 'is_string')) : [], + servers: is_array($servers) ? array_values(array_filter($servers, is_string(...))) : [], ); } } diff --git a/app/Traits/DigitalOceanValidationTrait.php b/app/Traits/DigitalOceanValidationTrait.php index 62701977..fe151e42 100644 --- a/app/Traits/DigitalOceanValidationTrait.php +++ b/app/Traits/DigitalOceanValidationTrait.php @@ -150,7 +150,7 @@ protected function validateSshKeysInput(mixed $keys): ?string } // Split and validate each key ID - $keyArray = array_map('trim', explode(',', $keys)); + $keyArray = array_map(trim(...), explode(',', $keys)); foreach ($keyArray as $key) { if ($key === '') { return 'SSH key IDs cannot be empty (remove extra commas)'; diff --git a/app/Traits/SiteHelpersTrait.php b/app/Traits/SiteHelpersTrait.php index ebf69bd3..226e524f 100644 --- a/app/Traits/SiteHelpersTrait.php +++ b/app/Traits/SiteHelpersTrait.php @@ -104,7 +104,7 @@ protected function selectServers(string $optionName = 'servers'): array if (is_string($serversInput)) { // Parse comma-separated server names from CLI option - $selectedServers = array_map('trim', explode(',', $serversInput)); + $selectedServers = array_map(trim(...), explode(',', $serversInput)); // Validate servers exist foreach ($selectedServers as $serverName) { @@ -118,7 +118,7 @@ protected function selectServers(string $optionName = 'servers'): array } // Ensure array values are strings with sequential integer keys - return array_values(array_filter(array_map('strval', $selectedServers))); + return array_values(array_filter(array_map(strval(...), $selectedServers))); } /** From 5544e349c33443bb8b9d0add240aba04575a9af4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Thu, 30 Oct 2025 18:27:48 +0200 Subject: [PATCH 4/4] style: improve error message spacing consistency Add blank lines after error messages for better visual separation and remove decorative comment separators. --- app/Console/Server/ServerAddCommand.php | 1 + 1 file changed, 1 insertion(+) diff --git a/app/Console/Server/ServerAddCommand.php b/app/Console/Server/ServerAddCommand.php index fb70179b..ba716a81 100644 --- a/app/Console/Server/ServerAddCommand.php +++ b/app/Console/Server/ServerAddCommand.php @@ -130,6 +130,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int if ($privateKeyPath === null) { $this->io->error('SSH private key not found.'); + $this->io->writeln(''); return Command::FAILURE; }