diff --git a/app/Actions/Server/DeleteServer.php b/app/Actions/Server/DeleteServer.php index bbfff7abc..3e4bc6e5d 100644 --- a/app/Actions/Server/DeleteServer.php +++ b/app/Actions/Server/DeleteServer.php @@ -35,7 +35,7 @@ public function handle(int $serverId, bool $deleteFromHetzner = false, ?int $het ); } - ray($server ? 'Deleting server from Coolify' : 'Server already deleted from Coolify, skipping Coolify deletion'); + logger()->debug($server ? 'Deleting server from Coolify' : 'Server already deleted from Coolify, skipping Coolify deletion'); // If server is already deleted from Coolify, skip this part if (! $server) { @@ -59,7 +59,10 @@ private function deleteFromHetznerById(int $hetznerServerId, ?int $cloudProvider $token = null; if ($cloudProviderTokenId) { - $token = CloudProviderToken::find($cloudProviderTokenId); + $token = CloudProviderToken::where('id', $cloudProviderTokenId) + ->where('team_id', $teamId) + ->where('provider', 'hetzner') + ->first(); } if (! $token) { @@ -97,7 +100,10 @@ private function deleteFromVultrById(string $vultrInstanceId, ?int $cloudProvide $token = null; if ($cloudProviderTokenId) { - $token = CloudProviderToken::find($cloudProviderTokenId); + $token = CloudProviderToken::where('id', $cloudProviderTokenId) + ->where('team_id', $teamId) + ->where('provider', 'vultr') + ->first(); } if (! $token) { @@ -107,7 +113,7 @@ private function deleteFromVultrById(string $vultrInstanceId, ?int $cloudProvide } if (! $token) { - ray('No Vultr token found for team, skipping Vultr deletion', [ + logger()->debug('No Vultr token found for team, skipping Vultr deletion', [ 'team_id' => $teamId, 'vultr_instance_id' => $vultrInstanceId, ]); @@ -118,12 +124,12 @@ private function deleteFromVultrById(string $vultrInstanceId, ?int $cloudProvide $vultrService = new VultrService($token->token); $vultrService->deleteInstance($vultrInstanceId); - ray('Deleted server from Vultr', [ + logger()->debug('Deleted server from Vultr', [ 'vultr_instance_id' => $vultrInstanceId, 'team_id' => $teamId, ]); } catch (\Throwable $e) { - ray('Failed to delete server from Vultr', [ + logger()->error('Failed to delete server from Vultr', [ 'error' => $e->getMessage(), 'vultr_instance_id' => $vultrInstanceId, 'team_id' => $teamId, diff --git a/app/Http/Controllers/Api/VultrController.php b/app/Http/Controllers/Api/VultrController.php index 2ea46041c..7aaba568e 100644 --- a/app/Http/Controllers/Api/VultrController.php +++ b/app/Http/Controllers/Api/VultrController.php @@ -15,6 +15,7 @@ use App\Services\VultrService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use OpenApi\Attributes as OA; class VultrController extends Controller { @@ -54,7 +55,22 @@ private function getVultrToken(Request $request): CloudProviderToken|JsonRespons return $token; } - public function regions(Request $request) + #[OA\Get( + summary: 'Get Vultr Regions', + description: 'Get all available Vultr regions.', + path: '/vultr/regions', + operationId: 'get-vultr-regions', + security: [ + ['bearerAuth' => []], + ], + tags: ['Vultr'], + responses: [ + new OA\Response(response: 200, description: 'List of Vultr regions.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ] + )] + public function regions(Request $request): JsonResponse { $token = $this->getVultrToken($request); if ($token instanceof JsonResponse) { @@ -68,7 +84,22 @@ public function regions(Request $request) } } - public function plans(Request $request) + #[OA\Get( + summary: 'Get Vultr Plans', + description: 'Get all available Vultr plans.', + path: '/vultr/plans', + operationId: 'get-vultr-plans', + security: [ + ['bearerAuth' => []], + ], + tags: ['Vultr'], + responses: [ + new OA\Response(response: 200, description: 'List of Vultr plans.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ] + )] + public function plans(Request $request): JsonResponse { $token = $this->getVultrToken($request); if ($token instanceof JsonResponse) { @@ -82,7 +113,22 @@ public function plans(Request $request) } } - public function operatingSystems(Request $request) + #[OA\Get( + summary: 'Get Vultr Operating Systems', + description: 'Get all available Vultr operating systems.', + path: '/vultr/os', + operationId: 'get-vultr-operating-systems', + security: [ + ['bearerAuth' => []], + ], + tags: ['Vultr'], + responses: [ + new OA\Response(response: 200, description: 'List of Vultr operating systems.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ] + )] + public function operatingSystems(Request $request): JsonResponse { $token = $this->getVultrToken($request); if ($token instanceof JsonResponse) { @@ -96,7 +142,22 @@ public function operatingSystems(Request $request) } } - public function sshKeys(Request $request) + #[OA\Get( + summary: 'Get Vultr SSH Keys', + description: 'Get all Vultr SSH keys available to the selected token.', + path: '/vultr/ssh-keys', + operationId: 'get-vultr-ssh-keys', + security: [ + ['bearerAuth' => []], + ], + tags: ['Vultr'], + responses: [ + new OA\Response(response: 200, description: 'List of Vultr SSH keys.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ] + )] + public function sshKeys(Request $request): JsonResponse { $token = $this->getVultrToken($request); if ($token instanceof JsonResponse) { @@ -110,7 +171,23 @@ public function sshKeys(Request $request) } } - public function createServer(Request $request) + #[OA\Post( + summary: 'Create Vultr Server', + description: 'Create a Vultr instance and link it as a Coolify server.', + path: '/servers/vultr', + operationId: 'create-vultr-server', + security: [ + ['bearerAuth' => []], + ], + tags: ['Vultr'], + responses: [ + new OA\Response(response: 201, description: 'Vultr server created.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 422, description: 'Validation failed.'), + new OA\Response(response: 429, description: 'Vultr API rate limit exceeded.'), + ] + )] + public function createServer(Request $request): JsonResponse { $allowedFields = [ 'cloud_provider_token_uuid', @@ -187,6 +264,10 @@ public function createServer(Request $request) $request->offsetSet('instant_validate', false); } + if ($request->disable_public_ipv4 && ! $request->enable_ipv6) { + return $this->networkConfigurationErrorResponse(); + } + $token = CloudProviderToken::whereTeamId($teamId) ->whereUuid($this->getCloudProviderTokenUuid($request)) ->where('provider', 'vultr') @@ -308,4 +389,14 @@ private function normalizePublicKey(string $publicKey): string return implode(' ', array_slice($parts ?: [], 0, 2)); } + + private function networkConfigurationErrorResponse(): JsonResponse + { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [ + 'enable_ipv6' => ['Enable IPv6 when disabling public IPv4.'], + ], + ], 422); + } } diff --git a/app/Livewire/Server/New/ByVultr.php b/app/Livewire/Server/New/ByVultr.php index 148968e6a..bc0b936d1 100644 --- a/app/Livewire/Server/New/ByVultr.php +++ b/app/Livewire/Server/New/ByVultr.php @@ -330,6 +330,9 @@ private function createVultrServer(string $token): array public function submit(): mixed { $this->validate(); + if (! $this->hasValidPublicNetworkConfiguration()) { + return null; + } try { $this->authorize('create', Server::class); @@ -414,4 +417,15 @@ private function normalizePublicKey(string $publicKey): string return implode(' ', array_slice($parts ?: [], 0, 2)); } + + private function hasValidPublicNetworkConfiguration(): bool + { + if (! $this->disable_public_ipv4 || $this->enable_ipv6) { + return true; + } + + $this->addError('enable_ipv6', 'Enable IPv6 when disabling public IPv4.'); + + return false; + } } diff --git a/app/Services/VultrService.php b/app/Services/VultrService.php index 6e1559f98..0e335d3e0 100644 --- a/app/Services/VultrService.php +++ b/app/Services/VultrService.php @@ -153,19 +153,19 @@ private function isUsableIp(?string $ip): bool public function getInstance(string $instanceId): array { - $response = $this->request('get', "/instances/{$instanceId}"); + $response = $this->request('get', $this->instanceEndpoint($instanceId)); return $response['instance'] ?? []; } public function startInstance(string $instanceId): array { - return $this->request('post', "/instances/{$instanceId}/start"); + return $this->request('post', $this->instanceEndpoint($instanceId).'/start'); } public function deleteInstance(string $instanceId): void { - $this->request('delete', "/instances/{$instanceId}"); + $this->request('delete', $this->instanceEndpoint($instanceId)); } public function getInstances(): array @@ -183,4 +183,9 @@ public function findInstanceByIp(string $ip): ?array return null; } + + private function instanceEndpoint(string $instanceId): string + { + return '/instances/'.rawurlencode($instanceId); + } } diff --git a/openapi.json b/openapi.json index 6e49729ce..c7aea585b 100644 --- a/openapi.json +++ b/openapi.json @@ -4040,7 +4040,8 @@ "type": "string", "enum": [ "hetzner", - "digitalocean" + "digitalocean", + "vultr" ] }, "team_id": { @@ -4098,7 +4099,8 @@ "type": "string", "enum": [ "hetzner", - "digitalocean" + "digitalocean", + "vultr" ], "example": "hetzner", "description": "The cloud provider." @@ -14156,6 +14158,139 @@ } ] } + }, + "\/vultr\/regions": { + "get": { + "tags": [ + "Vultr" + ], + "summary": "Get Vultr Regions", + "description": "Get all available Vultr regions.", + "operationId": "get-vultr-regions", + "responses": { + "200": { + "description": "List of Vultr regions." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/vultr\/plans": { + "get": { + "tags": [ + "Vultr" + ], + "summary": "Get Vultr Plans", + "description": "Get all available Vultr plans.", + "operationId": "get-vultr-plans", + "responses": { + "200": { + "description": "List of Vultr plans." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/vultr\/os": { + "get": { + "tags": [ + "Vultr" + ], + "summary": "Get Vultr Operating Systems", + "description": "Get all available Vultr operating systems.", + "operationId": "get-vultr-operating-systems", + "responses": { + "200": { + "description": "List of Vultr operating systems." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/vultr\/ssh-keys": { + "get": { + "tags": [ + "Vultr" + ], + "summary": "Get Vultr SSH Keys", + "description": "Get all Vultr SSH keys available to the selected token.", + "operationId": "get-vultr-ssh-keys", + "responses": { + "200": { + "description": "List of Vultr SSH keys." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/servers\/vultr": { + "post": { + "tags": [ + "Vultr" + ], + "summary": "Create Vultr Server", + "description": "Create a Vultr instance and link it as a Coolify server.", + "operationId": "create-vultr-server", + "responses": { + "201": { + "description": "Vultr server created." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "422": { + "description": "Validation failed." + }, + "429": { + "description": "Vultr API rate limit exceeded." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } } }, "components": { @@ -15458,6 +15593,10 @@ { "name": "Teams", "description": "Teams" + }, + { + "name": "Vultr", + "description": "Vultr" } ] } diff --git a/openapi.yaml b/openapi.yaml index dd97873a2..8df2a7576 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2577,7 +2577,7 @@ paths: schema: type: array items: - properties: { uuid: { type: string }, name: { type: string }, provider: { type: string, enum: [hetzner, digitalocean] }, team_id: { type: integer }, servers_count: { type: integer }, created_at: { type: string }, updated_at: { type: string } } + properties: { uuid: { type: string }, name: { type: string }, provider: { type: string, enum: [hetzner, digitalocean, vultr] }, team_id: { type: integer }, servers_count: { type: integer }, created_at: { type: string }, updated_at: { type: string } } type: object '401': $ref: '#/components/responses/401' @@ -2605,7 +2605,7 @@ paths: properties: provider: type: string - enum: [hetzner, digitalocean] + enum: [hetzner, digitalocean, vultr] example: hetzner description: 'The cloud provider.' token: @@ -8984,6 +8984,93 @@ paths: security: - bearerAuth: [] + /vultr/regions: + get: + tags: + - Vultr + summary: 'Get Vultr Regions' + description: 'Get all available Vultr regions.' + operationId: get-vultr-regions + responses: + '200': + description: 'List of Vultr regions.' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + /vultr/plans: + get: + tags: + - Vultr + summary: 'Get Vultr Plans' + description: 'Get all available Vultr plans.' + operationId: get-vultr-plans + responses: + '200': + description: 'List of Vultr plans.' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + /vultr/os: + get: + tags: + - Vultr + summary: 'Get Vultr Operating Systems' + description: 'Get all available Vultr operating systems.' + operationId: get-vultr-operating-systems + responses: + '200': + description: 'List of Vultr operating systems.' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + /vultr/ssh-keys: + get: + tags: + - Vultr + summary: 'Get Vultr SSH Keys' + description: 'Get all Vultr SSH keys available to the selected token.' + operationId: get-vultr-ssh-keys + responses: + '200': + description: 'List of Vultr SSH keys.' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + /servers/vultr: + post: + tags: + - Vultr + summary: 'Create Vultr Server' + description: 'Create a Vultr instance and link it as a Coolify server.' + operationId: create-vultr-server + responses: + '201': + description: 'Vultr server created.' + '401': + $ref: '#/components/responses/401' + '422': + description: 'Validation failed.' + '429': + description: 'Vultr API rate limit exceeded.' + security: + - + bearerAuth: [] components: schemas: Application: @@ -9925,3 +10012,6 @@ tags: - name: Teams description: Teams + - + name: Vultr + description: Vultr diff --git a/resources/views/livewire/security/cloud-provider-token-form.blade.php b/resources/views/livewire/security/cloud-provider-token-form.blade.php index 56c53d869..fed391463 100644 --- a/resources/views/livewire/security/cloud-provider-token-form.blade.php +++ b/resources/views/livewire/security/cloud-provider-token-form.blade.php @@ -33,7 +33,7 @@ class='underline dark:text-white'>Sign up here @if ($provider === 'vultr') Open Account → API Access.

- Don't have a Vultr account? Sign up here
Coolify's affiliate link, only new accounts - supports us @@ -74,7 +74,7 @@ class='underline dark:text-white'>Sign up here @if ($provider === 'vultr') Open Account → API Access.

- Don't have a Vultr account? Sign up here
Coolify's affiliate link, only new accounts - supports us diff --git a/tests/Feature/VultrApiTest.php b/tests/Feature/VultrApiTest.php index 788f3bfb5..8a96ec685 100644 --- a/tests/Feature/VultrApiTest.php +++ b/tests/Feature/VultrApiTest.php @@ -371,4 +371,23 @@ function testPublicKey(): string $response->assertStatus(201); $response->assertJsonFragment(['ip' => '2001:db8::1']); }); + + test('requires IPv6 when public IPv4 is disabled', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/servers/vultr', [ + 'cloud_provider_token_id' => $this->vultrToken->uuid, + 'region' => 'ewr', + 'plan' => 'vc2-1c-1gb', + 'os_id' => 2284, + 'name' => 'test-server', + 'private_key_uuid' => $this->privateKey->uuid, + 'enable_ipv6' => false, + 'disable_public_ipv4' => true, + ]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['enable_ipv6']); + }); }); diff --git a/tests/Feature/VultrServerCreationTest.php b/tests/Feature/VultrServerCreationTest.php index 6f8166ee3..5dee12ca2 100644 --- a/tests/Feature/VultrServerCreationTest.php +++ b/tests/Feature/VultrServerCreationTest.php @@ -133,3 +133,18 @@ function vultrLivewireTestPublicKey(): string && $request['user_data'] === base64_encode("#cloud-config\npackages:\n - curl"); }); }); + +it('requires IPv6 when public IPv4 is disabled', function () { + Livewire::test(ByVultr::class) + ->set('selected_token_id', $this->vultrToken->id) + ->set('current_step', 2) + ->set('server_name', 'test-vultr-server') + ->set('selected_region', 'ewr') + ->set('selected_plan', 'vc2-1c-1gb') + ->set('selected_os_id', 2284) + ->set('private_key_id', $this->privateKey->id) + ->set('enable_ipv6', false) + ->set('disable_public_ipv4', true) + ->call('submit') + ->assertHasErrors(['enable_ipv6']); +}); diff --git a/tests/Unit/VultrDeleteServerTest.php b/tests/Unit/VultrDeleteServerTest.php index cda628d16..05891eed7 100644 --- a/tests/Unit/VultrDeleteServerTest.php +++ b/tests/Unit/VultrDeleteServerTest.php @@ -81,3 +81,38 @@ function vultrDeleteTestPrivateKey(): string expect(Server::withTrashed()->find($server->id))->toBeNull(); }); + +it('does not use another team Vultr token when deleting an instance', function () { + Http::fake([ + 'https://api.vultr.com/v2/instances/instance-1' => Http::response([], 204), + ]); + + $otherTeam = Team::factory()->create(); + $otherToken = CloudProviderToken::create([ + 'team_id' => $otherTeam->id, + 'provider' => 'vultr', + 'token' => 'other-team-vultr-token', + 'name' => 'Other Team Vultr Token', + ]); + + $server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $this->privateKey->id, + 'cloud_provider_token_id' => $this->vultrToken->id, + 'vultr_instance_id' => 'instance-1', + ]); + + $server->delete(); + + DeleteServer::run( + serverId: $server->id, + cloudProviderTokenId: $otherToken->id, + teamId: $this->team->id, + deleteFromVultr: true, + vultrInstanceId: 'instance-1' + ); + + $request = Http::recorded()->first()[0]; + + expect($request->header('Authorization'))->toBe(['Bearer test-vultr-token']); +}); diff --git a/tests/Unit/VultrServiceTest.php b/tests/Unit/VultrServiceTest.php index 67f13f5a3..a912b8907 100644 --- a/tests/Unit/VultrServiceTest.php +++ b/tests/Unit/VultrServiceTest.php @@ -150,3 +150,17 @@ Http::assertSentCount(2); }); + +it('encodes instance IDs in request paths', function () { + Http::fake([ + 'https://api.vultr.com/v2/instances/instance%2F1' => Http::response([ + 'instance' => ['id' => 'instance/1'], + ], 200), + ]); + + $service = new VultrService('fake-token'); + + expect($service->getInstance('instance/1')['id'])->toBe('instance/1'); + + Http::assertSent(fn ($request) => $request->url() === 'https://api.vultr.com/v2/instances/instance%2F1'); +});