From 94abbe15901927a1cb4e4fadc5ae98a3f1678d9f Mon Sep 17 00:00:00 2001 From: Cornelis Terblanche Date: Wed, 3 Jun 2026 20:17:05 +0200 Subject: [PATCH 1/6] Add Vultr cloud provider integration --- app/Actions/Server/DeleteServer.php | 57 ++- app/Actions/Server/ValidateServer.php | 13 + .../Api/CloudProviderTokensController.php | 9 +- .../Controllers/Api/ServersController.php | 4 +- app/Http/Controllers/Api/VultrController.php | 311 +++++++++++++ app/Jobs/ServerConnectionCheckJob.php | 19 + .../Security/CloudProviderTokenForm.php | 20 +- app/Livewire/Security/CloudProviderTokens.php | 25 +- .../Server/CloudProviderToken/Show.php | 51 ++- app/Livewire/Server/Delete.php | 15 +- app/Livewire/Server/New/ByVultr.php | 417 ++++++++++++++++++ app/Livewire/Server/Show.php | 211 ++++++++- app/Livewire/Server/ValidateAndInstall.php | 16 + app/Models/CloudProviderToken.php | 4 + app/Models/Server.php | 49 ++ app/Services/VultrService.php | 186 ++++++++ .../factories/CloudProviderTokenFactory.php | 28 ++ ...vultr_instance_fields_to_servers_table.php | 44 ++ .../views/components/server/sidebar.blade.php | 4 +- .../views/livewire/boarding/index.blade.php | 26 +- .../cloud-provider-token-form.blade.php | 4 +- .../cloud-provider-token/show.blade.php | 20 +- .../views/livewire/server/create.blade.php | 23 + .../livewire/server/new/by-vultr.blade.php | 192 ++++++++ .../views/livewire/server/show.blade.php | 150 ++++++- routes/api.php | 7 + tests/Feature/CloudProviderTokenApiTest.php | 60 ++- tests/Feature/VultrApiTest.php | 374 ++++++++++++++++ tests/Feature/VultrServerCreationTest.php | 135 ++++++ tests/Feature/VultrServerLifecycleTest.php | 296 +++++++++++++ tests/Unit/ServerBackoffTest.php | 31 ++ tests/Unit/VultrDeleteServerTest.php | 83 ++++ tests/Unit/VultrServiceTest.php | 138 ++++++ 33 files changed, 2978 insertions(+), 44 deletions(-) create mode 100644 app/Http/Controllers/Api/VultrController.php create mode 100644 app/Livewire/Server/New/ByVultr.php create mode 100644 app/Services/VultrService.php create mode 100644 database/factories/CloudProviderTokenFactory.php create mode 100644 database/migrations/2026_06_01_210459_add_vultr_instance_fields_to_servers_table.php create mode 100644 resources/views/livewire/server/new/by-vultr.blade.php create mode 100644 tests/Feature/VultrApiTest.php create mode 100644 tests/Feature/VultrServerCreationTest.php create mode 100644 tests/Feature/VultrServerLifecycleTest.php create mode 100644 tests/Unit/VultrDeleteServerTest.php create mode 100644 tests/Unit/VultrServiceTest.php diff --git a/app/Actions/Server/DeleteServer.php b/app/Actions/Server/DeleteServer.php index 45ec68abc..601e653cb 100644 --- a/app/Actions/Server/DeleteServer.php +++ b/app/Actions/Server/DeleteServer.php @@ -7,13 +7,14 @@ use App\Models\Team; use App\Notifications\Server\HetznerDeletionFailed; use App\Services\HetznerService; +use App\Services\VultrService; use Lorisleiva\Actions\Concerns\AsAction; class DeleteServer { use AsAction; - public function handle(int $serverId, bool $deleteFromHetzner = false, ?int $hetznerServerId = null, ?int $cloudProviderTokenId = null, ?int $teamId = null) + public function handle(int $serverId, bool $deleteFromHetzner = false, ?int $hetznerServerId = null, ?int $cloudProviderTokenId = null, ?int $teamId = null, bool $deleteFromVultr = false, ?string $vultrInstanceId = null) { $server = Server::withTrashed()->find($serverId); @@ -26,6 +27,14 @@ public function handle(int $serverId, bool $deleteFromHetzner = false, ?int $het ); } + if ($deleteFromVultr && ($vultrInstanceId || ($server && $server->vultr_instance_id))) { + $this->deleteFromVultrById( + $vultrInstanceId ?? $server->vultr_instance_id, + $cloudProviderTokenId ?? $server->cloud_provider_token_id, + $teamId ?? $server->team_id + ); + } + ray($server ? 'Deleting server from Coolify' : 'Server already deleted from Coolify, skipping Coolify deletion'); // If server is already deleted from Coolify, skip this part @@ -100,4 +109,50 @@ private function deleteFromHetznerById(int $hetznerServerId, ?int $cloudProvider $team?->notify(new HetznerDeletionFailed($hetznerServerId, $teamId, $e->getMessage())); } } + + private function deleteFromVultrById(string $vultrInstanceId, ?int $cloudProviderTokenId, int $teamId): void + { + try { + $token = null; + + if ($cloudProviderTokenId) { + $token = CloudProviderToken::find($cloudProviderTokenId); + } + + if (! $token) { + $token = CloudProviderToken::where('team_id', $teamId) + ->where('provider', 'vultr') + ->first(); + } + + if (! $token) { + ray('No Vultr token found for team, skipping Vultr deletion', [ + 'team_id' => $teamId, + 'vultr_instance_id' => $vultrInstanceId, + ]); + + return; + } + + $vultrService = new VultrService($token->token); + $vultrService->deleteInstance($vultrInstanceId); + + ray('Deleted server from Vultr', [ + 'vultr_instance_id' => $vultrInstanceId, + 'team_id' => $teamId, + ]); + } catch (\Throwable $e) { + ray('Failed to delete server from Vultr', [ + 'error' => $e->getMessage(), + 'vultr_instance_id' => $vultrInstanceId, + 'team_id' => $teamId, + ]); + + logger()->error('Failed to delete server from Vultr', [ + 'error' => $e->getMessage(), + 'vultr_instance_id' => $vultrInstanceId, + 'team_id' => $teamId, + ]); + } + } } diff --git a/app/Actions/Server/ValidateServer.php b/app/Actions/Server/ValidateServer.php index 22c48aa89..9064c725c 100644 --- a/app/Actions/Server/ValidateServer.php +++ b/app/Actions/Server/ValidateServer.php @@ -28,6 +28,19 @@ public function handle(Server $server) $server->update([ 'validation_logs' => null, ]); + if ($server->vultr_instance_id) { + $status = $server->refreshVultrState(); + if (in_array($status, ['stopped', 'suspended', 'deleted'], true)) { + $this->error = $status === 'deleted' + ? 'Vultr instance is deleted or no longer accessible. Relink this server before validating.' + : 'Vultr instance is '.($status ?? 'not running').'. Power it on before validating.'; + $server->update([ + 'validation_logs' => $this->error, + ]); + throw new \Exception($this->error); + } + } + ['uptime' => $this->uptime, 'error' => $error] = $server->validateConnection(); if (! $this->uptime) { $sanitizedError = htmlspecialchars($error ?? '', ENT_QUOTES, 'UTF-8'); diff --git a/app/Http/Controllers/Api/CloudProviderTokensController.php b/app/Http/Controllers/Api/CloudProviderTokensController.php index d652f2ba1..712a67af0 100644 --- a/app/Http/Controllers/Api/CloudProviderTokensController.php +++ b/app/Http/Controllers/Api/CloudProviderTokensController.php @@ -37,6 +37,9 @@ private function validateProviderToken(string $provider, string $token): array 'digitalocean' => Http::withHeaders([ 'Authorization' => 'Bearer '.$token, ])->timeout(10)->get('https://api.digitalocean.com/v2/account'), + 'vultr' => Http::withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->timeout(10)->get('https://api.vultr.com/v2/account'), default => null, }; @@ -82,7 +85,7 @@ private function validateProviderToken(string $provider, string $token): array properties: [ 'uuid' => ['type' => 'string'], 'name' => ['type' => 'string'], - 'provider' => ['type' => 'string', 'enum' => ['hetzner', 'digitalocean']], + 'provider' => ['type' => 'string', 'enum' => ['hetzner', 'digitalocean', 'vultr']], 'team_id' => ['type' => 'integer'], 'servers_count' => ['type' => 'integer'], 'created_at' => ['type' => 'string'], @@ -199,7 +202,7 @@ public function show(Request $request) type: 'object', required: ['provider', 'token', 'name'], properties: [ - 'provider' => ['type' => 'string', 'enum' => ['hetzner', 'digitalocean'], 'example' => 'hetzner', 'description' => 'The cloud provider.'], + 'provider' => ['type' => 'string', 'enum' => ['hetzner', 'digitalocean', 'vultr'], 'example' => 'hetzner', 'description' => 'The cloud provider.'], 'token' => ['type' => 'string', 'example' => 'your-api-token-here', 'description' => 'The API token for the cloud provider.'], 'name' => ['type' => 'string', 'example' => 'My Hetzner Token', 'description' => 'A friendly name for the token.'], ], @@ -253,7 +256,7 @@ public function store(Request $request) $body = $request->json()->all(); $validator = customApiValidator($body, [ - 'provider' => 'required|string|in:hetzner,digitalocean', + 'provider' => 'required|string|in:hetzner,digitalocean,vultr', 'token' => 'required|string', 'name' => 'required|string|max:255', ]); diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php index e43026a72..6c8fbfed7 100644 --- a/app/Http/Controllers/Api/ServersController.php +++ b/app/Http/Controllers/Api/ServersController.php @@ -850,7 +850,9 @@ public function delete_server(Request $request) false, // Don't delete from Hetzner via API $server->hetzner_server_id, $server->cloud_provider_token_id, - $server->team_id + $server->team_id, + false, // Don't delete from Vultr via API + $server->vultr_instance_id ); auditLog('api.server.deleted', [ diff --git a/app/Http/Controllers/Api/VultrController.php b/app/Http/Controllers/Api/VultrController.php new file mode 100644 index 000000000..2ea46041c --- /dev/null +++ b/app/Http/Controllers/Api/VultrController.php @@ -0,0 +1,311 @@ +cloud_provider_token_uuid ?? $request->cloud_provider_token_id; + } + + private function getVultrToken(Request $request): CloudProviderToken|JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $validator = customApiValidator($request->all(), [ + 'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string', + 'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string', + ]); + + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $validator->errors(), + ], 422); + } + + $token = CloudProviderToken::whereTeamId($teamId) + ->whereUuid($this->getCloudProviderTokenUuid($request)) + ->where('provider', 'vultr') + ->first(); + + if (! $token) { + return response()->json(['message' => 'Vultr cloud provider token not found.'], 404); + } + + return $token; + } + + public function regions(Request $request) + { + $token = $this->getVultrToken($request); + if ($token instanceof JsonResponse) { + return $token; + } + + try { + return response()->json((new VultrService($token->token))->getRegions()); + } catch (\Throwable) { + return response()->json(['message' => 'Failed to fetch Vultr regions.'], 500); + } + } + + public function plans(Request $request) + { + $token = $this->getVultrToken($request); + if ($token instanceof JsonResponse) { + return $token; + } + + try { + return response()->json((new VultrService($token->token))->getPlans()); + } catch (\Throwable) { + return response()->json(['message' => 'Failed to fetch Vultr plans.'], 500); + } + } + + public function operatingSystems(Request $request) + { + $token = $this->getVultrToken($request); + if ($token instanceof JsonResponse) { + return $token; + } + + try { + return response()->json((new VultrService($token->token))->getOperatingSystems()); + } catch (\Throwable) { + return response()->json(['message' => 'Failed to fetch Vultr operating systems.'], 500); + } + } + + public function sshKeys(Request $request) + { + $token = $this->getVultrToken($request); + if ($token instanceof JsonResponse) { + return $token; + } + + try { + return response()->json((new VultrService($token->token))->getSshKeys()); + } catch (\Throwable) { + return response()->json(['message' => 'Failed to fetch Vultr SSH keys.'], 500); + } + } + + public function createServer(Request $request) + { + $allowedFields = [ + 'cloud_provider_token_uuid', + 'cloud_provider_token_id', + 'region', + 'plan', + 'os_id', + 'name', + 'private_key_uuid', + 'enable_ipv6', + 'disable_public_ipv4', + 'vultr_ssh_key_ids', + 'cloud_init_script', + 'instant_validate', + ]; + + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + + $validator = customApiValidator($request->all(), [ + 'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string', + 'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string', + 'region' => 'required|string', + 'plan' => 'required|string', + 'os_id' => 'required|integer', + 'name' => ['nullable', 'string', 'max:253', new ValidHostname], + 'private_key_uuid' => 'required|string', + 'enable_ipv6' => 'nullable|boolean', + 'disable_public_ipv4' => 'nullable|boolean', + 'vultr_ssh_key_ids' => 'nullable|array', + 'vultr_ssh_key_ids.*' => 'string', + 'cloud_init_script' => ['nullable', 'string', new ValidCloudInitYaml], + 'instant_validate' => 'nullable|boolean', + ]); + + $extraFields = array_diff(array_keys($request->all()), $allowedFields); + if ($validator->fails() || ! empty($extraFields)) { + $errors = $validator->errors(); + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); + } + + $team = Team::find($teamId); + if (Team::serverLimitReached($team)) { + return response()->json(['message' => 'Server limit reached for your subscription.'], 400); + } + + if (! $request->name) { + $request->offsetSet('name', generate_random_name()); + } + if (is_null($request->enable_ipv6)) { + $request->offsetSet('enable_ipv6', true); + } + if (is_null($request->disable_public_ipv4)) { + $request->offsetSet('disable_public_ipv4', false); + } + if (is_null($request->vultr_ssh_key_ids)) { + $request->offsetSet('vultr_ssh_key_ids', []); + } + if (is_null($request->instant_validate)) { + $request->offsetSet('instant_validate', false); + } + + $token = CloudProviderToken::whereTeamId($teamId) + ->whereUuid($this->getCloudProviderTokenUuid($request)) + ->where('provider', 'vultr') + ->first(); + + if (! $token) { + return response()->json(['message' => 'Vultr cloud provider token not found.'], 404); + } + + $privateKey = PrivateKey::whereTeamId($teamId)->whereUuid($request->private_key_uuid)->first(); + if (! $privateKey) { + return response()->json(['message' => 'Private key not found.'], 404); + } + + try { + $vultrService = new VultrService($token->token); + $publicKey = $privateKey->getPublicKey(); + $existingKey = $this->findMatchingSshKey($vultrService->getSshKeys(), $publicKey); + + if ($existingKey) { + $sshKeyId = $existingKey['id']; + } else { + $uploadedKey = $vultrService->uploadSshKey($privateKey->name, $publicKey); + $sshKeyId = $uploadedKey['id']; + } + + $normalizedServerName = strtolower(trim($request->name)); + $sshKeys = array_values(array_unique(array_merge([$sshKeyId], $request->vultr_ssh_key_ids))); + + $params = [ + 'region' => $request->region, + 'plan' => $request->plan, + 'os_id' => $request->os_id, + 'label' => $normalizedServerName, + 'hostname' => $normalizedServerName, + 'sshkey_id' => $sshKeys, + 'enable_ipv6' => $request->enable_ipv6, + 'disable_public_ipv4' => $request->disable_public_ipv4, + ]; + + if (! empty($request->cloud_init_script)) { + $params['user_data'] = $request->cloud_init_script; + } + + $vultrInstance = $vultrService->createInstance($params); + $ipAddress = $vultrService->getPublicIp($vultrInstance, $request->disable_public_ipv4, $request->enable_ipv6) ?? '0.0.0.0'; + + $server = Server::create([ + 'name' => $normalizedServerName, + 'ip' => $ipAddress, + 'user' => 'root', + 'port' => 22, + 'team_id' => $teamId, + 'private_key_id' => $privateKey->id, + 'cloud_provider_token_id' => $token->id, + 'vultr_instance_id' => $vultrInstance['id'], + 'vultr_instance_status' => $vultrInstance['status'] ?? null, + ]); + + $vultrInstance = $vultrService->waitForPublicIp($vultrInstance, $request->disable_public_ipv4, $request->enable_ipv6); + $assignedIpAddress = $vultrService->getPublicIp($vultrInstance, $request->disable_public_ipv4, $request->enable_ipv6); + if ($assignedIpAddress && $assignedIpAddress !== $server->ip) { + $ipAddress = $assignedIpAddress; + $server->update([ + 'ip' => $assignedIpAddress, + 'vultr_instance_status' => $vultrInstance['status'] ?? $server->vultr_instance_status, + ]); + } + + $server->proxy->set('status', 'exited'); + $server->proxy->set('type', ProxyTypes::TRAEFIK->value); + $server->save(); + + if ($request->instant_validate) { + ValidateServer::dispatch($server); + } + + auditLog('api.vultr_server.created', [ + 'team_id' => $teamId, + 'server_uuid' => $server->uuid, + 'server_name' => $server->name, + 'vultr_instance_id' => $vultrInstance['id'], + 'ip' => $ipAddress, + ]); + + return response()->json([ + 'uuid' => $server->uuid, + 'vultr_instance_id' => $vultrInstance['id'], + 'ip' => $ipAddress, + ])->setStatusCode(201); + } catch (RateLimitException $e) { + $response = response()->json(['message' => $e->getMessage()], 429); + if ($e->retryAfter !== null) { + $response->header('Retry-After', $e->retryAfter); + } + + return $response; + } catch (\Throwable) { + return response()->json(['message' => 'Failed to create Vultr server.'], 500); + } + } + + private function findMatchingSshKey(array $sshKeys, string $publicKey): ?array + { + $normalizedPublicKey = $this->normalizePublicKey($publicKey); + + foreach ($sshKeys as $sshKey) { + if ($this->normalizePublicKey($sshKey['ssh_key'] ?? '') === $normalizedPublicKey) { + return $sshKey; + } + } + + return null; + } + + private function normalizePublicKey(string $publicKey): string + { + $parts = preg_split('/\s+/', trim($publicKey)); + + return implode(' ', array_slice($parts ?: [], 0, 2)); + } +} diff --git a/app/Jobs/ServerConnectionCheckJob.php b/app/Jobs/ServerConnectionCheckJob.php index 98ad60fff..176cca96c 100644 --- a/app/Jobs/ServerConnectionCheckJob.php +++ b/app/Jobs/ServerConnectionCheckJob.php @@ -67,6 +67,10 @@ public function handle() $this->checkHetznerStatus(); } + if ($this->server->vultr_instance_id && $this->server->cloudProviderToken) { + $this->checkVultrStatus(); + } + // Temporarily disable mux if requested if ($this->disableMux) { $this->disableSshMux(); @@ -186,6 +190,21 @@ private function checkHetznerStatus(): void } + private function checkVultrStatus(): void + { + try { + $status = $this->server->refreshVultrState(); + } catch (\Throwable) { + // Silently ignore transient Vultr API errors. + + return; + } + + if (in_array($status, ['stopped', 'suspended', 'deleted'], true)) { + throw new \Exception('Vultr instance is not running'); + } + } + private function checkConnection(): bool { try { diff --git a/app/Livewire/Security/CloudProviderTokenForm.php b/app/Livewire/Security/CloudProviderTokenForm.php index 7affb1531..5cf004d40 100644 --- a/app/Livewire/Security/CloudProviderTokenForm.php +++ b/app/Livewire/Security/CloudProviderTokenForm.php @@ -27,7 +27,7 @@ public function mount() protected function rules(): array { return [ - 'provider' => 'required|string|in:hetzner,digitalocean', + 'provider' => 'required|string|in:hetzner,digitalocean,vultr', 'token' => 'required|string', 'name' => 'required|string|max:255', ]; @@ -50,13 +50,25 @@ private function validateToken(string $provider, string $token): bool $response = Http::withHeaders([ 'Authorization' => 'Bearer '.$token, ])->timeout(10)->get('https://api.hetzner.cloud/v1/servers'); - ray($response); return $response->successful(); } - // Add other providers here in the future - // if ($provider === 'digitalocean') { ... } + if ($provider === 'digitalocean') { + $response = Http::withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->timeout(10)->get('https://api.digitalocean.com/v2/account'); + + return $response->successful(); + } + + if ($provider === 'vultr') { + $response = Http::withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->timeout(10)->get('https://api.vultr.com/v2/account'); + + return $response->successful(); + } return false; } catch (\Throwable $e) { diff --git a/app/Livewire/Security/CloudProviderTokens.php b/app/Livewire/Security/CloudProviderTokens.php index cfef30772..a45cf6fc0 100644 --- a/app/Livewire/Security/CloudProviderTokens.php +++ b/app/Livewire/Security/CloudProviderTokens.php @@ -4,6 +4,7 @@ use App\Models\CloudProviderToken; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Support\Facades\Http; use Livewire\Component; class CloudProviderTokens extends Component @@ -50,6 +51,13 @@ public function validateToken(int $tokenId) } else { $this->dispatch('error', 'DigitalOcean token validation failed. Please check the token.'); } + } elseif ($token->provider === 'vultr') { + $isValid = $this->validateVultrToken($token->token); + if ($isValid) { + $this->dispatch('success', 'Vultr token is valid.'); + } else { + $this->dispatch('error', 'Vultr token validation failed. Please check the token.'); + } } else { $this->dispatch('error', 'Unknown provider.'); } @@ -61,7 +69,7 @@ public function validateToken(int $tokenId) private function validateHetznerToken(string $token): bool { try { - $response = \Illuminate\Support\Facades\Http::withToken($token) + $response = Http::withToken($token) ->timeout(10) ->get('https://api.hetzner.cloud/v1/servers?per_page=1'); @@ -74,7 +82,7 @@ private function validateHetznerToken(string $token): bool private function validateDigitalOceanToken(string $token): bool { try { - $response = \Illuminate\Support\Facades\Http::withToken($token) + $response = Http::withToken($token) ->timeout(10) ->get('https://api.digitalocean.com/v2/account'); @@ -84,6 +92,19 @@ private function validateDigitalOceanToken(string $token): bool } } + private function validateVultrToken(string $token): bool + { + try { + $response = Http::withToken($token) + ->timeout(10) + ->get('https://api.vultr.com/v2/account'); + + return $response->successful(); + } catch (\Throwable $e) { + return false; + } + } + public function deleteToken(int $tokenId) { try { diff --git a/app/Livewire/Server/CloudProviderToken/Show.php b/app/Livewire/Server/CloudProviderToken/Show.php index 6b22fddc6..25aae7c65 100644 --- a/app/Livewire/Server/CloudProviderToken/Show.php +++ b/app/Livewire/Server/CloudProviderToken/Show.php @@ -5,6 +5,7 @@ use App\Models\CloudProviderToken; use App\Models\Server; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Support\Facades\Http; use Livewire\Component; class Show extends Component @@ -17,6 +18,10 @@ class Show extends Component public $parameters = []; + public string $provider = 'hetzner'; + + public string $providerName = 'Hetzner'; + public function mount(string $server_uuid) { try { @@ -36,8 +41,11 @@ public function getListeners() public function loadTokens() { + $this->provider = $this->server->vultr_instance_id ? 'vultr' : 'hetzner'; + $this->providerName = $this->provider === 'vultr' ? 'Vultr' : 'Hetzner'; + $this->cloudProviderTokens = CloudProviderToken::ownedByCurrentTeam() - ->where('provider', 'hetzner') + ->where('provider', $this->provider) ->get(); } @@ -67,7 +75,7 @@ public function setCloudProviderToken($tokenId) $this->server->cloudProviderToken()->associate($ownedToken); $this->server->save(); - $this->dispatch('success', 'Hetzner token updated successfully.'); + $this->dispatch('success', "{$this->providerName} token updated successfully."); $this->dispatch('refreshServerShow'); } catch (\Exception $e) { $this->server->refresh(); @@ -78,10 +86,13 @@ public function setCloudProviderToken($tokenId) private function validateTokenForServer(CloudProviderToken $token): array { try { - // First, validate the token itself - $response = \Illuminate\Support\Facades\Http::withHeaders([ + $endpoint = $token->provider === 'vultr' + ? 'https://api.vultr.com/v2/account' + : 'https://api.hetzner.cloud/v1/servers'; + + $response = Http::withHeaders([ 'Authorization' => 'Bearer '.$token->token, - ])->timeout(10)->get('https://api.hetzner.cloud/v1/servers'); + ])->timeout(10)->get($endpoint); if (! $response->successful()) { return [ @@ -90,9 +101,8 @@ private function validateTokenForServer(CloudProviderToken $token): array ]; } - // Check if this token can access the specific Hetzner server if ($this->server->hetzner_server_id) { - $serverResponse = \Illuminate\Support\Facades\Http::withHeaders([ + $serverResponse = Http::withHeaders([ 'Authorization' => 'Bearer '.$token->token, ])->timeout(10)->get("https://api.hetzner.cloud/v1/servers/{$this->server->hetzner_server_id}"); @@ -104,6 +114,19 @@ private function validateTokenForServer(CloudProviderToken $token): array } } + if ($this->server->vultr_instance_id) { + $serverResponse = Http::withHeaders([ + 'Authorization' => 'Bearer '.$token->token, + ])->timeout(10)->get("https://api.vultr.com/v2/instances/{$this->server->vultr_instance_id}"); + + if (! $serverResponse->successful()) { + return [ + 'valid' => false, + 'error' => 'This token cannot access this instance. It may belong to a different Vultr account.', + ]; + } + } + return ['valid' => true]; } catch (\Throwable $e) { return [ @@ -118,19 +141,23 @@ public function validateToken() try { $token = $this->server->cloudProviderToken; if (! $token) { - $this->dispatch('error', 'No Hetzner token is associated with this server.'); + $this->dispatch('error', "No {$this->providerName} token is associated with this server."); return; } - $response = \Illuminate\Support\Facades\Http::withHeaders([ + $endpoint = $token->provider === 'vultr' + ? 'https://api.vultr.com/v2/account' + : 'https://api.hetzner.cloud/v1/servers'; + + $response = Http::withHeaders([ 'Authorization' => 'Bearer '.$token->token, - ])->timeout(10)->get('https://api.hetzner.cloud/v1/servers'); + ])->timeout(10)->get($endpoint); if ($response->successful()) { - $this->dispatch('success', 'Hetzner token is valid and working.'); + $this->dispatch('success', "{$this->providerName} token is valid and working."); } else { - $this->dispatch('error', 'Hetzner token is invalid or has insufficient permissions.'); + $this->dispatch('error', "{$this->providerName} token is invalid or has insufficient permissions."); } } catch (\Throwable $e) { return handleError($e, $this); diff --git a/app/Livewire/Server/Delete.php b/app/Livewire/Server/Delete.php index d06543b39..707677c79 100644 --- a/app/Livewire/Server/Delete.php +++ b/app/Livewire/Server/Delete.php @@ -16,6 +16,8 @@ class Delete extends Component public bool $delete_from_hetzner = false; + public bool $delete_from_vultr = false; + public bool $force_delete_resources = false; public function mount(string $server_uuid) @@ -35,6 +37,7 @@ public function delete($password, $selectedActions = []) if (! empty($selectedActions)) { $this->delete_from_hetzner = in_array('delete_from_hetzner', $selectedActions); + $this->delete_from_vultr = in_array('delete_from_vultr', $selectedActions); $this->force_delete_resources = in_array('force_delete_resources', $selectedActions); } try { @@ -57,7 +60,9 @@ public function delete($password, $selectedActions = []) $this->delete_from_hetzner, $this->server->hetzner_server_id, $this->server->cloud_provider_token_id, - $this->server->team_id + $this->server->team_id, + $this->delete_from_vultr, + $this->server->vultr_instance_id ); return redirectRoute($this, 'server.index'); @@ -87,6 +92,14 @@ public function render() ]; } + if ($this->server->vultr_instance_id) { + $checkboxes[] = [ + 'id' => 'delete_from_vultr', + 'label' => 'Also delete server from Vultr', + 'default_warning' => 'The actual server on Vultr will NOT be deleted.', + ]; + } + return view('livewire.server.delete', [ 'checkboxes' => $checkboxes, ]); diff --git a/app/Livewire/Server/New/ByVultr.php b/app/Livewire/Server/New/ByVultr.php new file mode 100644 index 000000000..148968e6a --- /dev/null +++ b/app/Livewire/Server/New/ByVultr.php @@ -0,0 +1,417 @@ +authorize('viewAny', CloudProviderToken::class); + $this->loadTokens(); + $this->loadSavedCloudInitScripts(); + $this->server_name = generate_random_name(); + $this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get(); + + if ($this->private_keys->count() > 0) { + $this->private_key_id = $this->private_keys->first()->id; + } + } + + public function getListeners(): array + { + return [ + 'tokenAdded' => 'handleTokenAdded', + 'privateKeyCreated' => 'handlePrivateKeyCreated', + 'modalClosed' => 'resetSelection', + ]; + } + + public function loadTokens(): void + { + $this->available_tokens = CloudProviderToken::ownedByCurrentTeam() + ->where('provider', 'vultr') + ->get(); + } + + public function loadSavedCloudInitScripts(): void + { + $this->saved_cloud_init_scripts = CloudInitScript::ownedByCurrentTeam()->get(); + } + + public function resetSelection(): void + { + $this->selected_token_id = null; + $this->current_step = 1; + $this->cloud_init_script = null; + $this->save_cloud_init_script = false; + $this->cloud_init_script_name = null; + $this->selected_cloud_init_script_id = null; + } + + public function handleTokenAdded($tokenId): void + { + $this->loadTokens(); + $this->selected_token_id = $tokenId; + $this->nextStep(); + } + + public function handlePrivateKeyCreated($keyId): void + { + $this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get(); + $this->private_key_id = $keyId; + $this->resetErrorBag('private_key_id'); + } + + protected function rules(): array + { + $rules = [ + 'selected_token_id' => 'required|integer|exists:cloud_provider_tokens,id', + ]; + + if ($this->current_step === 2) { + $rules = array_merge($rules, [ + 'server_name' => ['required', 'string', 'max:253', new ValidHostname], + 'selected_region' => 'required|string', + 'selected_plan' => 'required|string', + 'selected_os_id' => 'required|integer', + 'private_key_id' => 'required|integer|exists:private_keys,id,team_id,'.currentTeam()->id, + 'selectedVultrSshKeyIds' => 'nullable|array', + 'selectedVultrSshKeyIds.*' => 'string', + 'enable_ipv6' => 'required|boolean', + 'disable_public_ipv4' => 'required|boolean', + 'cloud_init_script' => ['nullable', 'string', new ValidCloudInitYaml], + 'save_cloud_init_script' => 'boolean', + 'cloud_init_script_name' => 'nullable|string|max:255', + 'selected_cloud_init_script_id' => 'nullable|integer|exists:cloud_init_scripts,id', + ]); + } + + return $rules; + } + + protected function messages(): array + { + return [ + 'selected_token_id.required' => 'Please select a Vultr token.', + 'selected_token_id.exists' => 'Selected token not found.', + ]; + } + + public function selectToken(int $tokenId): void + { + $this->selected_token_id = $tokenId; + } + + public function nextStep(): mixed + { + $this->validate([ + 'selected_token_id' => 'required|integer|exists:cloud_provider_tokens,id', + ]); + + try { + $vultrToken = $this->getVultrToken(); + + if (! $vultrToken) { + return $this->dispatch('error', 'Please select a valid Vultr token.'); + } + + $this->loadVultrData($vultrToken); + $this->current_step = 2; + } catch (\Throwable $e) { + return handleError($e, $this); + } + + return null; + } + + public function previousStep(): void + { + $this->current_step = 1; + } + + public function updatedSelectedRegion(): void + { + $this->selected_plan = null; + } + + public function updatedSelectedCloudInitScriptId($value): void + { + if ($value) { + $script = CloudInitScript::ownedByCurrentTeam()->findOrFail($value); + $this->cloud_init_script = $script->script; + $this->cloud_init_script_name = $script->name; + } + } + + public function clearCloudInitScript(): void + { + $this->selected_cloud_init_script_id = null; + $this->cloud_init_script = ''; + $this->cloud_init_script_name = ''; + $this->save_cloud_init_script = false; + } + + public function getAvailablePlansProperty(): array + { + if (! $this->selected_region) { + return $this->plans; + } + + return collect($this->plans) + ->filter(function ($plan) { + $locations = $plan['locations'] ?? []; + + return empty($locations) || in_array($this->selected_region, $locations); + }) + ->values() + ->toArray(); + } + + public function getSelectedServerPriceProperty(): ?string + { + if (! $this->selected_plan) { + return null; + } + + $plan = collect($this->plans)->firstWhere('id', $this->selected_plan); + $monthlyCost = $plan['monthly_cost'] ?? null; + + if ($monthlyCost === null) { + return null; + } + + return '$'.number_format((float) $monthlyCost, 2); + } + + private function getVultrToken(): string + { + if ($this->selected_token_id) { + $token = $this->available_tokens->firstWhere('id', $this->selected_token_id); + + return $token ? $token->token : ''; + } + + return ''; + } + + private function loadVultrData(string $token): void + { + $this->loading_data = true; + + try { + $vultrService = new VultrService($token); + + $this->regions = collect($vultrService->getRegions()) + ->sortBy('id') + ->values() + ->toArray(); + + $this->plans = collect($vultrService->getPlans()) + ->sortBy('monthly_cost') + ->values() + ->toArray(); + + $this->operatingSystems = collect($vultrService->getOperatingSystems()) + ->sortBy('name') + ->values() + ->toArray(); + + $this->vultrSshKeys = $vultrService->getSshKeys(); + $this->loading_data = false; + } catch (\Throwable $e) { + $this->loading_data = false; + throw $e; + } + } + + private function createVultrServer(string $token): array + { + $vultrService = new VultrService($token); + $privateKey = PrivateKey::ownedByCurrentTeam()->findOrFail($this->private_key_id); + $publicKey = $privateKey->getPublicKey(); + $existingKey = $this->findMatchingSshKey($vultrService->getSshKeys(), $publicKey); + + if ($existingKey) { + $sshKeyId = $existingKey['id']; + } else { + $uploadedKey = $vultrService->uploadSshKey($privateKey->name, $publicKey); + $sshKeyId = $uploadedKey['id']; + } + + $sshKeys = array_values(array_unique(array_merge([$sshKeyId], $this->selectedVultrSshKeyIds))); + $normalizedServerName = strtolower(trim($this->server_name)); + + $params = [ + 'region' => $this->selected_region, + 'plan' => $this->selected_plan, + 'os_id' => $this->selected_os_id, + 'label' => $normalizedServerName, + 'hostname' => $normalizedServerName, + 'sshkey_id' => $sshKeys, + 'enable_ipv6' => $this->enable_ipv6, + 'disable_public_ipv4' => $this->disable_public_ipv4, + ]; + + if (! empty($this->cloud_init_script)) { + $params['user_data'] = $this->cloud_init_script; + } + + return $vultrService->createInstance($params); + } + + public function submit(): mixed + { + $this->validate(); + + try { + $this->authorize('create', Server::class); + + if (Team::serverLimitReached()) { + return $this->dispatch('error', 'You have reached the server limit for your subscription.'); + } + + if ($this->save_cloud_init_script && ! empty($this->cloud_init_script) && ! empty($this->cloud_init_script_name)) { + $this->authorize('create', CloudInitScript::class); + + CloudInitScript::create([ + 'team_id' => currentTeam()->id, + 'name' => $this->cloud_init_script_name, + 'script' => $this->cloud_init_script, + ]); + } + + $vultrService = new VultrService($this->getVultrToken()); + $vultrInstance = $this->createVultrServer($this->getVultrToken()); + $ipAddress = $vultrService->getPublicIp($vultrInstance, $this->disable_public_ipv4, $this->enable_ipv6) ?? '0.0.0.0'; + + $server = Server::create([ + 'name' => strtolower(trim($this->server_name)), + 'ip' => $ipAddress, + 'user' => 'root', + 'port' => 22, + 'team_id' => currentTeam()->id, + 'private_key_id' => $this->private_key_id, + 'cloud_provider_token_id' => $this->selected_token_id, + 'vultr_instance_id' => $vultrInstance['id'], + 'vultr_instance_status' => $vultrInstance['status'] ?? null, + ]); + + $vultrInstance = $vultrService->waitForPublicIp($vultrInstance, $this->disable_public_ipv4, $this->enable_ipv6); + $assignedIpAddress = $vultrService->getPublicIp($vultrInstance, $this->disable_public_ipv4, $this->enable_ipv6); + if ($assignedIpAddress && $assignedIpAddress !== $server->ip) { + $server->update([ + 'ip' => $assignedIpAddress, + 'vultr_instance_status' => $vultrInstance['status'] ?? $server->vultr_instance_status, + ]); + } + + $server->proxy->set('status', 'exited'); + $server->proxy->set('type', ProxyTypes::TRAEFIK->value); + $server->save(); + + if ($this->from_onboarding) { + currentTeam()->update([ + 'show_boarding' => false, + ]); + refreshSession(); + } + + return redirectRoute($this, 'server.show', [$server->uuid]); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function render() + { + return view('livewire.server.new.by-vultr'); + } + + private function findMatchingSshKey(array $sshKeys, string $publicKey): ?array + { + $normalizedPublicKey = $this->normalizePublicKey($publicKey); + + foreach ($sshKeys as $sshKey) { + if ($this->normalizePublicKey($sshKey['ssh_key'] ?? '') === $normalizedPublicKey) { + return $sshKey; + } + } + + return null; + } + + private function normalizePublicKey(string $publicKey): string + { + $parts = preg_split('/\s+/', trim($publicKey)); + + return implode(' ', array_slice($parts ?: [], 0, 2)); + } +} diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index 4a6e2335e..0cd560c98 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -9,6 +9,7 @@ use App\Models\Server; use App\Rules\ValidServerIp; use App\Services\HetznerService; +use App\Services\VultrService; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; @@ -75,8 +76,12 @@ class Show extends Component public ?string $hetznerServerStatus = null; + public ?string $vultrInstanceStatus = null; + public bool $hetznerServerManuallyStarted = false; + public bool $vultrInstanceManuallyStarted = false; + public bool $isValidating = false; // Hetzner linking properties @@ -92,6 +97,18 @@ class Show extends Component public bool $hetznerNoMatchFound = false; + public Collection $availableVultrTokens; + + public ?int $selectedVultrTokenId = null; + + public ?string $manualVultrInstanceId = null; + + public ?array $matchedVultrInstance = null; + + public ?string $vultrSearchError = null; + + public bool $vultrNoMatchFound = false; + public function getListeners() { $teamId = $this->server->team_id ?? auth()->user()->currentTeam()->id; @@ -173,10 +190,12 @@ public function mount(string $server_uuid) } // Load saved Hetzner status and validation state $this->hetznerServerStatus = $this->server->hetzner_server_status; + $this->vultrInstanceStatus = $this->server->vultr_instance_status; $this->isValidating = $this->server->is_validating ?? false; - // Load Hetzner tokens for linking + // Load cloud provider tokens for linking $this->loadHetznerTokens(); + $this->loadVultrTokens(); } catch (\Throwable $e) { return handleError($e, $this); @@ -289,6 +308,22 @@ public function validateServer($install = true) { try { $this->authorize('update', $this->server); + if ($this->server->vultr_instance_id) { + $status = $this->server->refreshVultrState(); + $this->server->refresh(); + $this->vultrInstanceStatus = $this->server->vultr_instance_status; + $this->ip = $this->server->ip; + + if (in_array($status, ['stopped', 'suspended', 'deleted'], true)) { + $message = $status === 'deleted' + ? 'Vultr instance is deleted or no longer accessible. Relink this server before validating.' + : 'Vultr instance is '.($status ?? 'not running').'. Power it on before validating.'; + $this->dispatch('error', $message); + + return; + } + } + $this->validationLogs = $this->server->validation_logs = null; $this->server->save(); $this->dispatch('init', $install); @@ -453,6 +488,27 @@ public function checkHetznerServerStatus(bool $manual = false) } } + public function checkVultrInstanceStatus(bool $manual = false) + { + try { + if (! $this->server->vultr_instance_id || ! $this->server->cloudProviderToken) { + $this->dispatch('error', 'This server is not associated with a Vultr instance or token.'); + + return; + } + + $this->vultrInstanceStatus = $this->server->refreshVultrState(); + $this->server->refresh(); + $this->ip = $this->server->ip; + + if ($manual) { + $this->dispatch('success', 'Instance status refreshed: '.ucfirst($this->vultrInstanceStatus ?? 'unknown')); + } + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function handleServerValidated($event = null) { // Check if event is for this server @@ -472,6 +528,7 @@ public function handleServerValidated($event = null) // Reload Hetzner tokens in case the linking section should now be shown $this->loadHetznerTokens(); + $this->loadVultrTokens(); $this->dispatch('refreshServerShow'); $this->dispatch('refreshServer'); @@ -498,6 +555,27 @@ public function startHetznerServer() } } + public function startVultrInstance() + { + try { + if (! $this->server->vultr_instance_id || ! $this->server->cloudProviderToken) { + $this->dispatch('error', 'This server is not associated with a Vultr instance or token.'); + + return; + } + + $vultrService = new VultrService($this->server->cloudProviderToken->token); + $vultrService->startInstance($this->server->vultr_instance_id); + + $this->vultrInstanceStatus = 'starting'; + $this->server->update(['vultr_instance_status' => 'starting']); + $this->vultrInstanceManuallyStarted = true; + $this->dispatch('success', 'Vultr instance is starting...'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function refreshServerMetadata(): void { try { @@ -531,6 +609,13 @@ public function loadHetznerTokens(): void ->get(); } + public function loadVultrTokens(): void + { + $this->availableVultrTokens = CloudProviderToken::ownedByCurrentTeam() + ->where('provider', 'vultr') + ->get(); + } + public function searchHetznerServer(): void { $this->hetznerSearchError = null; @@ -658,6 +743,130 @@ public function linkToHetzner() } } + public function searchVultrInstance(): void + { + $this->vultrSearchError = null; + $this->vultrNoMatchFound = false; + $this->matchedVultrInstance = null; + + if (! $this->selectedVultrTokenId) { + $this->vultrSearchError = 'Please select a Vultr token.'; + + return; + } + + try { + $this->authorize('update', $this->server); + + $token = $this->availableVultrTokens->firstWhere('id', $this->selectedVultrTokenId); + if (! $token) { + $this->vultrSearchError = 'Invalid token selected.'; + + return; + } + + $vultrService = new VultrService($token->token); + $matched = $vultrService->findInstanceByIp($this->server->ip); + + if ($matched) { + $this->matchedVultrInstance = $matched; + } else { + $this->vultrNoMatchFound = true; + } + } catch (\Throwable $e) { + $this->vultrSearchError = 'Failed to search Vultr instances: '.$e->getMessage(); + } + } + + public function searchVultrInstanceById(): void + { + $this->vultrSearchError = null; + $this->vultrNoMatchFound = false; + $this->matchedVultrInstance = null; + + if (! $this->selectedVultrTokenId) { + $this->vultrSearchError = 'Please select a Vultr token first.'; + + return; + } + + if (! $this->manualVultrInstanceId) { + $this->vultrSearchError = 'Please enter a Vultr Instance ID.'; + + return; + } + + try { + $this->authorize('update', $this->server); + + $token = $this->availableVultrTokens->firstWhere('id', $this->selectedVultrTokenId); + if (! $token) { + $this->vultrSearchError = 'Invalid token selected.'; + + return; + } + + $vultrService = new VultrService($token->token); + $instanceData = $vultrService->getInstance($this->manualVultrInstanceId); + + if (! empty($instanceData)) { + $this->matchedVultrInstance = $instanceData; + } else { + $this->vultrNoMatchFound = true; + } + } catch (\Throwable $e) { + $this->vultrSearchError = 'Failed to fetch Vultr instance: '.$e->getMessage(); + } + } + + public function linkToVultr() + { + if (! $this->matchedVultrInstance) { + $this->dispatch('error', 'No Vultr instance selected.'); + + return; + } + + try { + $this->authorize('update', $this->server); + + $token = $this->availableVultrTokens->firstWhere('id', $this->selectedVultrTokenId); + if (! $token) { + $this->dispatch('error', 'Invalid token selected.'); + + return; + } + + $vultrService = new VultrService($token->token); + $instanceData = $vultrService->getInstance($this->matchedVultrInstance['id']); + + if (empty($instanceData)) { + $this->dispatch('error', 'Could not find Vultr instance with ID: '.$this->matchedVultrInstance['id']); + + return; + } + + $this->server->update([ + 'cloud_provider_token_id' => $this->selectedVultrTokenId, + 'vultr_instance_id' => $this->matchedVultrInstance['id'], + 'vultr_instance_status' => $instanceData['status'] ?? null, + ]); + + $this->vultrInstanceStatus = $instanceData['status'] ?? null; + + $this->matchedVultrInstance = null; + $this->selectedVultrTokenId = null; + $this->manualVultrInstanceId = null; + $this->vultrNoMatchFound = false; + $this->vultrSearchError = null; + + $this->dispatch('success', 'Server successfully linked to Vultr!'); + $this->dispatch('refreshServerShow'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function render() { return view('livewire.server.show'); diff --git a/app/Livewire/Server/ValidateAndInstall.php b/app/Livewire/Server/ValidateAndInstall.php index 59ca4cd36..5731c4fbe 100644 --- a/app/Livewire/Server/ValidateAndInstall.php +++ b/app/Livewire/Server/ValidateAndInstall.php @@ -87,6 +87,22 @@ public function retry() public function validateConnection() { $this->authorize('update', $this->server); + if ($this->server->vultr_instance_id) { + $status = $this->server->refreshVultrState(); + $this->server->refresh(); + + if (in_array($status, ['stopped', 'suspended', 'deleted'], true)) { + $this->error = $status === 'deleted' + ? 'Vultr instance is deleted or no longer accessible. Relink this server before validating.' + : 'Vultr instance is '.($status ?? 'not running').'. Power it on before validating.'; + $this->server->update([ + 'validation_logs' => $this->error, + ]); + + return; + } + } + ['uptime' => $this->uptime, 'error' => $error] = $this->server->validateConnection(); if (! $this->uptime) { $sanitizedError = htmlspecialchars($error ?? '', ENT_QUOTES, 'UTF-8'); diff --git a/app/Models/CloudProviderToken.php b/app/Models/CloudProviderToken.php index 026d11fba..35452553b 100644 --- a/app/Models/CloudProviderToken.php +++ b/app/Models/CloudProviderToken.php @@ -2,8 +2,12 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Factories\HasFactory; + class CloudProviderToken extends BaseModel { + use HasFactory; + protected $fillable = [ 'team_id', 'provider', diff --git a/app/Models/Server.php b/app/Models/Server.php index 2b7bbac55..631da7d3b 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -17,6 +17,7 @@ use App\Notifications\Server\Reachable; use App\Notifications\Server\Unreachable; use App\Services\ConfigurationRepository; +use App\Services\VultrService; use App\Support\ValidationPatterns; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasMetrics; @@ -270,7 +271,10 @@ public static function flushIdentityMap(): void 'team_id', 'hetzner_server_id', 'hetzner_server_status', + 'vultr_instance_id', + 'vultr_instance_status', 'is_validating', + 'validation_logs', 'detected_traefik_version', 'traefik_outdated_info', 'server_metadata', @@ -291,6 +295,51 @@ public function type() return 'server'; } + public function refreshVultrState(): ?string + { + if (! $this->vultr_instance_id || ! $this->cloudProviderToken) { + return null; + } + + $vultrService = new VultrService($this->cloudProviderToken->token); + try { + $instance = $vultrService->getInstance($this->vultr_instance_id); + } catch (\Throwable $e) { + if ((int) $e->getCode() !== 404) { + throw $e; + } + + if ($this->vultr_instance_status !== 'deleted') { + $this->update(['vultr_instance_status' => 'deleted']); + $this->forceFill(['vultr_instance_status' => 'deleted']); + } + + return 'deleted'; + } + + $status = ($instance['power_status'] ?? null) === 'stopped' + ? 'stopped' + : ($instance['status'] ?? null); + $publicIp = $vultrService->getPublicIp($instance); + + $updates = []; + if ($this->vultr_instance_status !== $status) { + $updates['vultr_instance_status'] = $status; + } + + $hasPlaceholderIp = blank($this->ip) || in_array($this->ip, ['0.0.0.0', '::'], true); + if ($hasPlaceholderIp && $publicIp) { + $updates['ip'] = $publicIp; + } + + if (! empty($updates)) { + $this->update($updates); + $this->forceFill($updates); + } + + return $status; + } + protected function isCoolifyHost(): Attribute { return Attribute::make( diff --git a/app/Services/VultrService.php b/app/Services/VultrService.php new file mode 100644 index 000000000..b6d2494b6 --- /dev/null +++ b/app/Services/VultrService.php @@ -0,0 +1,186 @@ + 'Bearer '.$this->token, + ]) + ->timeout(30) + ->retry(3, fn (int $attempt) => $attempt * 100) + ->{$method}($this->baseUrl.$endpoint, $data); + + if (! $response->successful()) { + if ($response->status() === 429) { + throw new RateLimitException( + 'Rate limit exceeded. Please try again later.', + $response->header('Retry-After') !== null ? (int) $response->header('Retry-After') : null + ); + } + + throw new \Exception('Vultr API error: '.$response->json('error', 'Unknown error'), $response->status()); + } + + return $response->json(); + } + + private function requestPaginated(string $endpoint, string $resourceKey, array $data = []): array + { + $allResults = []; + $cursor = null; + + do { + $query = $data; + $query['per_page'] = 100; + + if ($cursor !== null) { + $query['cursor'] = $cursor; + } + + $response = $this->request('get', $endpoint, $query); + + if (isset($response[$resourceKey])) { + $allResults = array_merge($allResults, $response[$resourceKey]); + } + + $next = $response['meta']['links']['next'] ?? null; + $cursor = $this->cursorFromNextLink($next); + } while ($cursor !== null); + + return $allResults; + } + + private function cursorFromNextLink(?string $next): ?string + { + if (blank($next)) { + return null; + } + + parse_str((string) parse_url($next, PHP_URL_QUERY), $query); + + return $query['cursor'] ?? null; + } + + public function getRegions(): array + { + return $this->requestPaginated('/regions', 'regions'); + } + + public function getPlans(): array + { + return $this->requestPaginated('/plans', 'plans'); + } + + public function getOperatingSystems(): array + { + return $this->requestPaginated('/os', 'os'); + } + + public function getSshKeys(): array + { + return $this->requestPaginated('/ssh-keys', 'ssh_keys'); + } + + public function uploadSshKey(string $name, string $publicKey): array + { + $response = $this->request('post', '/ssh-keys', [ + 'name' => $name, + 'ssh_key' => $publicKey, + ]); + + return $response['ssh_key'] ?? []; + } + + public function createInstance(array $params): array + { + if (! empty($params['user_data'])) { + $params['user_data'] = base64_encode($params['user_data']); + } + + $response = $this->request('post', '/instances', $params); + + return $response['instance'] ?? []; + } + + public function waitForPublicIp(array $instance, bool $disablePublicIpv4 = false, bool $enableIpv6 = true, int $attempts = 6, int $sleepMilliseconds = 1000): array + { + if ($this->getPublicIp($instance, $disablePublicIpv4, $enableIpv6) || empty($instance['id'])) { + return $instance; + } + + for ($attempt = 0; $attempt < $attempts; $attempt++) { + usleep($sleepMilliseconds * 1000); + + $instance = $this->getInstance($instance['id']); + + if ($this->getPublicIp($instance, $disablePublicIpv4, $enableIpv6)) { + return $instance; + } + } + + return $instance; + } + + public function getPublicIp(array $instance, bool $disablePublicIpv4 = false, bool $enableIpv6 = true): ?string + { + $ipv4 = $instance['main_ip'] ?? null; + if (! $disablePublicIpv4 && $this->isUsableIp($ipv4)) { + return $ipv4; + } + + $ipv6 = $instance['v6_main_ip'] ?? null; + if ($enableIpv6 && $this->isUsableIp($ipv6)) { + return $ipv6; + } + + return null; + } + + private function isUsableIp(?string $ip): bool + { + return ! blank($ip) && ! in_array($ip, ['0.0.0.0', '::'], true); + } + + public function getInstance(string $instanceId): array + { + $response = $this->request('get', "/instances/{$instanceId}"); + + return $response['instance'] ?? []; + } + + public function startInstance(string $instanceId): array + { + return $this->request('post', "/instances/{$instanceId}/start"); + } + + public function deleteInstance(string $instanceId): void + { + $this->request('delete', "/instances/{$instanceId}"); + } + + public function getInstances(): array + { + return $this->requestPaginated('/instances', 'instances'); + } + + public function findInstanceByIp(string $ip): ?array + { + foreach ($this->getInstances() as $instance) { + if (($instance['main_ip'] ?? null) === $ip || ($instance['v6_main_ip'] ?? null) === $ip) { + return $instance; + } + } + + return null; + } +} diff --git a/database/factories/CloudProviderTokenFactory.php b/database/factories/CloudProviderTokenFactory.php new file mode 100644 index 000000000..49bc2e498 --- /dev/null +++ b/database/factories/CloudProviderTokenFactory.php @@ -0,0 +1,28 @@ + + */ +class CloudProviderTokenFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'team_id' => Team::factory(), + 'provider' => 'hetzner', + 'token' => 'test-cloud-provider-token', + 'name' => fake()->words(3, true), + ]; + } +} diff --git a/database/migrations/2026_06_01_210459_add_vultr_instance_fields_to_servers_table.php b/database/migrations/2026_06_01_210459_add_vultr_instance_fields_to_servers_table.php new file mode 100644 index 000000000..2693011f1 --- /dev/null +++ b/database/migrations/2026_06_01_210459_add_vultr_instance_fields_to_servers_table.php @@ -0,0 +1,44 @@ +string('vultr_instance_id')->nullable()->after('hetzner_server_status'); + }); + } + + if (! Schema::hasColumn('servers', 'vultr_instance_status')) { + Schema::table('servers', function (Blueprint $table) { + $table->string('vultr_instance_status')->nullable()->after('vultr_instance_id'); + }); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + if (Schema::hasColumn('servers', 'vultr_instance_status')) { + Schema::table('servers', function (Blueprint $table) { + $table->dropColumn('vultr_instance_status'); + }); + } + + if (Schema::hasColumn('servers', 'vultr_instance_id')) { + Schema::table('servers', function (Blueprint $table) { + $table->dropColumn('vultr_instance_id'); + }); + } + } +}; diff --git a/resources/views/components/server/sidebar.blade.php b/resources/views/components/server/sidebar.blade.php index 2beb677be..3c5391429 100644 --- a/resources/views/components/server/sidebar.blade.php +++ b/resources/views/components/server/sidebar.blade.php @@ -9,10 +9,10 @@ Private Key - @if ($server->hetzner_server_id) + @if ($server->hetzner_server_id || $server->vultr_instance_id) Hetzner Token + href="{{ route('server.cloud-provider-token', ['server_uuid' => $server->uuid]) }}">Cloud Token @endif + + +
+
+
+ + + + +
+
+

Vultr Cloud

+

+ Deploy servers directly from your Vultr account. +

+
+
+
+
+ +
@endif @endcan @@ -745,4 +769,4 @@ class="text-sm dark:text-neutral-400 hover:text-coollabs dark:hover:text-warning @endif - \ No newline at end of file + 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 e803aa00c..d5b1b4727 100644 --- a/resources/views/livewire/security/cloud-provider-token-form.blade.php +++ b/resources/views/livewire/security/cloud-provider-token-form.blade.php @@ -6,6 +6,7 @@ + @else @@ -20,7 +21,7 @@ @if (auth()->user()->currentTeam()->cloudProviderTokens->where('provider', $provider)->isEmpty())
Create an API token in the {{ ucfirst($provider) }} Console → choose Project → Security → API Tokens. @if ($provider === 'hetzner') @@ -42,6 +43,7 @@ class='underline dark:text-white'>Sign up here +
diff --git a/resources/views/livewire/server/cloud-provider-token/show.blade.php b/resources/views/livewire/server/cloud-provider-token/show.blade.php index 6fb65c411..30edd145e 100644 --- a/resources/views/livewire/server/cloud-provider-token/show.blade.php +++ b/resources/views/livewire/server/cloud-provider-token/show.blade.php @@ -1,17 +1,17 @@
- {{ data_get_str($server, 'name')->limit(10) }} > Hetzner Token | Coolify + {{ data_get_str($server, 'name')->limit(10) }} > Cloud Token | Coolify
- @if ($server->hetzner_server_id) + @if ($server->hetzner_server_id || $server->vultr_instance_id)
-

Hetzner Token

+

{{ $providerName }} Token

@can('create', App\Models\CloudProviderToken::class) - - + + @endcan
-
Change your server's Hetzner token.
+
Change your server's {{ $providerName }} token.
@forelse ($cloudProviderTokens as $token)
-

Hetzner Token

+

Cloud Token

-
This server was not created through Hetzner Cloud integration.
+
This server was not created through a supported cloud provider integration.

- Only servers created through Hetzner Cloud can have their tokens managed here. + Only servers created through a supported cloud provider can have their tokens managed here.

@endif diff --git a/resources/views/livewire/server/create.blade.php b/resources/views/livewire/server/create.blade.php index b6e965ab4..6b69d0ace 100644 --- a/resources/views/livewire/server/create.blade.php +++ b/resources/views/livewire/server/create.blade.php @@ -23,6 +23,29 @@
+
+ + +
+
+ + + + +
+
Connect a Vultr Server
+
+ Deploy servers directly from your Vultr account +
+
+
+
+
+ +
+
+
@endcan diff --git a/resources/views/livewire/server/new/by-vultr.blade.php b/resources/views/livewire/server/new/by-vultr.blade.php new file mode 100644 index 000000000..b1f082e30 --- /dev/null +++ b/resources/views/livewire/server/new/by-vultr.blade.php @@ -0,0 +1,192 @@ +
+ @if ($limit_reached) + + @else + @if ($current_step === 1) +
+ @if ($available_tokens->count() > 0) +
+
+ + + @foreach ($available_tokens as $token) + + @endforeach + +
+
+ + Continue + +
+
+ +
OR
+ @endif + + + + +
+ @elseif ($current_step === 2) + @if ($loading_data) +
+
+
+

Loading Vultr data...

+
+
+ @else +
+
+ +
+ +
+ + + @foreach ($regions as $region) + + @endforeach + +
+ +
+ + + @foreach ($this->availablePlans as $plan) + + @endforeach + +
+ +
+ + + @foreach ($operatingSystems as $operatingSystem) + + @endforeach + +
+ +
+ @if ($private_keys->count() === 0) +
+ +
+

+ No private keys found. You need to create a private key to continue. +

+ + + +
+
+ @else + + + @foreach ($private_keys as $key) + + @endforeach + +

+ This SSH key will be automatically added to your Vultr account and used to access the + server. +

+ @endif +
+ +
+ + @foreach ($vultrSshKeys as $sshKey) + + @endforeach + +
+ +
+ +
+ + +
+
+ +
+
+ + @if ($saved_cloud_init_scripts->count() > 0) +
+ + + @foreach ($saved_cloud_init_scripts as $script) + + @endforeach + + + Clear + +
+ @endif +
+ + +
+ +
+ +
+
+
+ +
+ + Back + + + Buy & Create Server{{ $this->selectedServerPrice ? ' (' . $this->selectedServerPrice . '/mo)' : '' }} + +
+
+ @endif + @endif + @endif +
diff --git a/resources/views/livewire/server/show.blade.php b/resources/views/livewire/server/show.blade.php index cfbeccd0c..7db1c8df3 100644 --- a/resources/views/livewire/server/show.blade.php +++ b/resources/views/livewire/server/show.blade.php @@ -1,4 +1,5 @@ -
+
{{ data_get_str($server, 'name')->limit(10) }} > General | Coolify @@ -78,6 +79,74 @@ class="mx-1 dark:hover:fill-white fill-black dark:fill-warning"> @endif @endif + @if ($server->vultr_instance_id) +
+
+ + + + + @if ($vultrInstanceStatus) + + @if (in_array($vultrInstanceStatus, ['pending', 'starting'])) + + + + + + @endif + $vultrInstanceStatus === 'active', + 'text-red-500' => in_array($vultrInstanceStatus, ['stopped', 'suspended', 'deleted']), + ])> + {{ ucfirst($vultrInstanceStatus) }} + + + @else + + + + + + + Checking status... + + @endif +
+ + +
+ @if ($server->cloudProviderToken && !$server->isFunctional() && $vultrInstanceStatus === 'stopped') + + Power On + + @endif + @endif @if ($isValidating)
@@ -134,7 +203,8 @@ class="flex items-center gap-1.5 px-2 py-1 text-xs font-semibold rounded bg-warn (!$isReachable || !$isUsable) && $server->id !== 0 && !$isValidating && - !in_array($hetznerServerStatus, ['initializing', 'starting', 'stopping', 'off'])) + !in_array($hetznerServerStatus, ['initializing', 'starting', 'stopping', 'off']) && + !in_array($vultrInstanceStatus, ['pending', 'starting', 'stopped', 'suspended', 'deleted'])) Validate & configure @@ -423,6 +493,82 @@ class="dark:hover:fill-white fill-black dark:fill-warning"> @endif
@endif + @if (!$server->vultr_instance_id && $availableVultrTokens->isNotEmpty()) +
+

Link to Vultr

+

+ Link this server to a Vultr instance to enable power controls and status monitoring. +

+ +
+
+ + + @foreach ($availableVultrTokens as $token) + + @endforeach + +
+
+ +
+ + Search by ID + Searching... + +
OR
+ + Search by IP + Searching... + +
+ + @if ($vultrSearchError) +
+

{{ $vultrSearchError }}

+
+ @endif + + @if ($vultrNoMatchFound) +
+

+ @if ($manualVultrInstanceId) + No Vultr instance found with ID: {{ $manualVultrInstanceId }} + @else + No Vultr instance found matching IP: {{ $server->ip }} + @endif +

+

+ Try a different token, enter the Instance ID manually, or verify the details are correct. +

+
+ @endif + + @if ($matchedVultrInstance) +
+

Match Found!

+
+
Name: {{ $matchedVultrInstance['label'] ?? $matchedVultrInstance['hostname'] ?? 'Unknown' }}
+
ID: {{ $matchedVultrInstance['id'] }}
+
Status: {{ ucfirst($matchedVultrInstance['status'] ?? 'unknown') }}
+
Plan: {{ $matchedVultrInstance['plan'] ?? 'Unknown' }}
+
+ + Link This Server + +
+ @endif +
+ @endif
diff --git a/routes/api.php b/routes/api.php index ec6bde2e6..d21b69e41 100644 --- a/routes/api.php +++ b/routes/api.php @@ -15,6 +15,7 @@ use App\Http\Controllers\Api\ServersController; use App\Http\Controllers\Api\ServicesController; use App\Http\Controllers\Api\TeamController; +use App\Http\Controllers\Api\VultrController; use App\Http\Middleware\ApiAllowed; use Illuminate\Support\Facades\Route; @@ -98,6 +99,12 @@ Route::get('/hetzner/ssh-keys', [HetznerController::class, 'sshKeys'])->middleware(['api.ability:read']); Route::post('/servers/hetzner', [HetznerController::class, 'createServer'])->middleware(['api.ability:write']); + Route::get('/vultr/regions', [VultrController::class, 'regions'])->middleware(['api.ability:read']); + Route::get('/vultr/plans', [VultrController::class, 'plans'])->middleware(['api.ability:read']); + Route::get('/vultr/os', [VultrController::class, 'operatingSystems'])->middleware(['api.ability:read']); + Route::get('/vultr/ssh-keys', [VultrController::class, 'sshKeys'])->middleware(['api.ability:read']); + Route::post('/servers/vultr', [VultrController::class, 'createServer'])->middleware(['api.ability:write']); + Route::get('/resources', [ResourcesController::class, 'resources'])->middleware(['api.ability:read']); Route::get('/applications', [ApplicationsController::class, 'applications'])->middleware(['api.ability:read']); diff --git a/tests/Feature/CloudProviderTokenApiTest.php b/tests/Feature/CloudProviderTokenApiTest.php index da3acfd56..a26cd9311 100644 --- a/tests/Feature/CloudProviderTokenApiTest.php +++ b/tests/Feature/CloudProviderTokenApiTest.php @@ -1,6 +1,7 @@ 'file', + 'cache.default' => 'array', + 'session.driver' => 'array', + ]); + + InstanceSettings::unguarded(fn () => InstanceSettings::query()->create([ + 'id' => 0, + 'is_api_enabled' => true, + ])); + // Create a team with owner $this->team = Team::factory()->create(); $this->user = User::factory()->create(); @@ -159,6 +171,30 @@ $response->assertJsonStructure(['uuid']); }); + test('creates a Vultr cloud provider token', function () { + Http::fake([ + 'https://api.vultr.com/v2/account' => Http::response([], 200), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/cloud-tokens', [ + 'provider' => 'vultr', + 'token' => 'test-vultr-token', + 'name' => 'My Vultr Token', + ]); + + $response->assertStatus(201); + $response->assertJsonStructure(['uuid']); + + $this->assertDatabaseHas('cloud_provider_tokens', [ + 'team_id' => $this->team->id, + 'provider' => 'vultr', + 'name' => 'My Vultr Token', + ]); + }); + test('validates provider is required', function () { $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$this->bearerToken, @@ -198,7 +234,7 @@ $response->assertJsonValidationErrors(['name']); }); - test('validates provider must be hetzner or digitalocean', function () { + test('validates provider must be hetzner digitalocean or vultr', function () { $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$this->bearerToken, 'Content-Type' => 'application/json', @@ -285,8 +321,7 @@ 'Content-Type' => 'application/json', ])->patchJson("/api/v1/cloud-tokens/{$token->uuid}", []); - $response->assertStatus(422); - $response->assertJsonValidationErrors(['name']); + $response->assertStatus(400); }); test('cannot update token from another team', function () { @@ -410,4 +445,23 @@ $response->assertStatus(200); $response->assertJson(['valid' => true, 'message' => 'Token is valid.']); }); + + test('validates a valid Vultr token', function () { + $token = CloudProviderToken::factory()->create([ + 'team_id' => $this->team->id, + 'provider' => 'vultr', + ]); + + Http::fake([ + 'https://api.vultr.com/v2/account' => Http::response([], 200), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson("/api/v1/cloud-tokens/{$token->uuid}/validate"); + + $response->assertStatus(200); + $response->assertJson(['valid' => true, 'message' => 'Token is valid.']); + }); }); diff --git a/tests/Feature/VultrApiTest.php b/tests/Feature/VultrApiTest.php new file mode 100644 index 000000000..788f3bfb5 --- /dev/null +++ b/tests/Feature/VultrApiTest.php @@ -0,0 +1,374 @@ + 'file', + 'cache.default' => 'array', + 'session.driver' => 'array', + ]); + + InstanceSettings::unguarded(fn () => InstanceSettings::query()->create([ + 'id' => 0, + 'is_api_enabled' => true, + ])); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + session(['currentTeam' => $this->team]); + $this->token = $this->user->createToken('test-token', ['*']); + $this->bearerToken = $this->token->plainTextToken; + + $this->vultrToken = CloudProviderToken::create([ + 'team_id' => $this->team->id, + 'provider' => 'vultr', + 'token' => 'test-vultr-api-token', + 'name' => 'Test Vultr Token', + ]); + + $this->privateKey = PrivateKey::create([ + 'team_id' => $this->team->id, + 'name' => 'Test Private Key', + 'description' => 'Test private key', + 'private_key' => testPrivateKey(), + ]); +}); + +function testPrivateKey(): string +{ + return <<<'KEY' +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk +hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA +AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV +uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== +-----END OPENSSH PRIVATE KEY----- +KEY; +} + +function testPublicKey(): string +{ + return 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFuGmoeGq/pojrsyP1pszcNVuZx9iFkCELtxrh31QJ68'; +} + +describe('GET /api/v1/vultr/regions', function () { + test('gets Vultr regions', function () { + Http::fake([ + 'https://api.vultr.com/v2/regions*' => Http::response([ + 'regions' => [ + ['id' => 'ewr', 'city' => 'New Jersey', 'country' => 'US'], + ['id' => 'ams', 'city' => 'Amsterdam', 'country' => 'NL'], + ], + 'meta' => ['links' => ['next' => null]], + ], 200), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->getJson('/api/v1/vultr/regions?cloud_provider_token_id='.$this->vultrToken->uuid); + + $response->assertStatus(200); + $response->assertJsonCount(2); + $response->assertJsonFragment(['id' => 'ewr']); + }); + + test('requires cloud_provider_token_id parameter', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->getJson('/api/v1/vultr/regions'); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['cloud_provider_token_id']); + }); +}); + +describe('GET /api/v1/vultr/plans', function () { + test('gets Vultr plans', function () { + Http::fake([ + 'https://api.vultr.com/v2/plans*' => Http::response([ + 'plans' => [ + ['id' => 'vc2-1c-1gb', 'vcpu_count' => 1, 'ram' => 1024, 'disk' => 25], + ['id' => 'vc2-2c-2gb', 'vcpu_count' => 2, 'ram' => 2048, 'disk' => 55], + ], + 'meta' => ['links' => ['next' => null]], + ], 200), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->getJson('/api/v1/vultr/plans?cloud_provider_token_uuid='.$this->vultrToken->uuid); + + $response->assertStatus(200); + $response->assertJsonCount(2); + $response->assertJsonFragment(['id' => 'vc2-1c-1gb']); + }); +}); + +describe('GET /api/v1/vultr/os', function () { + test('gets Vultr operating systems', function () { + Http::fake([ + 'https://api.vultr.com/v2/os*' => Http::response([ + 'os' => [ + ['id' => 2284, 'name' => 'Ubuntu 24.04 LTS x64'], + ['id' => 2136, 'name' => 'Debian 12 x64'], + ], + 'meta' => ['links' => ['next' => null]], + ], 200), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->getJson('/api/v1/vultr/os?cloud_provider_token_id='.$this->vultrToken->uuid); + + $response->assertStatus(200); + $response->assertJsonCount(2); + $response->assertJsonFragment(['name' => 'Ubuntu 24.04 LTS x64']); + }); +}); + +describe('GET /api/v1/vultr/ssh-keys', function () { + test('gets Vultr SSH keys', function () { + Http::fake([ + 'https://api.vultr.com/v2/ssh-keys*' => Http::response([ + 'ssh_keys' => [ + ['id' => 'key-1', 'name' => 'my-key', 'ssh_key' => testPublicKey()], + ['id' => 'key-2', 'name' => 'another-key', 'ssh_key' => 'ssh-ed25519 AAAAother'], + ], + 'meta' => ['links' => ['next' => null]], + ], 200), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->getJson('/api/v1/vultr/ssh-keys?cloud_provider_token_id='.$this->vultrToken->uuid); + + $response->assertStatus(200); + $response->assertJsonCount(2); + $response->assertJsonFragment(['name' => 'my-key']); + }); +}); + +describe('POST /api/v1/servers/vultr', function () { + test('creates a Vultr server', function () { + Http::fake([ + 'https://api.vultr.com/v2/ssh-keys' => Http::response([ + 'ssh_key' => ['id' => 'key-1', 'ssh_key' => testPublicKey()], + ], 201), + 'https://api.vultr.com/v2/ssh-keys*' => Http::response([ + 'ssh_keys' => [], + 'meta' => ['links' => ['next' => null]], + ], 200), + 'https://api.vultr.com/v2/instances' => Http::response([ + 'instance' => [ + 'id' => 'instance-1', + 'label' => 'test-server', + 'main_ip' => '1.2.3.4', + 'v6_main_ip' => '2001:db8::1', + 'status' => 'pending', + ], + ], 202), + ]); + + $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' => true, + 'cloud_init_script' => "#cloud-config\npackages:\n - curl", + ]); + + $response->assertStatus(201); + $response->assertJsonStructure(['uuid', 'vultr_instance_id', 'ip']); + $response->assertJsonFragment(['vultr_instance_id' => 'instance-1', 'ip' => '1.2.3.4']); + + $this->assertDatabaseHas('servers', [ + 'name' => 'test-server', + 'ip' => '1.2.3.4', + 'team_id' => $this->team->id, + 'vultr_instance_id' => 'instance-1', + 'vultr_instance_status' => 'pending', + ]); + + Http::assertSent(function ($request) { + return $request->url() === 'https://api.vultr.com/v2/instances' + && $request['region'] === 'ewr' + && $request['plan'] === 'vc2-1c-1gb' + && $request['os_id'] === 2284 + && $request['sshkey_id'] === ['key-1'] + && $request['user_data'] === base64_encode("#cloud-config\npackages:\n - curl"); + }); + }); + + test('waits for Vultr to assign a real public IP when creation returns placeholder IP', function () { + Http::fake([ + 'https://api.vultr.com/v2/ssh-keys' => Http::response([ + 'ssh_key' => ['id' => 'key-1', 'ssh_key' => testPublicKey()], + ], 201), + 'https://api.vultr.com/v2/ssh-keys*' => Http::response([ + 'ssh_keys' => [], + 'meta' => ['links' => ['next' => null]], + ], 200), + 'https://api.vultr.com/v2/instances' => Http::response([ + 'instance' => [ + 'id' => 'instance-1', + 'main_ip' => '0.0.0.0', + 'status' => 'pending', + ], + ], 202), + 'https://api.vultr.com/v2/instances/instance-1' => Http::response([ + 'instance' => [ + 'id' => 'instance-1', + 'main_ip' => '1.2.3.4', + 'status' => 'active', + ], + ], 200), + ]); + + $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, + ]); + + $response->assertStatus(201); + $response->assertJsonFragment(['ip' => '1.2.3.4']); + + $this->assertDatabaseHas('servers', [ + 'name' => 'test-server', + 'ip' => '1.2.3.4', + 'vultr_instance_status' => 'active', + ]); + }); + + test('reuses existing matching Vultr SSH key', function () { + Http::fake([ + 'https://api.vultr.com/v2/ssh-keys*' => Http::response([ + 'ssh_keys' => [ + ['id' => 'existing-key', 'name' => 'Existing', 'ssh_key' => testPublicKey().' comment'], + ], + 'meta' => ['links' => ['next' => null]], + ], 200), + 'https://api.vultr.com/v2/instances' => Http::response([ + 'instance' => [ + 'id' => 'instance-1', + 'main_ip' => '1.2.3.4', + 'status' => 'pending', + ], + ], 202), + ]); + + $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, + ]); + + $response->assertStatus(201); + + Http::assertNotSent(function ($request) { + return $request->url() === 'https://api.vultr.com/v2/ssh-keys' + && $request->method() === 'POST'; + }); + Http::assertSent(function ($request) { + return $request->url() === 'https://api.vultr.com/v2/instances' + && $request['sshkey_id'] === ['existing-key']; + }); + }); + + test('validates required fields', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/servers/vultr', []); + + $response->assertStatus(400); + }); + + test('returns 404 for non-existent Vultr token', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/servers/vultr', [ + 'cloud_provider_token_id' => 'non-existent-uuid', + 'region' => 'ewr', + 'plan' => 'vc2-1c-1gb', + 'os_id' => 2284, + 'private_key_uuid' => $this->privateKey->uuid, + ]); + + $response->assertStatus(404); + $response->assertJson(['message' => 'Vultr cloud provider token not found.']); + }); + + test('uses IPv6 when public IPv4 is disabled', function () { + Http::fake([ + 'https://api.vultr.com/v2/ssh-keys' => Http::response([ + 'ssh_key' => ['id' => 'key-1', 'ssh_key' => testPublicKey()], + ], 201), + 'https://api.vultr.com/v2/ssh-keys*' => Http::response([ + 'ssh_keys' => [], + 'meta' => ['links' => ['next' => null]], + ], 200), + 'https://api.vultr.com/v2/instances' => Http::response([ + 'instance' => [ + 'id' => 'instance-1', + 'main_ip' => '', + 'v6_main_ip' => '2001:db8::1', + 'status' => 'pending', + ], + ], 202), + ]); + + $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' => true, + 'disable_public_ipv4' => true, + ]); + + $response->assertStatus(201); + $response->assertJsonFragment(['ip' => '2001:db8::1']); + }); +}); diff --git a/tests/Feature/VultrServerCreationTest.php b/tests/Feature/VultrServerCreationTest.php new file mode 100644 index 000000000..6f8166ee3 --- /dev/null +++ b/tests/Feature/VultrServerCreationTest.php @@ -0,0 +1,135 @@ + 'file', + 'cache.default' => 'array', + 'session.driver' => 'array', + ]); + + InstanceSettings::unguarded(fn () => InstanceSettings::query()->create([ + 'id' => 0, + 'is_api_enabled' => true, + ])); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + $this->vultrToken = CloudProviderToken::create([ + 'team_id' => $this->team->id, + 'provider' => 'vultr', + 'token' => 'test-vultr-token', + 'name' => 'Test Vultr Token', + ]); + + $this->privateKey = PrivateKey::create([ + 'team_id' => $this->team->id, + 'name' => 'Test Private Key', + 'description' => 'Test private key', + 'private_key' => vultrLivewireTestPrivateKey(), + ]); +}); + +function vultrLivewireTestPrivateKey(): string +{ + return <<<'KEY' +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk +hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA +AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV +uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== +-----END OPENSSH PRIVATE KEY----- +KEY; +} + +function vultrLivewireTestPublicKey(): string +{ + return 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFuGmoeGq/pojrsyP1pszcNVuZx9iFkCELtxrh31QJ68'; +} + +it('creates a Vultr server through the Livewire flow', function () { + Http::fake([ + 'https://api.vultr.com/v2/regions*' => Http::response([ + 'regions' => [ + ['id' => 'ewr', 'city' => 'New Jersey', 'country' => 'US'], + ], + 'meta' => ['links' => ['next' => null]], + ], 200), + 'https://api.vultr.com/v2/plans*' => Http::response([ + 'plans' => [ + ['id' => 'vc2-1c-1gb', 'vcpu_count' => 1, 'ram' => 1024, 'disk' => 25, 'monthly_cost' => 6, 'locations' => ['ewr']], + ], + 'meta' => ['links' => ['next' => null]], + ], 200), + 'https://api.vultr.com/v2/os*' => Http::response([ + 'os' => [ + ['id' => 2284, 'name' => 'Ubuntu 24.04 LTS x64'], + ], + 'meta' => ['links' => ['next' => null]], + ], 200), + 'https://api.vultr.com/v2/ssh-keys' => Http::response([ + 'ssh_key' => ['id' => 'key-1', 'ssh_key' => vultrLivewireTestPublicKey()], + ], 201), + 'https://api.vultr.com/v2/ssh-keys*' => Http::response([ + 'ssh_keys' => [], + 'meta' => ['links' => ['next' => null]], + ], 200), + 'https://api.vultr.com/v2/instances' => Http::response([ + 'instance' => [ + 'id' => 'instance-1', + 'label' => 'test-vultr-server', + 'main_ip' => '1.2.3.4', + 'v6_main_ip' => '2001:db8::1', + 'status' => 'pending', + ], + ], 202), + ]); + + Livewire::test(ByVultr::class) + ->set('selected_token_id', $this->vultrToken->id) + ->call('nextStep') + ->assertSet('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', true) + ->set('cloud_init_script', "#cloud-config\npackages:\n - curl") + ->call('submit'); + + $this->assertDatabaseHas('servers', [ + 'name' => 'test-vultr-server', + 'ip' => '1.2.3.4', + 'team_id' => $this->team->id, + 'cloud_provider_token_id' => $this->vultrToken->id, + 'vultr_instance_id' => 'instance-1', + 'vultr_instance_status' => 'pending', + ]); + + Http::assertSent(function ($request) { + return $request->url() === 'https://api.vultr.com/v2/instances' + && $request['region'] === 'ewr' + && $request['plan'] === 'vc2-1c-1gb' + && $request['os_id'] === 2284 + && $request['sshkey_id'] === ['key-1'] + && $request['user_data'] === base64_encode("#cloud-config\npackages:\n - curl"); + }); +}); diff --git a/tests/Feature/VultrServerLifecycleTest.php b/tests/Feature/VultrServerLifecycleTest.php new file mode 100644 index 000000000..e1b72a7f7 --- /dev/null +++ b/tests/Feature/VultrServerLifecycleTest.php @@ -0,0 +1,296 @@ + 'file', + 'cache.default' => 'array', + 'session.driver' => 'array', + ]); + + InstanceSettings::unguarded(fn () => InstanceSettings::query()->create([ + 'id' => 0, + 'is_api_enabled' => true, + ])); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + $this->privateKey = PrivateKey::create([ + 'team_id' => $this->team->id, + 'name' => 'Test Private Key', + 'description' => 'Test private key', + 'private_key' => vultrLifecycleTestPrivateKey(), + ]); + + $this->vultrToken = CloudProviderToken::create([ + 'team_id' => $this->team->id, + 'provider' => 'vultr', + 'token' => 'test-vultr-token', + 'name' => 'Test Vultr Token', + ]); + + $this->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', + 'vultr_instance_status' => 'pending', + 'ip' => '1.2.3.4', + ]); +}); + +function vultrLifecycleTestPrivateKey(): string +{ + return <<<'KEY' +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk +hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA +AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV +uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== +-----END OPENSSH PRIVATE KEY----- +KEY; +} + +it('refreshes Vultr instance status', function () { + Http::fake([ + 'https://api.vultr.com/v2/instances/instance-1' => Http::response([ + 'instance' => [ + 'id' => 'instance-1', + 'status' => 'active', + ], + ], 200), + ]); + + Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]) + ->call('checkVultrInstanceStatus', true) + ->assertSet('vultrInstanceStatus', 'active'); + + $this->assertDatabaseHas('servers', [ + 'id' => $this->server->id, + 'vultr_instance_status' => 'active', + ]); +}); + +it('refreshes Vultr status on server page load even when cached status is active', function () { + $this->server->update(['vultr_instance_status' => 'active']); + + Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]) + ->assertSee('$wire.checkVultrInstanceStatus();', false); +}); + +it('updates placeholder server IP when Vultr status refresh returns assigned public IP', function () { + $this->server->update(['ip' => '0.0.0.0']); + + Http::fake([ + 'https://api.vultr.com/v2/instances/instance-1' => Http::response([ + 'instance' => [ + 'id' => 'instance-1', + 'status' => 'active', + 'main_ip' => '9.8.7.6', + ], + ], 200), + ]); + + Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]) + ->call('checkVultrInstanceStatus', true) + ->assertSet('ip', '9.8.7.6'); + + $this->assertDatabaseHas('servers', [ + 'id' => $this->server->id, + 'ip' => '9.8.7.6', + 'vultr_instance_status' => 'active', + ]); +}); + +it('does not overwrite an established server IP during Vultr status refresh', function () { + Http::fake([ + 'https://api.vultr.com/v2/instances/instance-1' => Http::response([ + 'instance' => [ + 'id' => 'instance-1', + 'status' => 'active', + 'main_ip' => '9.8.7.6', + ], + ], 200), + ]); + + Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]) + ->call('checkVultrInstanceStatus', true) + ->assertSet('ip', '1.2.3.4') + ->assertSet('vultrInstanceStatus', 'active'); + + $this->assertDatabaseHas('servers', [ + 'id' => $this->server->id, + 'ip' => '1.2.3.4', + 'vultr_instance_status' => 'active', + ]); +}); + +it('blocks server page validation when Vultr instance is stopped', function () { + Http::fake([ + 'https://api.vultr.com/v2/instances/instance-1' => Http::response([ + 'instance' => [ + 'id' => 'instance-1', + 'status' => 'active', + 'power_status' => 'stopped', + 'main_ip' => '1.2.3.4', + ], + ], 200), + ]); + + Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]) + ->call('validateServer') + ->assertSet('vultrInstanceStatus', 'stopped') + ->assertDispatched('error', 'Vultr instance is stopped. Power it on before validating.') + ->assertNotDispatched('init'); + + $this->assertDatabaseHas('servers', [ + 'id' => $this->server->id, + 'vultr_instance_status' => 'stopped', + ]); + + expect($this->server->fresh()->validation_logs)->toBeNull(); +}); + +it('marks missing Vultr instances as deleted before validation', function () { + Http::fake([ + 'https://api.vultr.com/v2/instances/instance-1' => Http::response([ + 'error' => 'instance not found', + ], 404), + ]); + + Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]) + ->call('validateServer') + ->assertSet('vultrInstanceStatus', 'deleted') + ->assertDispatched('error', 'Vultr instance is deleted or no longer accessible. Relink this server before validating.') + ->assertNotDispatched('init'); + + $this->assertDatabaseHas('servers', [ + 'id' => $this->server->id, + 'vultr_instance_status' => 'deleted', + ]); +}); + +it('blocks validation modal connection checks when Vultr instance is stopped', function () { + Http::fake([ + 'https://api.vultr.com/v2/instances/instance-1' => Http::response([ + 'instance' => [ + 'id' => 'instance-1', + 'status' => 'active', + 'power_status' => 'stopped', + 'main_ip' => '1.2.3.4', + ], + ], 200), + ]); + + Livewire::test(ValidateAndInstall::class, ['server' => $this->server]) + ->call('validateConnection') + ->assertSet('error', 'Vultr instance is stopped. Power it on before validating.') + ->assertNotDispatched('validateOS'); + + $this->assertDatabaseHas('servers', [ + 'id' => $this->server->id, + 'vultr_instance_status' => 'stopped', + 'validation_logs' => 'Vultr instance is stopped. Power it on before validating.', + ]); +}); + +it('blocks action validation when Vultr instance is stopped', function () { + Http::fake([ + 'https://api.vultr.com/v2/instances/instance-1' => Http::response([ + 'instance' => [ + 'id' => 'instance-1', + 'status' => 'active', + 'power_status' => 'stopped', + 'main_ip' => '1.2.3.4', + ], + ], 200), + ]); + + expect(fn () => ValidateServerAction::run($this->server)) + ->toThrow(Exception::class, 'Vultr instance is stopped. Power it on before validating.'); + + $this->assertDatabaseHas('servers', [ + 'id' => $this->server->id, + 'vultr_instance_status' => 'stopped', + 'validation_logs' => 'Vultr instance is stopped. Power it on before validating.', + ]); +}); + +it('starts a Vultr instance', function () { + Http::fake([ + 'https://api.vultr.com/v2/instances/instance-1/start' => Http::response([], 204), + ]); + + Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]) + ->call('startVultrInstance') + ->assertSet('vultrInstanceStatus', 'starting'); + + $this->assertDatabaseHas('servers', [ + 'id' => $this->server->id, + 'vultr_instance_status' => 'starting', + ]); + + Http::assertSent(fn ($request) => $request->url() === 'https://api.vultr.com/v2/instances/instance-1/start'); +}); + +it('links a server to Vultr by matching IP', function () { + $unlinkedServer = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $this->privateKey->id, + 'ip' => '5.6.7.8', + ]); + + Http::fake([ + 'https://api.vultr.com/v2/instances?per_page=100' => Http::response([ + 'instances' => [ + [ + 'id' => 'instance-2', + 'label' => 'matched-server', + 'main_ip' => '5.6.7.8', + 'status' => 'active', + 'plan' => 'vc2-1c-1gb', + ], + ], + 'meta' => ['links' => ['next' => null]], + ], 200), + 'https://api.vultr.com/v2/instances/instance-2' => Http::response([ + 'instance' => [ + 'id' => 'instance-2', + 'status' => 'active', + ], + ], 200), + ]); + + Livewire::test(Show::class, ['server_uuid' => $unlinkedServer->uuid]) + ->set('selectedVultrTokenId', $this->vultrToken->id) + ->call('searchVultrInstance') + ->assertSet('matchedVultrInstance.id', 'instance-2') + ->call('linkToVultr'); + + $this->assertDatabaseHas('servers', [ + 'id' => $unlinkedServer->id, + 'cloud_provider_token_id' => $this->vultrToken->id, + 'vultr_instance_id' => 'instance-2', + 'vultr_instance_status' => 'active', + ]); +}); diff --git a/tests/Unit/ServerBackoffTest.php b/tests/Unit/ServerBackoffTest.php index 9f1f747d4..7c9910a5c 100644 --- a/tests/Unit/ServerBackoffTest.php +++ b/tests/Unit/ServerBackoffTest.php @@ -127,6 +127,37 @@ }); describe('ServerConnectionCheckJob unreachable_count', function () { + it('marks Vultr servers unreachable when provider status is unavailable', function () { + Event::fake([ServerReachabilityChanged::class]); + + $settings = Mockery::mock(); + $settings->is_reachable = true; + $settings->force_disabled = false; + $settings->shouldReceive('update') + ->with(['is_reachable' => false, 'is_usable' => false]) + ->once(); + + $server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods(); + $server->shouldReceive('getAttribute')->andReturnUsing(fn (string $key) => match ($key) { + 'settings' => $settings, + 'unreachable_notification_sent' => false, + 'vultr_instance_id' => 'instance-1', + 'cloudProviderToken' => (object) ['token' => 'test-token'], + 'id' => 1, + 'name' => 'test-server', + 'unreachable_count' => 1, + default => null, + }); + $server->shouldReceive('refreshVultrState')->once()->andReturn('stopped'); + $server->shouldReceive('increment')->with('unreachable_count')->once(); + $server->id = 1; + $server->name = 'test-server'; + $server->uuid = 'server-uuid'; + + $job = new ServerConnectionCheckJob($server); + $job->handle(); + }); + it('increments unreachable_count on timeout', function () { Event::fake([ServerReachabilityChanged::class]); diff --git a/tests/Unit/VultrDeleteServerTest.php b/tests/Unit/VultrDeleteServerTest.php new file mode 100644 index 000000000..cda628d16 --- /dev/null +++ b/tests/Unit/VultrDeleteServerTest.php @@ -0,0 +1,83 @@ + 'array', + 'session.driver' => 'array', + ]); + + InstanceSettings::unguarded(fn () => InstanceSettings::query()->create([ + 'id' => 0, + 'is_api_enabled' => true, + ])); + + $this->team = Team::factory()->create(); + session(['currentTeam' => $this->team]); + + $this->privateKey = PrivateKey::create([ + 'team_id' => $this->team->id, + 'name' => 'Test Private Key', + 'description' => 'Test private key', + 'private_key' => vultrDeleteTestPrivateKey(), + ]); + + $this->vultrToken = CloudProviderToken::create([ + 'team_id' => $this->team->id, + 'provider' => 'vultr', + 'token' => 'test-vultr-token', + 'name' => 'Test Vultr Token', + ]); +}); + +function vultrDeleteTestPrivateKey(): string +{ + return <<<'KEY' +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk +hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA +AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV +uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== +-----END OPENSSH PRIVATE KEY----- +KEY; +} + +it('deletes a Vultr instance when requested', function () { + Http::fake([ + 'https://api.vultr.com/v2/instances/instance-1' => Http::response([], 204), + ]); + + $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: $this->vultrToken->id, + teamId: $this->team->id, + deleteFromVultr: true, + vultrInstanceId: 'instance-1' + ); + + Http::assertSent(fn ($request) => $request->method() === 'DELETE' + && $request->url() === 'https://api.vultr.com/v2/instances/instance-1'); + + expect(Server::withTrashed()->find($server->id))->toBeNull(); +}); diff --git a/tests/Unit/VultrServiceTest.php b/tests/Unit/VultrServiceTest.php new file mode 100644 index 000000000..8fa27eb54 --- /dev/null +++ b/tests/Unit/VultrServiceTest.php @@ -0,0 +1,138 @@ + Http::response([ + 'instances' => [ + [ + 'id' => 'instance-1', + 'label' => 'test-server-1', + 'status' => 'active', + 'main_ip' => '123.45.67.89', + 'v6_main_ip' => '2001:db8::1', + ], + [ + 'id' => 'instance-2', + 'label' => 'test-server-2', + 'status' => 'stopped', + 'main_ip' => '98.76.54.32', + 'v6_main_ip' => '2001:db8::2', + ], + ], + 'meta' => ['links' => ['next' => null]], + ], 200), + ]); + + $service = new VultrService('fake-token'); + $instances = $service->getInstances(); + + expect($instances)->toBeArray() + ->and($instances)->toHaveCount(2) + ->and($instances[0]['id'])->toBe('instance-1') + ->and($instances[1]['id'])->toBe('instance-2'); +}); + +it('follows cursor pagination', function () { + Http::fake([ + 'https://api.vultr.com/v2/regions?per_page=100' => Http::response([ + 'regions' => [ + ['id' => 'ewr', 'city' => 'New Jersey'], + ], + 'meta' => [ + 'links' => [ + 'next' => 'https://api.vultr.com/v2/regions?cursor=next-cursor', + ], + ], + ], 200), + 'https://api.vultr.com/v2/regions?per_page=100&cursor=next-cursor' => Http::response([ + 'regions' => [ + ['id' => 'ams', 'city' => 'Amsterdam'], + ], + 'meta' => ['links' => ['next' => null]], + ], 200), + ]); + + $service = new VultrService('fake-token'); + $regions = $service->getRegions(); + + expect($regions)->toHaveCount(2) + ->and($regions[0]['id'])->toBe('ewr') + ->and($regions[1]['id'])->toBe('ams'); +}); + +it('base64 encodes user data when creating an instance', function () { + Http::fake([ + 'https://api.vultr.com/v2/instances' => Http::response([ + 'instance' => [ + 'id' => 'instance-1', + 'main_ip' => '123.45.67.89', + 'status' => 'pending', + ], + ], 202), + ]); + + $service = new VultrService('fake-token'); + $service->createInstance([ + 'region' => 'ewr', + 'plan' => 'vc2-1c-1gb', + 'os_id' => 2284, + 'user_data' => "#cloud-config\npackages:\n - curl", + ]); + + Http::assertSent(function ($request) { + return $request->url() === 'https://api.vultr.com/v2/instances' + && $request['user_data'] === base64_encode("#cloud-config\npackages:\n - curl"); + }); +}); + +it('waits for Vultr to replace placeholder public IP', function () { + Http::fake([ + 'https://api.vultr.com/v2/instances/instance-1' => Http::response([ + 'instance' => [ + 'id' => 'instance-1', + 'main_ip' => '123.45.67.89', + 'status' => 'active', + ], + ], 200), + ]); + + $service = new VultrService('fake-token'); + $instance = $service->waitForPublicIp([ + 'id' => 'instance-1', + 'main_ip' => '0.0.0.0', + 'status' => 'pending', + ], sleepMilliseconds: 0); + + expect($service->getPublicIp($instance))->toBe('123.45.67.89'); +}); + +it('finds an instance by IPv4 or IPv6', function () { + Http::fake([ + 'https://api.vultr.com/v2/instances*' => Http::response([ + 'instances' => [ + [ + 'id' => 'instance-1', + 'main_ip' => '123.45.67.89', + 'v6_main_ip' => '2001:db8::1', + ], + ], + 'meta' => ['links' => ['next' => null]], + ], 200), + ]); + + $service = new VultrService('fake-token'); + + expect($service->findInstanceByIp('123.45.67.89')['id'])->toBe('instance-1') + ->and($service->findInstanceByIp('2001:db8::1')['id'])->toBe('instance-1') + ->and($service->findInstanceByIp('1.2.3.4'))->toBeNull(); +}); From f7150a1d6c9dca9eb2056d530605889d708172bb Mon Sep 17 00:00:00 2001 From: Cornelis Terblanche Date: Wed, 3 Jun 2026 21:20:56 +0200 Subject: [PATCH 2/6] Fix Vultr power controls for stopped instances --- app/Services/VultrService.php | 2 +- resources/views/livewire/server/show.blade.php | 2 +- tests/Feature/VultrServerLifecycleTest.php | 17 +++++++++++++++++ tests/Unit/VultrServiceTest.php | 14 ++++++++++++++ 4 files changed, 33 insertions(+), 2 deletions(-) diff --git a/app/Services/VultrService.php b/app/Services/VultrService.php index b6d2494b6..6e1559f98 100644 --- a/app/Services/VultrService.php +++ b/app/Services/VultrService.php @@ -31,7 +31,7 @@ private function request(string $method, string $endpoint, array $data = []): ar throw new \Exception('Vultr API error: '.$response->json('error', 'Unknown error'), $response->status()); } - return $response->json(); + return $response->json() ?? []; } private function requestPaginated(string $endpoint, string $resourceKey, array $data = []): array diff --git a/resources/views/livewire/server/show.blade.php b/resources/views/livewire/server/show.blade.php index 7db1c8df3..e9a27eb39 100644 --- a/resources/views/livewire/server/show.blade.php +++ b/resources/views/livewire/server/show.blade.php @@ -140,7 +140,7 @@ class="mx-1 dark:hover:fill-white fill-black dark:fill-warning">
- @if ($server->cloudProviderToken && !$server->isFunctional() && $vultrInstanceStatus === 'stopped') + @if ($server->cloudProviderToken && $vultrInstanceStatus === 'stopped') Power On diff --git a/tests/Feature/VultrServerLifecycleTest.php b/tests/Feature/VultrServerLifecycleTest.php index e1b72a7f7..c89ca47b4 100644 --- a/tests/Feature/VultrServerLifecycleTest.php +++ b/tests/Feature/VultrServerLifecycleTest.php @@ -236,6 +236,23 @@ function vultrLifecycleTestPrivateKey(): string ]); }); +it('shows Vultr power on button when stopped even if server was previously functional', function () { + $this->server->update([ + 'ip' => '1.2.3.5', + 'vultr_instance_status' => 'stopped', + ]); + $this->server->settings->update([ + 'is_reachable' => true, + 'is_usable' => true, + 'force_disabled' => false, + ]); + + expect($this->server->fresh()->isFunctional())->toBeTrue(); + + Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]) + ->assertSee('Power On'); +}); + it('starts a Vultr instance', function () { Http::fake([ 'https://api.vultr.com/v2/instances/instance-1/start' => Http::response([], 204), diff --git a/tests/Unit/VultrServiceTest.php b/tests/Unit/VultrServiceTest.php index 8fa27eb54..67f13f5a3 100644 --- a/tests/Unit/VultrServiceTest.php +++ b/tests/Unit/VultrServiceTest.php @@ -136,3 +136,17 @@ ->and($service->findInstanceByIp('2001:db8::1')['id'])->toBe('instance-1') ->and($service->findInstanceByIp('1.2.3.4'))->toBeNull(); }); + +it('handles empty successful responses from Vultr API', function () { + Http::fake([ + 'https://api.vultr.com/v2/instances/instance-1/start' => Http::response(null, 204), + 'https://api.vultr.com/v2/instances/instance-1' => Http::response(null, 204), + ]); + + $service = new VultrService('fake-token'); + + expect($service->startInstance('instance-1'))->toBe([]); + $service->deleteInstance('instance-1'); + + Http::assertSentCount(2); +}); From 3f467103094006dcbbf9c76d2725d62ebcb09211 Mon Sep 17 00:00:00 2001 From: Cornelis Terblanche Date: Mon, 8 Jun 2026 18:25:42 +0200 Subject: [PATCH 3/6] Refine cloud provider token form --- .../cloud-provider-token-form.blade.php | 95 +++++++++++-------- .../security/cloud-provider-tokens.blade.php | 2 +- tests/Unit/CloudProviderTokenFormViewTest.php | 16 ++++ 3 files changed, 71 insertions(+), 42 deletions(-) create mode 100644 tests/Unit/CloudProviderTokenFormViewTest.php 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 d5b1b4727..56c53d869 100644 --- a/resources/views/livewire/security/cloud-provider-token-form.blade.php +++ b/resources/views/livewire/security/cloud-provider-token-form.blade.php @@ -3,28 +3,67 @@ @if ($modal_mode) {{-- Modal layout: vertical, compact --}} @if (!isset($provider) || empty($provider) || $provider === '') - + - + @else @endif - + - @if (auth()->user()->currentTeam()->cloudProviderTokens->where('provider', $provider)->isEmpty()) -
+
+ Create an API token in the {{ ucfirst($provider) }} Console. + @if ($provider === 'hetzner') + Choose Project → Security → API Tokens. +

+ Don't have a Hetzner account? Sign up here +
+ (Coolify's affiliate link, only new accounts - supports us (€10) + and gives you €20) + @endif + @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 + @endif +
+ + Validate & Add Token + @else + {{-- Full page layout: horizontal, spacious --}} +
+
+ + + + + +
+
+ +
+
+
+ +
Create an API token in the {{ ucfirst($provider) }} Console → choose - Project → Security → API Tokens. + href='{{ $provider === 'hetzner' ? 'https://console.hetzner.com/projects' : ($provider === 'vultr' ? 'https://console.vultr.com/user/apiaccess/' : '#') }}' + target='_blank' class='underline dark:text-white'>{{ ucfirst($provider) }} Console. @if ($provider === 'hetzner') + Choose Project → Security → API Tokens.

Don't have a Hetzner account? Sign up here @@ -32,41 +71,15 @@ class='underline dark:text-white'>Sign up here (Coolify's affiliate link, only new accounts - supports us (€10) and gives you €20) @endif -
- @endif - - Validate & Add Token - @else - {{-- Full page layout: horizontal, spacious --}} -
-
- - - - - -
-
- -
-
-
- - @if (auth()->user()->currentTeam()->cloudProviderTokens->where('provider', $provider)->isEmpty()) -
- Create an API token in the Hetzner Console → choose Project → Security → API - Tokens. + @if ($provider === 'vultr') + Open Account → API Access.

- Don't have a Hetzner account? Sign up here
- (Coolify's affiliate link, only new accounts - supports us (€10) - and gives you €20) -
- @endif + Coolify's affiliate link, only new accounts - supports us + @endif +
Validate & Add Token @endif diff --git a/resources/views/livewire/security/cloud-provider-tokens.blade.php b/resources/views/livewire/security/cloud-provider-tokens.blade.php index 6369134a8..ec7bc976f 100644 --- a/resources/views/livewire/security/cloud-provider-tokens.blade.php +++ b/resources/views/livewire/security/cloud-provider-tokens.blade.php @@ -1,6 +1,6 @@

Cloud Provider Tokens

-
Manage API tokens for cloud providers (Hetzner, DigitalOcean, etc.).
+
Manage API tokens for cloud providers (Hetzner, Vultr, etc.).

New Token

@can('create', App\Models\CloudProviderToken::class) diff --git a/tests/Unit/CloudProviderTokenFormViewTest.php b/tests/Unit/CloudProviderTokenFormViewTest.php new file mode 100644 index 000000000..ae6be05c0 --- /dev/null +++ b/tests/Unit/CloudProviderTokenFormViewTest.php @@ -0,0 +1,16 @@ +toContain('') + ->and($view)->toContain('') + ->and($view)->toContain('') + ->and(substr_count($view, 'Open Account → API Access.'))->toBe(2) + ->and(substr_count($view, 'https://console.vultr.com/user/apiaccess/'))->toBe(2) + ->and($view)->not->toContain('') + ->and($view)->not->toContain('cloudProviderTokens->where(\'provider\', $provider)->isEmpty()') + ->and($view)->not->toContain('') + ->and($component)->toContain("'provider' => 'required|string|in:hetzner,digitalocean,vultr'"); +}); From 6ed92cb97af582ee0f2cea2493b8e42dcefb69f9 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:39:57 +0200 Subject: [PATCH 4/6] fix(vultr): validate public network and token scope Require IPv6 when public IPv4 is disabled, constrain cloud token lookup by team and provider during deletion, and encode Vultr instance IDs in API paths. --- app/Actions/Server/DeleteServer.php | 18 ++- app/Http/Controllers/Api/VultrController.php | 101 ++++++++++++- app/Livewire/Server/New/ByVultr.php | 14 ++ app/Services/VultrService.php | 11 +- openapi.json | 143 +++++++++++++++++- openapi.yaml | 94 +++++++++++- .../cloud-provider-token-form.blade.php | 4 +- tests/Feature/VultrApiTest.php | 19 +++ tests/Feature/VultrServerCreationTest.php | 15 ++ tests/Unit/VultrDeleteServerTest.php | 35 +++++ tests/Unit/VultrServiceTest.php | 14 ++ 11 files changed, 448 insertions(+), 20 deletions(-) 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'); +}); From d90a0a288c076eac87e07287c0a7606d5bb842d9 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 8 Jul 2026 09:49:16 +0200 Subject: [PATCH 5/6] fix: align server mobile menu spacing --- resources/views/components/server/sidebar-proxy.blade.php | 2 +- resources/views/components/server/sidebar-security.blade.php | 2 +- resources/views/components/server/sidebar-sentinel.blade.php | 2 +- resources/views/components/server/sidebar.blade.php | 2 +- resources/views/livewire/server/advanced.blade.php | 2 +- resources/views/livewire/server/ca-certificate/show.blade.php | 2 +- resources/views/livewire/server/charts.blade.php | 2 +- .../views/livewire/server/cloud-provider-token/show.blade.php | 2 +- resources/views/livewire/server/cloudflare-tunnel.blade.php | 2 +- resources/views/livewire/server/delete.blade.php | 2 +- resources/views/livewire/server/destinations.blade.php | 2 +- resources/views/livewire/server/docker-cleanup.blade.php | 2 +- resources/views/livewire/server/log-drains.blade.php | 2 +- resources/views/livewire/server/private-key/show.blade.php | 2 +- .../livewire/server/proxy/dynamic-configurations.blade.php | 2 +- resources/views/livewire/server/proxy/logs.blade.php | 2 +- resources/views/livewire/server/proxy/show.blade.php | 2 +- resources/views/livewire/server/resources.blade.php | 2 +- resources/views/livewire/server/security/patches.blade.php | 2 +- .../views/livewire/server/security/terminal-access.blade.php | 2 +- resources/views/livewire/server/sentinel/logs.blade.php | 2 +- resources/views/livewire/server/sentinel/show.blade.php | 2 +- resources/views/livewire/server/show.blade.php | 2 +- resources/views/livewire/server/swarm.blade.php | 2 +- 24 files changed, 24 insertions(+), 24 deletions(-) diff --git a/resources/views/components/server/sidebar-proxy.blade.php b/resources/views/components/server/sidebar-proxy.blade.php index 329f50c11..dba5d7db5 100644 --- a/resources/views/components/server/sidebar-proxy.blade.php +++ b/resources/views/components/server/sidebar-proxy.blade.php @@ -70,7 +70,7 @@ @endphp
-
+
@can('viewSentinel', $server) -
+
limit(10) }} > Advanced | Coolify -
+
diff --git a/resources/views/livewire/server/ca-certificate/show.blade.php b/resources/views/livewire/server/ca-certificate/show.blade.php index afa027d87..dadb448d2 100644 --- a/resources/views/livewire/server/ca-certificate/show.blade.php +++ b/resources/views/livewire/server/ca-certificate/show.blade.php @@ -3,7 +3,7 @@ {{ data_get_str($server, 'name')->limit(10) }} > CA Certificate | Coolify -
+
diff --git a/resources/views/livewire/server/charts.blade.php b/resources/views/livewire/server/charts.blade.php index 1a9aebe50..db221c7ef 100644 --- a/resources/views/livewire/server/charts.blade.php +++ b/resources/views/livewire/server/charts.blade.php @@ -3,7 +3,7 @@ {{ data_get_str($server, 'name')->limit(10) }} > Metrics | Coolify -
+
diff --git a/resources/views/livewire/server/cloud-provider-token/show.blade.php b/resources/views/livewire/server/cloud-provider-token/show.blade.php index afb589c94..b98fb735a 100644 --- a/resources/views/livewire/server/cloud-provider-token/show.blade.php +++ b/resources/views/livewire/server/cloud-provider-token/show.blade.php @@ -3,7 +3,7 @@ {{ data_get_str($server, 'name')->limit(10) }} > Cloud Token | Coolify -
+
@if ($server->hetzner_server_id || $server->vultr_instance_id) diff --git a/resources/views/livewire/server/cloudflare-tunnel.blade.php b/resources/views/livewire/server/cloudflare-tunnel.blade.php index bca08c4ec..94b7061ec 100644 --- a/resources/views/livewire/server/cloudflare-tunnel.blade.php +++ b/resources/views/livewire/server/cloudflare-tunnel.blade.php @@ -3,7 +3,7 @@ {{ data_get_str($server, 'name')->limit(10) }} > Cloudflare Tunnel | Coolify -
+
diff --git a/resources/views/livewire/server/delete.blade.php b/resources/views/livewire/server/delete.blade.php index 1658e9864..132f54e10 100644 --- a/resources/views/livewire/server/delete.blade.php +++ b/resources/views/livewire/server/delete.blade.php @@ -3,7 +3,7 @@ {{ data_get_str($server, 'name')->limit(10) }} > Delete Server | Coolify -
+
@if ($server->id !== 0) diff --git a/resources/views/livewire/server/destinations.blade.php b/resources/views/livewire/server/destinations.blade.php index e2f334283..9de0ca10e 100644 --- a/resources/views/livewire/server/destinations.blade.php +++ b/resources/views/livewire/server/destinations.blade.php @@ -3,7 +3,7 @@ {{ data_get_str($server, 'name')->limit(10) }} > Destinations | Coolify -
+
@if ($server->isFunctional()) diff --git a/resources/views/livewire/server/docker-cleanup.blade.php b/resources/views/livewire/server/docker-cleanup.blade.php index 918d6ca61..52eff080e 100644 --- a/resources/views/livewire/server/docker-cleanup.blade.php +++ b/resources/views/livewire/server/docker-cleanup.blade.php @@ -3,7 +3,7 @@ {{ data_get_str($server, 'name')->limit(10) }} > Docker Cleanup | Coolify -
+
diff --git a/resources/views/livewire/server/log-drains.blade.php b/resources/views/livewire/server/log-drains.blade.php index 9afd5cf4e..4d971f53d 100644 --- a/resources/views/livewire/server/log-drains.blade.php +++ b/resources/views/livewire/server/log-drains.blade.php @@ -3,7 +3,7 @@ {{ data_get_str($server, 'name')->limit(10) }} > Log Drains | Coolify -
+
@if ($server->isFunctional()) diff --git a/resources/views/livewire/server/private-key/show.blade.php b/resources/views/livewire/server/private-key/show.blade.php index ee5162082..495e1596d 100644 --- a/resources/views/livewire/server/private-key/show.blade.php +++ b/resources/views/livewire/server/private-key/show.blade.php @@ -3,7 +3,7 @@ {{ data_get_str($server, 'name')->limit(10) }} > Private Key | Coolify -
+
diff --git a/resources/views/livewire/server/proxy/dynamic-configurations.blade.php b/resources/views/livewire/server/proxy/dynamic-configurations.blade.php index d8a9bb0f1..388e038bb 100644 --- a/resources/views/livewire/server/proxy/dynamic-configurations.blade.php +++ b/resources/views/livewire/server/proxy/dynamic-configurations.blade.php @@ -3,7 +3,7 @@ Proxy Dynamic Configuration | Coolify -
+
@if ($server->isFunctional())
diff --git a/resources/views/livewire/server/proxy/logs.blade.php b/resources/views/livewire/server/proxy/logs.blade.php index 98d3d1bdd..a4d250f7f 100644 --- a/resources/views/livewire/server/proxy/logs.blade.php +++ b/resources/views/livewire/server/proxy/logs.blade.php @@ -3,7 +3,7 @@ Proxy Logs | Coolify -
+

Logs

diff --git a/resources/views/livewire/server/proxy/show.blade.php b/resources/views/livewire/server/proxy/show.blade.php index 386b69609..8ed78eca6 100644 --- a/resources/views/livewire/server/proxy/show.blade.php +++ b/resources/views/livewire/server/proxy/show.blade.php @@ -4,7 +4,7 @@ @if ($server->isFunctional()) -
+
diff --git a/resources/views/livewire/server/resources.blade.php b/resources/views/livewire/server/resources.blade.php index 6d5ce55ad..cd6586309 100644 --- a/resources/views/livewire/server/resources.blade.php +++ b/resources/views/livewire/server/resources.blade.php @@ -3,7 +3,7 @@ {{ data_get_str($server, 'name')->limit(10) }} > Server Resources | Coolify -
+
diff --git a/resources/views/livewire/server/security/patches.blade.php b/resources/views/livewire/server/security/patches.blade.php index 14d142fb8..9915aa3ad 100644 --- a/resources/views/livewire/server/security/patches.blade.php +++ b/resources/views/livewire/server/security/patches.blade.php @@ -10,7 +10,7 @@ -
+
diff --git a/resources/views/livewire/server/security/terminal-access.blade.php b/resources/views/livewire/server/security/terminal-access.blade.php index 411ce7299..7612a8f4c 100644 --- a/resources/views/livewire/server/security/terminal-access.blade.php +++ b/resources/views/livewire/server/security/terminal-access.blade.php @@ -3,7 +3,7 @@ {{ data_get_str($server, 'name')->limit(10) }} > Terminal Access | Coolify -
+
diff --git a/resources/views/livewire/server/sentinel/logs.blade.php b/resources/views/livewire/server/sentinel/logs.blade.php index 9635a804e..940d77b52 100644 --- a/resources/views/livewire/server/sentinel/logs.blade.php +++ b/resources/views/livewire/server/sentinel/logs.blade.php @@ -3,7 +3,7 @@ Sentinel Logs | Coolify -
+

Logs

diff --git a/resources/views/livewire/server/sentinel/show.blade.php b/resources/views/livewire/server/sentinel/show.blade.php index e684c874a..977f1bc68 100644 --- a/resources/views/livewire/server/sentinel/show.blade.php +++ b/resources/views/livewire/server/sentinel/show.blade.php @@ -4,7 +4,7 @@ @if ($server->isFunctional()) -
+
diff --git a/resources/views/livewire/server/show.blade.php b/resources/views/livewire/server/show.blade.php index eb7bdd473..3111e83f0 100644 --- a/resources/views/livewire/server/show.blade.php +++ b/resources/views/livewire/server/show.blade.php @@ -4,7 +4,7 @@ {{ data_get_str($server, 'name')->limit(10) }} > General | Coolify -
+
diff --git a/resources/views/livewire/server/swarm.blade.php b/resources/views/livewire/server/swarm.blade.php index 005e1814d..15450c78c 100644 --- a/resources/views/livewire/server/swarm.blade.php +++ b/resources/views/livewire/server/swarm.blade.php @@ -3,7 +3,7 @@ {{ data_get_str($server, 'name')->limit(10) }} > Swarm | Coolify -
+
From 2dd264f8024342e28872be44b16143b81f45a5b7 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 8 Jul 2026 10:23:31 +0200 Subject: [PATCH 6/6] fix(vultr): remove duplicate deletion error log --- app/Actions/Server/DeleteServer.php | 6 ------ 1 file changed, 6 deletions(-) diff --git a/app/Actions/Server/DeleteServer.php b/app/Actions/Server/DeleteServer.php index 3e4bc6e5d..1ae919c28 100644 --- a/app/Actions/Server/DeleteServer.php +++ b/app/Actions/Server/DeleteServer.php @@ -134,12 +134,6 @@ private function deleteFromVultrById(string $vultrInstanceId, ?int $cloudProvide 'vultr_instance_id' => $vultrInstanceId, 'team_id' => $teamId, ]); - - logger()->error('Failed to delete server from Vultr', [ - 'error' => $e->getMessage(), - 'vultr_instance_id' => $vultrInstanceId, - 'team_id' => $teamId, - ]); } } }