From 94abbe15901927a1cb4e4fadc5ae98a3f1678d9f Mon Sep 17 00:00:00 2001 From: Cornelis Terblanche Date: Wed, 3 Jun 2026 20:17:05 +0200 Subject: [PATCH] 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(); +});