diff --git a/app/Console/Server/ServerAddCommand.php b/app/Console/Server/ServerAddCommand.php index 18eda33c..ba716a81 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,22 @@ 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.'); + $this->io->writeln(''); + + 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..8f10513e --- /dev/null +++ b/app/Console/Server/ServerProvisionDigitalOceanCommand.php @@ -0,0 +1,413 @@ +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-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') + ->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-id', + 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.'); + $this->io->writeln(''); + + 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()); + $this->rollbackDroplet($dropletId); + + 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()); + $this->rollbackDroplet($dropletId); + + 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()); + $this->rollbackDroplet($dropletId); + + 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-id' => $sshKeyId, + 'backups' => $backups, + 'monitoring' => $monitoring, + 'ipv6' => $ipv6, + 'vpc-uuid' => $vpcUuid, + ]); + + 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()); + } + } +} 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/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/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..fe151e42 --- /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 { 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))); } /**