diff --git a/app/Models/Server.php b/app/Models/Server.php index c631bb0bc..fd5660f94 100755 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -171,15 +171,15 @@ public static function boot(): void $server->workers()->delete(); $server->daemons()->delete(); $server->sshKeys()->detach(); + if ($server->deleteFromProvider) { + $server->provider()->delete(); + } if (File::exists($server->sshKey()['public_key_path'])) { File::delete($server->sshKey()['public_key_path']); } if (File::exists($server->sshKey()['private_key_path'])) { File::delete($server->sshKey()['private_key_path']); } - if ($server->deleteFromProvider) { - $server->provider()->delete(); - } DB::commit(); } catch (Throwable $e) { DB::rollBack(); diff --git a/app/Providers/ServerProviderServiceProvider.php b/app/Providers/ServerProviderServiceProvider.php index 697c7aa9b..3d8b87650 100644 --- a/app/Providers/ServerProviderServiceProvider.php +++ b/app/Providers/ServerProviderServiceProvider.php @@ -9,6 +9,7 @@ use App\ServerProviders\Custom; use App\ServerProviders\DigitalOcean; use App\ServerProviders\Hetzner; +use App\ServerProviders\Lightsail; use App\ServerProviders\Linode; use App\ServerProviders\Vultr; use Illuminate\Support\ServiceProvider; @@ -21,6 +22,7 @@ public function boot(): void { $this->custom(); $this->aws(); + $this->lightsail(); $this->hetzner(); $this->digitalOcean(); $this->linode(); @@ -55,6 +57,25 @@ private function aws(): void ->register(); } + private function lightsail(): void + { + RegisterServerProvider::make(Lightsail::id()) + ->label('AWS Lightsail') + ->handler(Lightsail::class) + ->form( + DynamicForm::make([ + DynamicField::make('key') + ->text() + ->label('Access Key'), + DynamicField::make('secret') + ->password() + ->label('Secret Access Key'), + ]) + ) + ->defaultUser('ubuntu') + ->register(); + } + private function hetzner(): void { RegisterServerProvider::make(Hetzner::id()) diff --git a/app/ServerProviders/Lightsail.php b/app/ServerProviders/Lightsail.php new file mode 100644 index 000000000..3ad3b9cc6 --- /dev/null +++ b/app/ServerProviders/Lightsail.php @@ -0,0 +1,249 @@ + ['required', 'string'], + 'region' => ['required', 'string', 'regex:/^[a-z]{2}(?:-[a-z]+)+-\d+$/'], + ]; + } + + public function credentialValidationRules(array $input): array + { + return [ + 'key' => ['required', 'string'], + 'secret' => ['required', 'string'], + ]; + } + + public function credentialData(array $input): array + { + return [ + 'key' => $input['key'], + 'secret' => $input['secret'], + ]; + } + + public function data(array $input): array + { + return [ + 'plan' => $input['plan'], + 'region' => $input['region'], + ]; + } + + public function connect(#[SensitiveParameter] array $credentials): bool + { + $this->request('GetRegions', credentials: $this->credentialData($credentials)); + + return true; + } + + public function regions(): array + { + return collect($this->request('GetRegions')['regions'] ?? []) + ->mapWithKeys(fn (array $region): array => [ + $region['name'] => $region['displayName'].' ('.$region['name'].')', + ]) + ->all(); + } + + public function plans(?string $region): array + { + if (! $region) { + return []; + } + + return collect($this->paginate('GetBundles', 'bundles', $region)) + ->filter(fn (array $bundle): bool => ($bundle['isActive'] ?? false) + && in_array('LINUX_UNIX', $bundle['supportedPlatforms'] ?? [], true) + && ($bundle['publicIpv4AddressCount'] ?? 0) > 0) + ->mapWithKeys(fn (array $bundle): array => [ + $bundle['bundleId'] => __('server_providers.plan', [ + 'name' => $bundle['name'], + 'cpu' => $bundle['cpuCount'], + 'memory' => $bundle['ramSizeInGb'] * 1024, + 'disk' => $bundle['diskSizeInGb'], + ]).' ('.number_format($bundle['price'], 2).'/mo)', + ]) + ->all(); + } + + public function create(): void + { + $region = $this->server->provider_data['region']; + $regions = $this->request('GetRegions', ['includeAvailabilityZones' => true]); + $location = collect($regions['regions'] ?? [])->firstWhere('name', $region); + $zone = $location['availabilityZones'][0]['zoneName'] ?? null; + + if (! $zone) { + throw new ServerProviderError('The selected AWS Lightsail region is unavailable.'); + } + + if (! isset($this->plans($region)[$this->server->provider_data['plan']])) { + throw new ServerProviderError('The selected AWS Lightsail plan is unavailable.'); + } + + $blueprint = collect($this->paginate('GetBlueprints', 'blueprints', $region)) + ->first(fn (array $blueprint): bool => ($blueprint['isActive'] ?? false) + && ($blueprint['group'] ?? '') === 'ubuntu' + && ($blueprint['type'] ?? '') === 'os' + && str_starts_with($blueprint['version'] ?? '', $this->server->os->getVersion())); + + if (! $blueprint) { + throw new ServerProviderError('The selected Ubuntu version is unavailable on AWS Lightsail.'); + } + + $name = 'vito-'.$this->server->id.'-'.Str::lower(Str::random(12)); + $this->generateKeyPair(); + $this->server->jsonUpdate('provider_data', 'ssh_key_name', $name); + $this->request('ImportKeyPair', [ + 'keyPairName' => $name, + 'publicKeyBase64' => base64_encode($this->server->sshKey()['public_key']), + ]); + + $this->server->jsonUpdate('provider_data', 'instance_name', $name); + $this->request('CreateInstances', [ + 'instanceNames' => [$name], + 'availabilityZone' => $zone, + 'blueprintId' => $blueprint['blueprintId'], + 'bundleId' => $this->server->provider_data['plan'], + 'keyPairName' => $name, + 'ipAddressType' => 'ipv4', + ]); + } + + public function generateKeyPair(): void + { + $key = RSA::createKey(2048); + /** @var FilesystemAdapter $disk */ + $disk = Storage::disk(config('core.key_pairs_disk')); + $disk->put((string) $this->server->id, $key->toString('PKCS8')); + chmod($disk->path((string) $this->server->id), 0400); + $disk->put($this->server->id.'.pub', $key->getPublicKey()->toString('OpenSSH')); + } + + public function isRunning(): bool + { + if (! isset($this->server->provider_data['instance_name'])) { + return false; + } + + $result = $this->request('GetInstance', [ + 'instanceName' => $this->server->provider_data['instance_name'], + ]); + $instance = $result['instance'] ?? []; + + if (($instance['state']['name'] ?? '') !== 'running' || empty($instance['publicIpAddress'])) { + return false; + } + + if (! ($this->server->provider_data['firewall_configured'] ?? false)) { + $this->request('PutInstancePublicPorts', [ + 'instanceName' => $this->server->provider_data['instance_name'], + 'portInfos' => [[ + 'fromPort' => 0, + 'toPort' => 65535, + 'protocol' => 'all', + 'cidrs' => ['0.0.0.0/0'], + ]], + ]); + $this->server->jsonUpdate('provider_data', 'firewall_configured', true, false); + } + + $this->server->ip = $instance['publicIpAddress']; + $this->server->local_ip = $instance['privateIpAddress'] ?? null; + $this->server->save(); + + return true; + } + + public function delete(): void + { + if (isset($this->server->provider_data['instance_name'])) { + $this->request('DeleteInstance', [ + 'instanceName' => $this->server->provider_data['instance_name'], + ]); + } + + if (isset($this->server->provider_data['ssh_key_name'])) { + $this->request('DeleteKeyPair', [ + 'keyPairName' => $this->server->provider_data['ssh_key_name'], + ]); + } + } + + /** + * @return array> + */ + private function paginate(string $operation, string $key, string $region): array + { + $items = []; + $parameters = ['includeInactive' => false]; + + do { + $result = $this->request($operation, $parameters, $region); + $items = array_merge($items, $result[$key] ?? []); + $parameters['pageToken'] = $result['nextPageToken'] ?? null; + } while ($parameters['pageToken']); + + return $items; + } + + /** + * @param array $parameters + * @param array{key: string, secret: string}|null $credentials + * @return array + */ + private function request(string $operation, array $parameters = [], ?string $region = null, #[SensitiveParameter] ?array $credentials = null): array + { + $region ??= $this->server->provider_data['region'] ?? 'us-east-1'; + + if (! preg_match('/^[a-z]{2}(?:-[a-z]+)+-\d+$/', $region)) { + throw ValidationException::withMessages(['region' => 'Invalid AWS Lightsail region.']); + } + + try { + $client = app(LightsailClient::class, ['args' => [ + 'version' => '2016-11-28', + 'region' => $region, + 'credentials' => $credentials ?? $this->serverProvider->getCredentials(), + ]]); + $result = $client->execute($client->getCommand($operation, $parameters))->toArray(); + } catch (AwsException $exception) { + if ($exception->getAwsErrorCode() === 'NotFoundException' + && in_array($operation, ['GetInstance', 'DeleteInstance', 'DeleteKeyPair'], true)) { + return []; + } + + throw new ServerProviderError('AWS Lightsail could not complete '.$operation.'. Check the provider permissions and try again.'); + } + + foreach ($result['operations'] ?? (isset($result['operation']) ? [$result['operation']] : []) as $operationResult) { + if (($operationResult['status'] ?? '') === 'Failed') { + throw new ServerProviderError('AWS Lightsail could not complete '.$operation.'.'); + } + } + + return $result; + } +} diff --git a/docs/4.x/settings/server-providers.md b/docs/4.x/settings/server-providers.md index 2692a627c..95aeb6974 100644 --- a/docs/4.x/settings/server-providers.md +++ b/docs/4.x/settings/server-providers.md @@ -11,6 +11,7 @@ A connected provider is also used to discover the private networks your servers ## Supported Providers - AWS +- AWS Lightsail - Akamai (Linode) - Digital Ocean - Vultr @@ -26,6 +27,32 @@ Here you can see the required permissions for each provider's API Keys. - AWS IAM users must have Programmatic API Access. - AWS IAM users need to belong to a group with the `AmazonEC2FullAccess` managed policies. +### AWS Lightsail + +Connect **AWS Lightsail** with an IAM access key ID and secret access key. This is a separate connection from the AWS (EC2) provider. + +The IAM identity needs these permissions in the regions you use: + +- `lightsail:GetRegions` +- `lightsail:GetBundles` +- `lightsail:GetBlueprints` +- `lightsail:ImportKeyPair` +- `lightsail:CreateInstances` +- `lightsail:GetInstance` +- `lightsail:PutInstancePublicPorts` +- `lightsail:DeleteInstance` +- `lightsail:DeleteKeyPair` + +Allow `GetRegions` in `us-east-1` as well, because Vito uses it to verify the connection and list regions. See the [AWS Lightsail permissions reference](https://docs.aws.amazon.com/service-authorization/latest/reference/list_lightsail.html) for resource-level restrictions. + +When creating a server, select the connected profile, region, plan, and Ubuntu version. Vito retrieves active Linux plans with public IPv4 addresses and selects an available Ubuntu image and availability zone. If AWS no longer offers the selected Ubuntu version, creation returns an error before allocating resources. + +Vito creates an RSA SSH key for each instance and connects initially as `ubuntu`. The Lightsail firewall allows inbound traffic so that you can manage access through Vito's server firewall; include the firewall service when provisioning. Deleting a server with **Delete from provider** selected also removes its Lightsail instance and imported SSH key. Leaving that option off keeps both resources in AWS. + +The instance uses its assigned public IPv4 address. Lightsail can change this address after a stop/start; automatic static IP allocation and provider private-network discovery are not included. + +For the existing API, use `provider: "lightsail"` and send `key` and `secret` as top-level request fields when connecting a provider. + ### Linode - `Linodes` (Read/Write) diff --git a/public/api-docs/openapi/server-providers.yaml b/public/api-docs/openapi/server-providers.yaml index 8eece644f..84f663f81 100644 --- a/public/api-docs/openapi/server-providers.yaml +++ b/public/api-docs/openapi/server-providers.yaml @@ -74,7 +74,6 @@ paths: required: - name - provider - - credentials properties: name: type: string @@ -82,14 +81,21 @@ paths: example: 'DigitalOcean' provider: type: string - enum: ['aws', 'hetzner', 'digitalocean', 'linode', 'vultr'] + enum: ['aws', 'lightsail', 'hetzner', 'digitalocean', 'linode', 'vultr'] description: Server provider type example: 'digitalocean' - credentials: - type: object - description: Provider-specific credentials - example: - token: 'dop_v1_xxxxxxxxxxxx' + token: + type: string + writeOnly: true + description: API token (required for Hetzner, DigitalOcean, Linode, and Vultr) + key: + type: string + writeOnly: true + description: Access key ID (required for AWS and AWS Lightsail) + secret: + type: string + writeOnly: true + description: Secret access key (required for AWS and AWS Lightsail) global: type: boolean description: Whether this provider should be available globally (not tied to current project) diff --git a/public/api-docs/openapi/servers.yaml b/public/api-docs/openapi/servers.yaml index 5be390400..f7904a64d 100644 --- a/public/api-docs/openapi/servers.yaml +++ b/public/api-docs/openapi/servers.yaml @@ -76,7 +76,7 @@ paths: properties: provider: type: string - enum: ['custom', 'aws', 'hetzner', 'digitalocean', 'linode', 'vultr'] + enum: ['custom', 'aws', 'lightsail', 'hetzner', 'digitalocean', 'linode', 'vultr'] description: Server provider type example: 'digitalocean' name: diff --git a/public/api-docs/openapi/user-server-providers.yaml b/public/api-docs/openapi/user-server-providers.yaml index d62572f18..52107c562 100644 --- a/public/api-docs/openapi/user-server-providers.yaml +++ b/public/api-docs/openapi/user-server-providers.yaml @@ -50,7 +50,6 @@ paths: required: - name - provider - - credentials properties: name: type: string @@ -58,14 +57,21 @@ paths: example: 'DigitalOcean' provider: type: string - enum: ['aws', 'hetzner', 'digitalocean', 'linode', 'vultr'] + enum: ['aws', 'lightsail', 'hetzner', 'digitalocean', 'linode', 'vultr'] description: Server provider type example: 'digitalocean' - credentials: - type: object - description: Provider-specific credentials - example: - token: 'dop_v1_xxxxxxxxxxxx' + token: + type: string + writeOnly: true + description: API token (required for Hetzner, DigitalOcean, Linode, and Vultr) + key: + type: string + writeOnly: true + description: Access key ID (required for AWS and AWS Lightsail) + secret: + type: string + writeOnly: true + description: Secret access key (required for AWS and AWS Lightsail) global: type: boolean description: Whether this provider should be available globally (not tied to current project) diff --git a/tests/Feature/LightsailProviderTest.php b/tests/Feature/LightsailProviderTest.php new file mode 100644 index 000000000..d86cebc66 --- /dev/null +++ b/tests/Feature/LightsailProviderTest.php @@ -0,0 +1,381 @@ +lightsailHandler = new MockHandler; + $this->lightsailCommands = []; + $this->app->bind(LightsailClient::class, function ($app, array $parameters): LightsailClient { + return new LightsailClient(array_merge($parameters['args'], [ + 'retries' => 0, + 'handler' => function (CommandInterface $command, RequestInterface $request): PromiseInterface { + $this->lightsailCommands[] = [ + 'name' => $command->getName(), + 'parameters' => $command->toArray(), + 'host' => $request->getUri()->getHost(), + ]; + + return ($this->lightsailHandler)($command, $request); + }, + ])); + }); + + $this->lightsailProfile = ServerProvider::factory()->create([ + 'provider' => Lightsail::id(), + 'user_id' => $this->user->id, + 'project_id' => $this->user->current_project_id, + 'credentials' => ['key' => 'test-key', 'secret' => 'test-secret'], + ]); + $this->server->update([ + 'provider' => Lightsail::id(), + 'provider_id' => $this->lightsailProfile->id, + 'provider_data' => ['region' => 'eu-central-1', 'plan' => 'small_3_0'], + 'os' => OperatingSystem::UBUNTU24, + 'ip' => '', + ]); + $this->server->refresh(); + $this->lightsailBundle = [ + 'bundleId' => 'small_3_0', 'name' => 'Small', 'isActive' => true, + 'supportedPlatforms' => ['LINUX_UNIX'], 'publicIpv4AddressCount' => 1, + 'cpuCount' => 2, 'ramSizeInGb' => 2, 'diskSizeInGb' => 60, 'price' => 12, + ]; + $this->lightsailRegions = ['regions' => [[ + 'name' => 'eu-central-1', 'displayName' => 'Frankfurt', + 'availabilityZones' => [['zoneName' => 'eu-central-1b']], + ]]]; + $this->lightsailBlueprint = [ + 'blueprintId' => 'ubuntu_24_04', 'group' => 'ubuntu', 'type' => 'os', + 'isActive' => true, 'version' => '24.04 LTS', + ]; +}); + +test('lightsail is available through bootstrap with a masked secret field', function () { + $config = app(GetBootstrap::class)->handle()['configs']['server_provider']['providers']['lightsail']; + + expect($config['label'])->toBe('AWS Lightsail') + ->and($config['default_user'])->toBe('ubuntu') + ->and($config['form'][1]['type'])->toBe('password'); +}); + +test('connect lightsail through the existing web and api flows', function (bool $api) { + $this->lightsailHandler->append(new Result($this->lightsailRegions)); + $input = ['provider' => 'lightsail', 'name' => 'My Lightsail', 'key' => 'new-key', 'secret' => 'new-secret']; + + if ($api) { + Sanctum::actingAs($this->user, ['read', 'write']); + $this->postJson(route('api.user.server-providers.create'), $input) + ->assertSuccessful() + ->assertJsonFragment(['provider' => 'lightsail']) + ->assertDontSee('new-secret') + ->assertDontSee('new-key'); + } else { + $this->actingAs($this->user)->post(route('server-providers.store'), $input) + ->assertSessionDoesntHaveErrors(); + } + + $this->assertDatabaseHas('server_providers', [ + 'profile' => 'My Lightsail', 'provider' => 'lightsail', + 'project_id' => $this->user->current_project_id, + ]); + $profile = ServerProvider::query()->where('profile', 'My Lightsail')->firstOrFail(); + expect($profile->credentials)->toBe(['key' => 'new-key', 'secret' => 'new-secret']) + ->and($profile->getRawOriginal('credentials'))->not->toContain('new-secret') + ->and($this->lightsailHandler->getLastRequest()->getHeaderLine('Authorization'))->toContain('Credential=new-key/'); +})->with([false, true]); + +test('lightsail credentials are required and must be strings', function (array $credentials) { + Sanctum::actingAs($this->user, ['write']); + $this->postJson(route('api.user.server-providers.create'), array_merge([ + 'provider' => 'lightsail', 'name' => 'Invalid', + ], $credentials))->assertUnprocessable()->assertJsonValidationErrors(['key', 'secret']); + + expect($this->lightsailCommands)->toBe([]); +})->with([[[]], [['key' => [], 'secret' => []]]]); + +test('lightsail rejected credentials return validation errors without secrets', function () { + Sanctum::actingAs($this->user, ['write']); + $this->lightsailHandler->append(new AwsException('test-secret', new Command('GetRegions'), ['code' => 'AccessDeniedException'])); + + $this->postJson(route('api.user.server-providers.create'), [ + 'provider' => 'lightsail', 'name' => 'Rejected', 'key' => 'test-key', 'secret' => 'test-secret', + ])->assertUnprocessable()->assertJsonValidationErrors('provider')->assertDontSee('test-secret'); + + $this->assertDatabaseMissing('server_providers', ['profile' => 'Rejected']); +}); + +test('lightsail regions and paginated compatible plans use the selected region', function () { + $this->actingAs($this->user); + $this->lightsailHandler->append( + new Result($this->lightsailRegions), + new Result(['bundles' => [ + array_merge($this->lightsailBundle, ['bundleId' => 'windows', 'supportedPlatforms' => ['WINDOWS']]), + array_merge($this->lightsailBundle, ['bundleId' => 'ipv6', 'publicIpv4AddressCount' => 0]), + array_merge($this->lightsailBundle, ['bundleId' => 'inactive', 'isActive' => false]), + ], 'nextPageToken' => 'next-bundles']), + new Result(['bundles' => [$this->lightsailBundle]]), + ); + + $this->getJson(route('server-providers.regions', $this->lightsailProfile)) + ->assertExactJson(['eu-central-1' => 'Frankfurt (eu-central-1)']); + $this->getJson(route('server-providers.plans', ['serverProvider' => $this->lightsailProfile, 'region' => 'eu-central-1'])) + ->assertExactJson(['small_3_0' => 'Small - 2 Cores - 2048 Memory - 60 Disk (12.00/mo)']); + + expect($this->lightsailCommands[2]['parameters']['pageToken'])->toBe('next-bundles') + ->and($this->lightsailCommands[2]['host'])->toBe('lightsail.eu-central-1.amazonaws.com') + ->and($this->lightsailProfile->provider()->plans(null))->toBe([]); +}); + +test('lightsail provisions the selected ubuntu image and queues installation', function (string $os, string $version) { + $this->actingAs($this->user); + $this->lightsailHandler->append( + new Result($this->lightsailRegions), + new Result(['bundles' => [$this->lightsailBundle]]), + new Result(['blueprints' => [array_merge($this->lightsailBlueprint, ['isActive' => false])], 'nextPageToken' => 'next-images']), + new Result(['blueprints' => [array_merge($this->lightsailBlueprint, [ + 'version' => $version.' LTS', 'blueprintId' => 'ubuntu_'.str_replace('.', '_', $version), + ])]]), + new Result, + new Result(['operations' => [['status' => 'Started']]]), + ); + + $this->post(route('servers.store'), [ + 'provider' => 'lightsail', 'server_provider' => $this->lightsailProfile->id, + 'name' => 'Production server / with spaces', 'os' => $os, + 'region' => 'eu-central-1', 'plan' => 'small_3_0', + ])->assertSessionDoesntHaveErrors(); + + $server = Server::query()->where('name', 'Production server / with spaces')->firstOrFail(); + $this->assertDatabaseHas('servers', ['id' => $server->id, 'provider' => 'lightsail', 'ssh_user' => 'ubuntu']); + expect($server->provider_data['instance_name'])->toMatch('/^vito-\d+-[a-z0-9]{12}$/') + ->and($server->sshKey()['public_key'])->toStartWith('ssh-rsa ') + ->and($this->lightsailCommands[0]['parameters']['includeAvailabilityZones'])->toBeTrue() + ->and($this->lightsailCommands[3]['parameters']['pageToken'])->toBe('next-images') + ->and(base64_decode($this->lightsailCommands[4]['parameters']['publicKeyBase64']))->toBe($server->sshKey()['public_key']); + $create = $this->lightsailCommands[5]['parameters']; + expect($create['instanceNames'])->toBe([$server->provider_data['instance_name']]) + ->and($create['keyPairName'])->toBe($server->provider_data['ssh_key_name']) + ->and($create['availabilityZone'])->toBe('eu-central-1b') + ->and($create['blueprintId'])->toBe('ubuntu_'.str_replace('.', '_', $version)) + ->and($create['bundleId'])->toBe('small_3_0') + ->and($create['ipAddressType'])->toBe('ipv4'); + Queue::assertPushed(InstallJob::class); +})->with([ + ['ubuntu_20', '20.04'], ['ubuntu_22', '22.04'], ['ubuntu_24', '24.04'], +]); + +test('lightsail validates server input before making requests', function () { + $this->actingAs($this->user)->postJson(route('servers.store'), [ + 'provider' => 'lightsail', 'server_provider' => $this->lightsailProfile->id, + 'name' => 'Invalid', 'os' => 'ubuntu_24', 'region' => 'https://example.com', 'plan' => [], + ])->assertUnprocessable()->assertJsonValidationErrors(['region', 'plan']); + + expect($this->lightsailCommands)->toBe([]); +}); + +test('lightsail rejects malformed api plan regions with validation feedback', function () { + Sanctum::actingAs($this->user, ['read']); + + $this->getJson(route('api.user.server-providers.plans', [ + 'serverProvider' => $this->lightsailProfile->id, 'region' => 'not-a-region', + ]))->assertUnprocessable()->assertJsonValidationErrors('region'); + + expect($this->lightsailCommands)->toBe([]); +}); + +test('lightsail rejects unavailable catalog selections before creating resources', function (string $missing) { + $this->lightsailHandler->append(new Result($missing === 'region' ? ['regions' => []] : $this->lightsailRegions)); + if ($missing !== 'region') { + $this->lightsailHandler->append(new Result(['bundles' => $missing === 'plan' ? [] : [$this->lightsailBundle]])); + } + if ($missing === 'image') { + $this->lightsailHandler->append(new Result(['blueprints' => []])); + } + + expect(fn () => $this->server->provider()->create())->toThrow(ServerProviderError::class, 'unavailable'); + expect(array_column($this->lightsailCommands, 'name'))->not->toContain('ImportKeyPair', 'CreateInstances'); +})->with(['region', 'plan', 'image']); + +test('lightsail cleans up its key when instance creation fails', function () { + $this->lightsailHandler->append( + new Result($this->lightsailRegions), + new Result(['bundles' => [$this->lightsailBundle]]), + new Result(['blueprints' => [$this->lightsailBlueprint]]), + new Result, + new AwsException('upstream secret', new Command('CreateInstances'), ['code' => 'AccessDeniedException']), + new AwsException('not found', new Command('DeleteInstance'), ['code' => 'NotFoundException']), + new Result, + ); + + $this->actingAs($this->user)->postJson(route('servers.store'), [ + 'provider' => 'lightsail', 'server_provider' => $this->lightsailProfile->id, + 'name' => 'Failed Lightsail', 'os' => 'ubuntu_24', 'region' => 'eu-central-1', 'plan' => 'small_3_0', + ])->assertUnprocessable()->assertJsonValidationErrors('provider')->assertDontSee('upstream secret'); + + $this->assertDatabaseMissing('servers', ['name' => 'Failed Lightsail']); + expect($this->lightsailCommands[5]['name'])->toBe('DeleteInstance') + ->and($this->lightsailCommands[6]['name'])->toBe('DeleteKeyPair') + ->and($this->lightsailCommands[6]['parameters']['keyPairName'])->toBe($this->lightsailCommands[3]['parameters']['keyPairName']); + Queue::assertNotPushed(InstallJob::class); +}); + +test('lightsail retains cleanup targets after a lost creation response', function (string $operation, bool $cleanupFails) { + $this->lightsailHandler->append( + new Result($this->lightsailRegions), + new Result(['bundles' => [$this->lightsailBundle]]), + new Result(['blueprints' => [$this->lightsailBlueprint]]), + ); + if ($operation === 'CreateInstances') { + $this->lightsailHandler->append(new Result); + } + $this->lightsailHandler->append(function (CommandInterface $command) use ($operation): AwsException { + $server = Server::query()->where('name', 'Lost response')->firstOrFail(); + $this->lostResponseKeys = $server->sshKey(); + $field = $operation === 'CreateInstances' ? 'instance_name' : 'ssh_key_name'; + $name = $operation === 'CreateInstances' ? $command['instanceNames'][0] : $command['keyPairName']; + expect($server->provider_data[$field])->toBe($name); + + return new AwsException('Response lost', $command, ['connection_error' => true]); + }); + $this->lightsailHandler->append($cleanupFails + ? new AwsException('Cleanup unavailable', new Command('DeleteInstance'), ['connection_error' => true]) + : new Result); + if (! $cleanupFails && $operation === 'CreateInstances') { + $this->lightsailHandler->append(new Result); + } + + expect(fn () => app(CreateServer::class)->create($this->user, $this->user->currentProject, [ + 'provider' => 'lightsail', 'server_provider' => $this->lightsailProfile->id, + 'name' => 'Lost response', 'os' => 'ubuntu_24', 'region' => 'eu-central-1', 'plan' => 'small_3_0', + ]))->toThrow($cleanupFails ? ServerProviderError::class : Illuminate\Validation\ValidationException::class); + + $deleteOperation = $operation === 'CreateInstances' ? 'DeleteInstance' : 'DeleteKeyPair'; + expect(array_column($this->lightsailCommands, 'name'))->toContain($deleteOperation); + if ($cleanupFails) { + $this->assertDatabaseHas('servers', ['name' => 'Lost response']); + expect(file_exists($this->lostResponseKeys['private_key_path']))->toBeTrue() + ->and(file_exists($this->lostResponseKeys['public_key_path']))->toBeTrue(); + } else { + $this->assertDatabaseMissing('servers', ['name' => 'Lost response']); + } + Queue::assertNotPushed(InstallJob::class); +})->with(['ImportKeyPair', 'CreateInstances'])->with([false, true]); + +test('lightsail waits for a running instance with a public address', function (array $instance) { + $this->server->jsonUpdate('provider_data', 'instance_name', 'vito-instance'); + $this->lightsailHandler->append(new Result(['instance' => $instance])); + + expect($this->server->provider()->isRunning())->toBeFalse() + ->and($this->server->fresh()->ip)->toBe('') + ->and(array_column($this->lightsailCommands, 'name'))->toBe(['GetInstance']); +})->with([ + [['state' => ['name' => 'pending'], 'publicIpAddress' => '203.0.113.10']], + [['state' => ['name' => 'running']]], +]); + +test('lightsail saves addresses and configures its outer firewall once', function () { + $this->server->jsonUpdate('provider_data', 'instance_name', 'vito-instance'); + $instance = ['state' => ['name' => 'running'], 'publicIpAddress' => '203.0.113.10', 'privateIpAddress' => '172.26.1.10']; + $this->lightsailHandler->append(new Result(['instance' => $instance]), new Result, new Result(['instance' => $instance])); + + expect($this->server->provider()->isRunning())->toBeTrue() + ->and($this->server->fresh()->provider()->isRunning())->toBeTrue(); + $this->assertDatabaseHas('servers', ['id' => $this->server->id, 'ip' => '203.0.113.10', 'local_ip' => '172.26.1.10']); + expect($this->lightsailCommands[1]['parameters']['portInfos'])->toBe([[ + 'fromPort' => 0, 'toPort' => 65535, 'protocol' => 'all', 'cidrs' => ['0.0.0.0/0'], + ]])->and(array_column($this->lightsailCommands, 'name'))->toBe(['GetInstance', 'PutInstancePublicPorts', 'GetInstance']); +}); + +test('lightsail readiness handles resources not yet visible', function () { + expect($this->server->provider()->isRunning())->toBeFalse(); + $this->server->jsonUpdate('provider_data', 'instance_name', 'vito-instance'); + $this->lightsailHandler->append(new AwsException('not found', new Command('GetInstance'), ['code' => 'NotFoundException'])); + + expect($this->server->provider()->isRunning())->toBeFalse(); +}); + +test('lightsail deletion respects the existing delete from provider choice', function (bool $delete) { + $this->server->jsonUpdate('provider_data', 'instance_name', 'vito-instance'); + $this->server->jsonUpdate('provider_data', 'ssh_key_name', 'vito-key'); + if ($delete) { + $this->lightsailHandler->append(new Result, new Result); + } + + $this->actingAs($this->user)->delete(route('servers.destroy', $this->server), [ + 'name' => $this->server->name, 'delete_from_provider' => $delete, + ])->assertSessionDoesntHaveErrors(); + + $this->assertDatabaseMissing('servers', ['id' => $this->server->id]); + expect(array_column($this->lightsailCommands, 'name'))->toBe($delete ? ['DeleteInstance', 'DeleteKeyPair'] : []); + if ($delete) { + expect($this->lightsailCommands[0]['parameters']['instanceName'])->toBe('vito-instance') + ->and($this->lightsailCommands[1]['parameters']['keyPairName'])->toBe('vito-key'); + } +})->with([true, false]); + +test('lightsail deletion tolerates resources already removed in aws', function () { + $this->server->jsonUpdate('provider_data', 'instance_name', 'vito-instance'); + $this->server->jsonUpdate('provider_data', 'ssh_key_name', 'vito-key'); + $this->lightsailHandler->append( + new AwsException('not found', new Command('DeleteInstance'), ['code' => 'NotFoundException']), + new AwsException('not found', new Command('DeleteKeyPair'), ['code' => 'NotFoundException']), + ); + + $this->server->provider()->delete(); + + expect(array_column($this->lightsailCommands, 'name'))->toBe(['DeleteInstance', 'DeleteKeyPair']); +}); + +test('lightsail keeps the server and ssh keys when aws rejects deletion', function () { + $this->server->jsonUpdate('provider_data', 'instance_name', 'vito-instance'); + $keys = $this->server->sshKey(); + $this->lightsailHandler->append(new AwsException('denied', new Command('DeleteInstance'), ['code' => 'AccessDeniedException'])); + + expect(fn () => app(DeleteServer::class)->delete($this->server, [ + 'name' => $this->server->name, 'delete_from_provider' => true, + ]))->toThrow(ServerProviderError::class); + + $this->assertDatabaseHas('servers', ['id' => $this->server->id]); + expect(file_exists($keys['private_key_path']))->toBeTrue() + ->and(file_exists($keys['public_key_path']))->toBeTrue(); +}); + +test('lightsail surfaces upstream failures without retaining credential bearing exceptions', function (bool $operationFailure) { + $this->server->jsonUpdate('provider_data', 'instance_name', 'vito-instance'); + $this->lightsailHandler->append($operationFailure + ? new Result(['operations' => [['status' => 'Failed', 'errorDetails' => 'test-secret']]]) + : new AwsException('test-secret', new Command('DeleteInstance'), ['code' => 'AccessDeniedException'])); + + try { + $this->server->provider()->delete(); + $this->fail('Expected the provider error to be surfaced.'); + } catch (ServerProviderError $exception) { + expect($exception->getMessage())->toContain('DeleteInstance')->not->toContain('test-secret') + ->and($exception->getPrevious())->toBeNull(); + } +})->with([true, false]);