From 451d30e72ed84daf60cfc193c43e668427bbfdb5 Mon Sep 17 00:00:00 2001 From: Saeed Vaziry Date: Tue, 15 Sep 2026 18:28:40 +0200 Subject: [PATCH 1/6] [Feat] Add AWS Lightsail server provider --- app/Models/Server.php | 6 +- .../ServerProviderServiceProvider.php | 21 + app/ServerProviders/Lightsail.php | 249 ++++++++++++ docs/4.x/settings/server-providers.md | 27 ++ public/api-docs/openapi/server-providers.yaml | 20 +- public/api-docs/openapi/servers.yaml | 2 +- .../openapi/user-server-providers.yaml | 20 +- tests/Feature/LightsailProviderTest.php | 381 ++++++++++++++++++ 8 files changed, 708 insertions(+), 18 deletions(-) create mode 100644 app/ServerProviders/Lightsail.php create mode 100644 tests/Feature/LightsailProviderTest.php 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]); From 4d6bf41dd5764bb68bd64baa060aacfd4ab3b9de Mon Sep 17 00:00:00 2001 From: Saeed Vaziry Date: Sun, 27 Sep 2026 23:49:54 +0200 Subject: [PATCH 2/6] [Fix] Bound Lightsail catalog pagination --- app/ServerProviders/Lightsail.php | 17 ++++++++++- tests/Feature/LightsailProviderTest.php | 39 +++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/app/ServerProviders/Lightsail.php b/app/ServerProviders/Lightsail.php index 0c7e28846..59d6b0867 100644 --- a/app/ServerProviders/Lightsail.php +++ b/app/ServerProviders/Lightsail.php @@ -14,6 +14,8 @@ class Lightsail extends AbstractProvider { + private const MAX_PAGES = 100; + public static function id(): string { return 'lightsail'; @@ -218,12 +220,25 @@ private function paginate(string $operation, string $key, string $region): array { $items = []; $parameters = ['includeInactive' => false]; + $page = 0; + $seenTokens = []; do { + if (++$page > self::MAX_PAGES) { + throw new ServerProviderError('AWS Lightsail returned too many pages for '.$operation.'.'); + } + $result = $this->request($operation, $parameters, $region); $items = array_merge($items, $result[$key] ?? []); $parameters['pageToken'] = $result['nextPageToken'] ?? null; - } while ($parameters['pageToken']); + + if (isset($seenTokens[$parameters['pageToken']])) { + throw new ServerProviderError('AWS Lightsail returned an invalid page token for '.$operation.'.'); + } + if ($parameters['pageToken'] !== null) { + $seenTokens[$parameters['pageToken']] = true; + } + } while ($parameters['pageToken'] !== null && $parameters['pageToken'] !== ''); return $items; } diff --git a/tests/Feature/LightsailProviderTest.php b/tests/Feature/LightsailProviderTest.php index 57cc3e72e..de4f7bea5 100644 --- a/tests/Feature/LightsailProviderTest.php +++ b/tests/Feature/LightsailProviderTest.php @@ -153,6 +153,45 @@ ->and($this->lightsailProfile->provider()->plans(null))->toBe([]); }); +test('lightsail stops repeated catalog pagination tokens', function (array $tokens, bool $blueprints) { + if ($blueprints) { + $this->lightsailHandler->append( + new Result($this->lightsailRegions), + new Result(['bundles' => [$this->lightsailBundle]]), + ); + } + foreach ($tokens as $token) { + $this->lightsailHandler->append(new Result(['nextPageToken' => $token])); + } + + expect(fn () => $blueprints ? $this->server->provider()->create() : $this->lightsailProfile->provider()->plans('eu-central-1')) + ->toThrow(ServerProviderError::class, 'invalid page token'); + $operations = array_column($this->lightsailCommands, 'name'); + expect(array_count_values($operations)[$blueprints ? 'GetBlueprints' : 'GetBundles'])->toBe(count($tokens)) + ->and($operations)->not->toContain('ImportKeyPair', 'CreateInstances'); +})->with([ + [['next-page', 'next-page']], + [['page-a', 'page-b', 'page-a']], + [['0', '0']], +])->with([false, true]); + +test('lightsail permits at most one hundred catalog pages', function (bool $hasMore) { + for ($page = 1; $page <= 100; $page++) { + $this->lightsailHandler->append(new Result([ + 'bundles' => [array_merge($this->lightsailBundle, ['bundleId' => 'plan-'.$page])], + 'nextPageToken' => $page < 100 || $hasMore ? 'page-'.$page : null, + ])); + } + + if ($hasMore) { + expect(fn () => $this->lightsailProfile->provider()->plans('eu-central-1')) + ->toThrow(ServerProviderError::class, 'too many pages'); + } else { + expect($this->lightsailProfile->provider()->plans('eu-central-1'))->toHaveCount(100)->toHaveKeys(['plan-1', 'plan-100']); + } + expect($this->lightsailCommands)->toHaveCount(100); +})->with([false, true]); + test('lightsail provisions the selected ubuntu image and queues installation', function (string $os, string $version) { $this->actingAs($this->user); $this->lightsailHandler->append( From 8443adec2eb67028375c7ae114638b1d911df5ee Mon Sep 17 00:00:00 2001 From: Saeed Vaziry Date: Sun, 27 Sep 2026 23:57:57 +0200 Subject: [PATCH 3/6] [Docs] Add step-by-step Lightsail provider setup --- docs/4.x/settings/server-providers.md | 41 +++++++++++++++++++++++---- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/docs/4.x/settings/server-providers.md b/docs/4.x/settings/server-providers.md index 2cbe6a83c..3229c416b 100644 --- a/docs/4.x/settings/server-providers.md +++ b/docs/4.x/settings/server-providers.md @@ -42,9 +42,16 @@ For each server, Vito creates an SSH key pair and a security group in the region ### 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. +AWS Lightsail uses a separate connection from the AWS (EC2) provider. An EC2 connection and the `AmazonEC2FullAccess` policy do not grant Lightsail access. -The IAM identity needs these permissions in the regions you use: +#### 1. Create an IAM user and access key + +1. In the AWS console, open **IAM → Users** and create a dedicated user for Vito. It does not need AWS console access. +2. Give the user an IAM policy allowing the Lightsail actions below, either directly or through a group. You can create one under **IAM → Policies → Create policy** by selecting the **Lightsail** service and these actions. +3. Open the user's **Security credentials** tab and choose **Create access key**. Choose the option for an application running outside AWS. +4. Save the **Access key ID** and **Secret access key**. AWS only shows the secret when the key is created; if you lose it, create another key. + +The policy must allow these actions in the regions you use: - `lightsail:GetRegions` - `lightsail:GetBundles` @@ -58,13 +65,37 @@ The IAM identity needs these permissions in the regions you use: 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. +#### 2. Connect Lightsail to Vito + +1. In Vito, open **Settings → Server Providers → Connect**. +2. Choose **AWS Lightsail** and give the connection a name. +3. Enter the access key ID as **Access Key** and the secret access key as **Secret Access Key**. +4. Choose the connection's [scope](#scope) and connect. Vito checks the credentials by listing Lightsail regions before saving the connection. + +For the API, use `provider: "lightsail"` and send `key` and `secret` as top-level request fields when connecting a provider. + +#### Creating servers on Lightsail + +When you [create a server](../servers/create.md) on a Lightsail connection: -Vito creates an RSA SSH key for each instance and connects initially as `ubuntu`. The Lightsail firewall permits only SSH until UFW installs and applies its rules successfully, then allows inbound traffic through to Vito's server firewall. Include the firewall service when provisioning; if it is omitted or fails, the Lightsail firewall remains SSH-only. Deleting a server with **Delete from provider** selected also removes its Lightsail instance and imported SSH key. If AWS reports that deletion is still in progress, Vito keeps the server record and local keys; retry deletion after AWS finishes. Leaving that option off keeps both resources in AWS. +- **Region** is the AWS region where the instance will run. Vito selects an availability zone within it. +- **Plan** lists active Linux plans with public IPv4 addresses in that region. +- **Operating System** must be an Ubuntu version AWS currently offers. Vito looks up the image at creation time and returns an error before allocating resources if it is unavailable. +- Include the **UFW** firewall service in the services to install. + +Vito generates and imports a separate RSA SSH key for each instance and initially connects as `ubuntu`. Your Vito installation must be able to reach the instance's public IPv4 address on SSH port `22`. + +:::warning +The Lightsail firewall permits only SSH until UFW installs and applies its rules successfully. Vito then allows inbound traffic through to the server's UFW firewall, where you manage access to service ports. If UFW is omitted or fails, the Lightsail firewall remains SSH-only. +::: 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. +#### Deleting servers + +Choose **Delete from Vito and AWS Lightsail** to remove the Lightsail instance and its imported SSH key as well as the server in Vito. Deleting only from Vito keeps both resources in AWS. + +If AWS reports that deletion is still in progress, Vito keeps the server record and local keys. Wait for AWS to finish, then retry deletion; Vito tolerates resources that have already been removed. ### Akamai (Linode) From c619e0b1b1bfa7597bfc2cc575d9dc6d226de230 Mon Sep 17 00:00:00 2001 From: Saeed Vaziry Date: Mon, 28 Sep 2026 00:19:07 +0200 Subject: [PATCH 4/6] [Fix] Match versioned Lightsail Ubuntu blueprint groups --- app/ServerProviders/Lightsail.php | 2 +- tests/Feature/LightsailProviderTest.php | 22 ++++++++++++++++++++-- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/app/ServerProviders/Lightsail.php b/app/ServerProviders/Lightsail.php index 59d6b0867..e878cd7ef 100644 --- a/app/ServerProviders/Lightsail.php +++ b/app/ServerProviders/Lightsail.php @@ -107,7 +107,7 @@ public function create(): void $blueprint = collect($this->paginate('GetBlueprints', 'blueprints', $region)) ->first(fn (array $blueprint): bool => ($blueprint['isActive'] ?? false) - && ($blueprint['group'] ?? '') === 'ubuntu' + && in_array($blueprint['group'] ?? '', ['ubuntu', $this->server->os->value], true) && ($blueprint['type'] ?? '') === 'os' && str_starts_with($blueprint['version'] ?? '', $this->server->os->getVersion())); diff --git a/tests/Feature/LightsailProviderTest.php b/tests/Feature/LightsailProviderTest.php index de4f7bea5..544d3841b 100644 --- a/tests/Feature/LightsailProviderTest.php +++ b/tests/Feature/LightsailProviderTest.php @@ -72,7 +72,7 @@ 'availabilityZones' => [['zoneName' => 'eu-central-1b']], ]]]; $this->lightsailBlueprint = [ - 'blueprintId' => 'ubuntu_24_04', 'group' => 'ubuntu', 'type' => 'os', + 'blueprintId' => 'ubuntu_24_04', 'group' => 'ubuntu_24', 'type' => 'os', 'isActive' => true, 'version' => '24.04 LTS', ]; }); @@ -192,13 +192,14 @@ expect($this->lightsailCommands)->toHaveCount(100); })->with([false, true]); -test('lightsail provisions the selected ubuntu image and queues installation', function (string $os, string $version) { +test('lightsail provisions the selected ubuntu image and queues installation', function (string $os, string $version, bool $versionedGroup) { $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, [ + 'group' => $versionedGroup ? $os : 'ubuntu', 'version' => $version.' LTS', 'blueprintId' => 'ubuntu_'.str_replace('.', '_', $version), ])]]), new Result, @@ -228,6 +229,23 @@ Queue::assertPushed(InstallJob::class); })->with([ ['ubuntu_20', '20.04'], ['ubuntu_22', '22.04'], ['ubuntu_24', '24.04'], ['ubuntu_26', '26.04'], +])->with([true, false]); + +test('lightsail rejects unsuitable blueprints before creating resources', function (array $blueprint) { + $this->lightsailHandler->append( + new Result($this->lightsailRegions), + new Result(['bundles' => [$this->lightsailBundle]]), + new Result(['blueprints' => [array_merge($this->lightsailBlueprint, $blueprint)]]), + ); + + expect(fn () => $this->server->provider()->create())->toThrow(ServerProviderError::class, 'The selected Ubuntu version is unavailable'); + expect(array_column($this->lightsailCommands, 'name'))->not->toContain('ImportKeyPair', 'CreateInstances'); +})->with([ + [['isActive' => false]], + [['group' => 'debian']], + [['group' => 'ubuntu_22']], + [['type' => 'app']], + [['version' => '22.04 LTS']], ]); test('lightsail validates server input before making requests', function () { From 8e0fe60376eb171748fea441b8041940f6817f42 Mon Sep 17 00:00:00 2001 From: Saeed Vaziry Date: Mon, 28 Sep 2026 00:32:10 +0200 Subject: [PATCH 5/6] [Fix] Handle Lightsail missing-resource error codes --- app/ServerProviders/Lightsail.php | 2 +- tests/Feature/LightsailProviderTest.php | 41 ++++++++++++++++++++----- 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/app/ServerProviders/Lightsail.php b/app/ServerProviders/Lightsail.php index e878cd7ef..7e95d7371 100644 --- a/app/ServerProviders/Lightsail.php +++ b/app/ServerProviders/Lightsail.php @@ -264,7 +264,7 @@ private function request(string $operation, array $parameters = [], ?string $reg ]]); $result = $client->execute($client->getCommand($operation, $parameters))->toArray(); } catch (AwsException $exception) { - if ($exception->getAwsErrorCode() === 'NotFoundException' + if (in_array($exception->getAwsErrorCode(), ['NotFoundException', 'DoesNotExist'], true) && in_array($operation, ['GetInstance', 'DeleteInstance', 'DeleteKeyPair'], true)) { return []; } diff --git a/tests/Feature/LightsailProviderTest.php b/tests/Feature/LightsailProviderTest.php index 544d3841b..eabaa8f24 100644 --- a/tests/Feature/LightsailProviderTest.php +++ b/tests/Feature/LightsailProviderTest.php @@ -303,6 +303,33 @@ Queue::assertNotPushed(InstallJob::class); }); +test('lightsail reports creation failures when cleanup targets do not exist', function (string $operation, string $code) { + $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(new AwsException('upstream secret', new Command($operation), ['code' => 'AccessDeniedException'])); + if ($operation === 'CreateInstances') { + $this->lightsailHandler->append(new AwsException('not found', new Command('DeleteInstance'), ['code' => $code])); + } + $this->lightsailHandler->append(new AwsException('not found', new Command('DeleteKeyPair'), ['code' => $code])); + + $this->actingAs($this->user)->postJson(route('servers.store'), [ + 'provider' => 'lightsail', 'server_provider' => $this->lightsailProfile->id, + 'name' => 'Rejected Lightsail', 'os' => 'ubuntu_24', 'region' => 'eu-central-1', 'plan' => 'small_3_0', + ])->assertUnprocessable()->assertJsonValidationErrors('provider') + ->assertJsonPath('errors.provider.0', 'AWS Lightsail could not complete '.$operation.'. Check the provider permissions and try again.') + ->assertDontSee('upstream secret'); + + $this->assertDatabaseMissing('servers', ['name' => 'Rejected Lightsail']); + expect(end($this->lightsailCommands)['name'])->toBe('DeleteKeyPair'); + Queue::assertNotPushed(InstallJob::class); +})->with(['ImportKeyPair', 'CreateInstances'])->with(['NotFoundException', 'DoesNotExist']); + test('lightsail retains cleanup targets after a lost creation response', function (string $operation, bool $cleanupFails) { $this->lightsailHandler->append( new Result($this->lightsailRegions), @@ -416,13 +443,13 @@ ->and($this->server->fresh()->provider_data['firewall_configured'] ?? false)->toBeFalse(); }); -test('lightsail readiness handles resources not yet visible', function () { +test('lightsail readiness handles resources not yet visible', function (string $code) { 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'])); + $this->lightsailHandler->append(new AwsException('not found', new Command('GetInstance'), ['code' => $code])); expect($this->server->provider()->isRunning())->toBeFalse(); -}); +})->with(['NotFoundException', 'DoesNotExist']); test('lightsail deletion respects the existing delete from provider choice', function (bool $delete) { $this->server->jsonUpdate('provider_data', 'instance_name', 'vito-instance'); @@ -443,18 +470,18 @@ } })->with([true, false]); -test('lightsail deletion tolerates resources already removed in aws', function () { +test('lightsail deletion tolerates resources already removed in aws', function (string $code) { $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']), + new AwsException('not found', new Command('DeleteInstance'), ['code' => $code]), + new AwsException('not found', new Command('DeleteKeyPair'), ['code' => $code]), ); $this->server->provider()->delete(); expect(array_column($this->lightsailCommands, 'name'))->toBe(['DeleteInstance', 'DeleteKeyPair']); -}); +})->with(['NotFoundException', 'DoesNotExist']); test('lightsail keeps the server and ssh keys when aws rejects deletion', function () { $this->server->jsonUpdate('provider_data', 'instance_name', 'vito-instance'); From 52052e8a75e6b882de2cf9d86b5e09ed0b0b1962 Mon Sep 17 00:00:00 2001 From: Saeed Vaziry Date: Tue, 29 Sep 2026 01:06:57 +0200 Subject: [PATCH 6/6] [Fix] Use distinct Lightsail key pair name and raw public key ImportKeyPair expects the OpenSSH public key as-is, so stop base64-encoding it. Suffix the key pair name with -key so it differs from the instance name, and include the AWS error code in provider error messages. --- app/ServerProviders/Lightsail.php | 15 +++++++++------ tests/Feature/LightsailProviderTest.php | 5 +++-- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/app/ServerProviders/Lightsail.php b/app/ServerProviders/Lightsail.php index 7e95d7371..693dd106d 100644 --- a/app/ServerProviders/Lightsail.php +++ b/app/ServerProviders/Lightsail.php @@ -116,11 +116,12 @@ public function create(): void } $name = 'vito-'.$this->server->id.'-'.Str::lower(Str::random(12)); + $keyName = $name.'-key'; $this->generateKeyPair(); - $this->server->jsonUpdate('provider_data', 'ssh_key_name', $name); + $this->server->jsonUpdate('provider_data', 'ssh_key_name', $keyName); $this->request('ImportKeyPair', [ - 'keyPairName' => $name, - 'publicKeyBase64' => base64_encode($this->server->sshKey()['public_key']), + 'keyPairName' => $keyName, + 'publicKeyBase64' => $this->server->sshKey()['public_key'], ]); $this->server->jsonUpdate('provider_data', 'instance_name', $name); @@ -129,7 +130,7 @@ public function create(): void 'availabilityZone' => $zone, 'blueprintId' => $blueprint['blueprintId'], 'bundleId' => $this->server->provider_data['plan'], - 'keyPairName' => $name, + 'keyPairName' => $keyName, 'ipAddressType' => 'ipv4', ]); } @@ -264,12 +265,14 @@ private function request(string $operation, array $parameters = [], ?string $reg ]]); $result = $client->execute($client->getCommand($operation, $parameters))->toArray(); } catch (AwsException $exception) { - if (in_array($exception->getAwsErrorCode(), ['NotFoundException', 'DoesNotExist'], true) + $code = $exception->getAwsErrorCode(); + + if (in_array($code, ['NotFoundException', 'DoesNotExist'], true) && in_array($operation, ['GetInstance', 'DeleteInstance', 'DeleteKeyPair'], true)) { return []; } - throw new ServerProviderError('AWS Lightsail could not complete '.$operation.'. Check the provider permissions and try again.'); + throw new ServerProviderError('AWS Lightsail could not complete '.$operation.($code ? ' ('.$code.')' : '').'.'); } foreach ($result['operations'] ?? (isset($result['operation']) ? [$result['operation']] : []) as $operationResult) { diff --git a/tests/Feature/LightsailProviderTest.php b/tests/Feature/LightsailProviderTest.php index eabaa8f24..5762e3127 100644 --- a/tests/Feature/LightsailProviderTest.php +++ b/tests/Feature/LightsailProviderTest.php @@ -218,10 +218,11 @@ ->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']); + ->and($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['keyPairName'])->not->toBe($create['instanceNames'][0]) ->and($create['availabilityZone'])->toBe('eu-central-1b') ->and($create['blueprintId'])->toBe('ubuntu_'.str_replace('.', '_', $version)) ->and($create['bundleId'])->toBe('small_3_0') @@ -322,7 +323,7 @@ 'provider' => 'lightsail', 'server_provider' => $this->lightsailProfile->id, 'name' => 'Rejected Lightsail', 'os' => 'ubuntu_24', 'region' => 'eu-central-1', 'plan' => 'small_3_0', ])->assertUnprocessable()->assertJsonValidationErrors('provider') - ->assertJsonPath('errors.provider.0', 'AWS Lightsail could not complete '.$operation.'. Check the provider permissions and try again.') + ->assertJsonPath('errors.provider.0', 'AWS Lightsail could not complete '.$operation.' (AccessDeniedException).') ->assertDontSee('upstream secret'); $this->assertDatabaseMissing('servers', ['name' => 'Rejected Lightsail']);