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');
+});