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 536c34bce..131db00a8 100644 --- a/app/Providers/ServerProviderServiceProvider.php +++ b/app/Providers/ServerProviderServiceProvider.php @@ -5,14 +5,18 @@ use App\DTOs\DynamicField; use App\DTOs\DynamicForm; use App\Enums\OperatingSystem; +use App\Models\Service; use App\Plugins\RegisterServerProvider; use App\ServerProviders\AWS; use App\ServerProviders\Custom; use App\ServerProviders\DigitalOcean; use App\ServerProviders\Hetzner; +use App\ServerProviders\Lightsail; use App\ServerProviders\Linode; use App\ServerProviders\Proxmox; use App\ServerProviders\Vultr; +use App\Services\Firewall\Ufw; +use Illuminate\Support\Facades\Event; use Illuminate\Support\ServiceProvider; class ServerProviderServiceProvider extends ServiceProvider @@ -23,6 +27,7 @@ public function boot(): void { $this->custom(); $this->aws(); + $this->lightsail(); $this->hetzner(); $this->digitalOcean(); $this->linode(); @@ -58,6 +63,34 @@ private function aws(): void ->register(); } + private function lightsail(): void + { + Event::listen('service.installed', function (Service $service): void { + if ($service->name === Ufw::id() && $service->server->provider === Lightsail::id()) { + $provider = $service->server->provider(); + if ($provider instanceof Lightsail) { + $provider->configureFirewall(); + } + } + }); + + 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..693dd106d --- /dev/null +++ b/app/ServerProviders/Lightsail.php @@ -0,0 +1,291 @@ + ['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) + && in_array($blueprint['group'] ?? '', ['ubuntu', $this->server->os->value], true) + && ($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)); + $keyName = $name.'-key'; + $this->generateKeyPair(); + $this->server->jsonUpdate('provider_data', 'ssh_key_name', $keyName); + $this->request('ImportKeyPair', [ + 'keyPairName' => $keyName, + 'publicKeyBase64' => $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' => $keyName, + '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['ssh_access_configured'] ?? false) + && ! ($this->server->provider_data['firewall_configured'] ?? false)) { + $this->request('PutInstancePublicPorts', [ + 'instanceName' => $this->server->provider_data['instance_name'], + 'portInfos' => [[ + 'fromPort' => 22, + 'toPort' => 22, + 'protocol' => 'tcp', + 'cidrs' => ['0.0.0.0/0'], + ]], + ]); + $this->server->jsonUpdate('provider_data', 'ssh_access_configured', true, false); + } + + $this->server->ip = $instance['publicIpAddress']; + $this->server->local_ip = $instance['privateIpAddress'] ?? null; + $this->server->save(); + + return true; + } + + public function configureFirewall(): void + { + if ($this->server->provider_data['firewall_configured'] ?? false) { + return; + } + + $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); + } + + 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]; + $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; + + 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; + } + + /** + * @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) { + $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.($code ? ' ('.$code.')' : '').'.'); + } + + foreach ($result['operations'] ?? (isset($result['operation']) ? [$result['operation']] : []) as $operationResult) { + if (($operationResult['status'] ?? '') === 'Failed') { + throw new ServerProviderError('AWS Lightsail could not complete '.$operation.'.'); + } + + if (in_array($operation, ['DeleteInstance', 'DeleteKeyPair'], true) + && ! in_array($operationResult['status'] ?? '', ['Succeeded', 'Completed'], true)) { + throw new ServerProviderError('AWS Lightsail deletion is still in progress. Wait for it to finish, then retry deleting the server.'); + } + } + + return $result; + } +} diff --git a/docs/4.x/settings/server-providers.md b/docs/4.x/settings/server-providers.md index 77c422e55..3229c416b 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 @@ -39,6 +40,63 @@ Vito checks the credentials before it saves the connection, so a wrong or under- For each server, Vito creates an SSH key pair and a security group in the region you pick. +### AWS Lightsail + +AWS Lightsail uses a separate connection from the AWS (EC2) provider. An EC2 connection and the `AmazonEC2FullAccess` policy do not grant Lightsail access. + +#### 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` +- `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. + +#### 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: + +- **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. + +#### 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) 1. In Cloud Manager, open your profile menu and go to **API Tokens → Create a Personal Access Token**. diff --git a/public/api-docs/openapi/server-providers.yaml b/public/api-docs/openapi/server-providers.yaml index 0838f1caa..47b50200b 100644 --- a/public/api-docs/openapi/server-providers.yaml +++ b/public/api-docs/openapi/server-providers.yaml @@ -74,7 +74,24 @@ paths: required: - name - provider - - credentials + oneOf: + - properties: + provider: + enum: ['aws', 'lightsail'] + required: ['key', 'secret'] + - properties: + provider: + enum: ['hetzner', 'digitalocean', 'linode', 'vultr'] + required: ['token'] + - properties: + provider: + enum: ['proxmox'] + required: ['api_url', 'token_id', 'token_secret'] + anyOf: + - required: ['template_ubuntu_20'] + - required: ['template_ubuntu_22'] + - required: ['template_ubuntu_24'] + - required: ['template_ubuntu_26'] properties: name: type: string @@ -82,14 +99,54 @@ paths: example: 'DigitalOcean' provider: type: string - enum: ['aws', 'hetzner', 'digitalocean', 'linode', 'vultr', 'proxmox'] + enum: ['aws', 'lightsail', 'hetzner', 'digitalocean', 'linode', 'vultr', 'proxmox'] description: Server provider type example: 'digitalocean' - credentials: - type: object - description: 'Provider-specific credentials. Proxmox: `api_url` (https), `token_id` (`user@realm!name`), `token_secret`, `verify_ssl` (default true), optional `storage`, and at least one of `template_ubuntu_20`, `template_ubuntu_22`, `template_ubuntu_24`, `template_ubuntu_26` (cloud-init template VMIDs).' - 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) + api_url: + type: string + format: uri + description: Proxmox HTTPS API URL + token_id: + type: string + description: 'Proxmox API token ID (`user@realm!name`)' + token_secret: + type: string + writeOnly: true + description: Proxmox API token secret + verify_ssl: + type: boolean + default: true + storage: + type: string + description: Optional Proxmox disk storage + template_ubuntu_20: + type: integer + minimum: 100 + description: Proxmox Ubuntu 20.04 cloud-init template VMID (at least one template is required) + template_ubuntu_22: + type: integer + minimum: 100 + description: Proxmox Ubuntu 22.04 cloud-init template VMID (at least one template is required) + template_ubuntu_24: + type: integer + minimum: 100 + description: Proxmox Ubuntu 24.04 cloud-init template VMID (at least one template is required) + template_ubuntu_26: + type: integer + minimum: 100 + description: Proxmox Ubuntu 26.04 cloud-init template VMID (at least one template is required) 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 7fa6410fc..7f1dce3ba 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', 'proxmox'] + enum: ['custom', 'aws', 'lightsail', 'hetzner', 'digitalocean', 'linode', 'vultr', 'proxmox'] 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 cbe0944c9..2ef9f4484 100644 --- a/public/api-docs/openapi/user-server-providers.yaml +++ b/public/api-docs/openapi/user-server-providers.yaml @@ -50,7 +50,24 @@ paths: required: - name - provider - - credentials + oneOf: + - properties: + provider: + enum: ['aws', 'lightsail'] + required: ['key', 'secret'] + - properties: + provider: + enum: ['hetzner', 'digitalocean', 'linode', 'vultr'] + required: ['token'] + - properties: + provider: + enum: ['proxmox'] + required: ['api_url', 'token_id', 'token_secret'] + anyOf: + - required: ['template_ubuntu_20'] + - required: ['template_ubuntu_22'] + - required: ['template_ubuntu_24'] + - required: ['template_ubuntu_26'] properties: name: type: string @@ -58,14 +75,54 @@ paths: example: 'DigitalOcean' provider: type: string - enum: ['aws', 'hetzner', 'digitalocean', 'linode', 'vultr', 'proxmox'] + enum: ['aws', 'lightsail', 'hetzner', 'digitalocean', 'linode', 'vultr', 'proxmox'] description: Server provider type example: 'digitalocean' - credentials: - type: object - description: 'Provider-specific credentials. Proxmox: `api_url` (https), `token_id` (`user@realm!name`), `token_secret`, `verify_ssl` (default true), optional `storage`, and at least one of `template_ubuntu_20`, `template_ubuntu_22`, `template_ubuntu_24`, `template_ubuntu_26` (cloud-init template VMIDs).' - 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) + api_url: + type: string + format: uri + description: Proxmox HTTPS API URL + token_id: + type: string + description: 'Proxmox API token ID (`user@realm!name`)' + token_secret: + type: string + writeOnly: true + description: Proxmox API token secret + verify_ssl: + type: boolean + default: true + storage: + type: string + description: Optional Proxmox disk storage + template_ubuntu_20: + type: integer + minimum: 100 + description: Proxmox Ubuntu 20.04 cloud-init template VMID (at least one template is required) + template_ubuntu_22: + type: integer + minimum: 100 + description: Proxmox Ubuntu 22.04 cloud-init template VMID (at least one template is required) + template_ubuntu_24: + type: integer + minimum: 100 + description: Proxmox Ubuntu 24.04 cloud-init template VMID (at least one template is required) + template_ubuntu_26: + type: integer + minimum: 100 + description: Proxmox Ubuntu 26.04 cloud-init template VMID (at least one template is required) 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..5762e3127 --- /dev/null +++ b/tests/Feature/LightsailProviderTest.php @@ -0,0 +1,541 @@ +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_24', '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 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, 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, + 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($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') + ->and($create['ipAddressType'])->toBe('ipv4'); + 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 () { + $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 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.' (AccessDeniedException).') + ->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), + 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 : 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 permits only ssh before ufw installation', 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' => 22, 'toPort' => 22, 'protocol' => 'tcp', 'cidrs' => ['0.0.0.0/0'], + ]])->and(array_column($this->lightsailCommands, 'name'))->toBe(['GetInstance', 'PutInstancePublicPorts', 'GetInstance']); +}); + +test('lightsail opens its perimeter only after ufw succeeds and preserves it on later readiness checks', function () { + $this->server->jsonUpdate('provider_data', 'instance_name', 'vito-instance'); + $instance = ['state' => ['name' => 'running'], 'publicIpAddress' => '203.0.113.10']; + $this->lightsailHandler->append(new Result(['instance' => $instance]), new Result, new Result, new Result(['instance' => $instance])); + + $this->server->provider()->isRunning(); + $this->server->firewall()->handler()->install(); + $this->server->firewall()->handler()->install(); + + expect($this->server->fresh()->provider()->isRunning())->toBeTrue() + ->and($this->server->fresh()->provider_data['firewall_configured'])->toBeTrue() + ->and($this->lightsailCommands[2]['parameters']['portInfos'])->toBe([[ + 'fromPort' => 0, 'toPort' => 65535, 'protocol' => 'all', 'cidrs' => ['0.0.0.0/0'], + ]]) + ->and(array_column($this->lightsailCommands, 'name'))->toBe(['GetInstance', 'PutInstancePublicPorts', 'PutInstancePublicPorts', 'GetInstance']); + SSH::assertExecutedContains('sudo ufw --force enable'); +}); + +test('lightsail keeps ssh only when ufw is omitted or fails', function (bool $installFirewall) { + $this->server->jsonUpdate('provider_data', 'instance_name', 'vito-instance'); + $this->lightsailHandler->append(new Result(['instance' => [ + 'state' => ['name' => 'running'], 'publicIpAddress' => '203.0.113.10', + ]]), new Result); + $this->server->provider()->isRunning(); + + if ($installFirewall) { + SSH::fake()->execWillFail(); + expect(fn () => $this->server->firewall()->handler()->install())->toThrow(SSHCommandError::class); + } else { + $this->server->firewall()->delete(); + event('service.installed', $this->server->webserver()); + } + + expect($this->server->fresh()->provider_data['firewall_configured'] ?? false)->toBeFalse() + ->and(array_column($this->lightsailCommands, 'name'))->toBe(['GetInstance', 'PutInstancePublicPorts']) + ->and($this->lightsailCommands[1]['parameters']['portInfos'][0]['toPort'])->toBe(22); +})->with([true, false]); + +test('lightsail does not mark the firewall configured when aws rejects opening it', function () { + $this->server->jsonUpdate('provider_data', 'instance_name', 'vito-instance'); + $this->lightsailHandler->append(new AwsException('denied', new Command('PutInstancePublicPorts'), ['code' => 'AccessDeniedException'])); + + expect(fn () => $this->server->firewall()->handler()->install())->toThrow(ServerProviderError::class) + ->and($this->server->fresh()->provider_data['firewall_configured'] ?? false)->toBeFalse(); +}); + +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' => $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'); + $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 (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' => $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'); + $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 retains local recovery data until asynchronous deletion completes', function (string $operation, string $status) { + $this->server->jsonUpdate('provider_data', 'instance_name', 'vito-instance'); + $this->server->jsonUpdate('provider_data', 'ssh_key_name', 'vito-key'); + $keys = $this->server->sshKey(); + if ($operation === 'DeleteKeyPair') { + $this->lightsailHandler->append(new Result(['operations' => [['status' => 'Succeeded']]])); + } + $this->lightsailHandler->append(new Result(['operations' => [['status' => $status, 'isTerminal' => false]]])); + $input = ['name' => $this->server->name, 'delete_from_provider' => true]; + + expect(fn () => app(DeleteServer::class)->delete($this->server, $input)) + ->toThrow(ServerProviderError::class, 'deletion is still in progress'); + $this->assertDatabaseHas('servers', ['id' => $this->server->id]); + expect(file_exists($keys['private_key_path']))->toBeTrue() + ->and(file_exists($keys['public_key_path']))->toBeTrue(); + + $this->lightsailHandler->append( + new AwsException('gone', new Command('DeleteInstance'), ['code' => 'NotFoundException']), + new Result(['operation' => ['status' => 'Succeeded', 'isTerminal' => true]]), + ); + app(DeleteServer::class)->delete($this->server->fresh(), $input); + + $this->assertDatabaseMissing('servers', ['id' => $this->server->id]); + expect(file_exists($keys['private_key_path']))->toBeFalse() + ->and(file_exists($keys['public_key_path']))->toBeFalse(); +})->with(['DeleteInstance', 'DeleteKeyPair'])->with(['NotStarted', 'Started']); + +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]);