diff --git a/app/Actions/Server/DeleteServer.php b/app/Actions/Server/DeleteServer.php index c6f0320133..428d696fdc 100644 --- a/app/Actions/Server/DeleteServer.php +++ b/app/Actions/Server/DeleteServer.php @@ -8,6 +8,7 @@ use App\Models\Team; use App\Notifications\Server\HetznerDeletionFailed; use App\Services\DigitalOceanService; use App\Services\HetznerService; +use App\Services\HostingerService; use App\Services\VultrService; use Lorisleiva\Actions\Concerns\AsAction; @@ -15,7 +16,7 @@ class DeleteServer { use AsAction; - public function handle(int $serverId, bool $deleteFromHetzner = false, ?int $hetznerServerId = null, ?int $cloudProviderTokenId = null, ?int $teamId = null, bool $deleteFromVultr = false, ?string $vultrInstanceId = null, bool $deleteFromDigitalOcean = false, ?int $digitalOceanDropletId = null) + public function handle(int $serverId, bool $deleteFromHetzner = false, ?int $hetznerServerId = null, ?int $cloudProviderTokenId = null, ?int $teamId = null, bool $deleteFromVultr = false, ?string $vultrInstanceId = null, bool $deleteFromDigitalOcean = false, ?int $digitalOceanDropletId = null, bool $deleteFromHostinger = false, ?int $hostingerVirtualMachineId = null) { $server = Server::withTrashed()->find($serverId); @@ -44,6 +45,14 @@ class DeleteServer ); } + if ($deleteFromHostinger && ($hostingerVirtualMachineId || ($server && $server->hostinger_virtual_machine_id))) { + $this->deleteFromHostingerById( + $hostingerVirtualMachineId ?? $server->hostinger_virtual_machine_id, + $cloudProviderTokenId ?? $server->cloud_provider_token_id, + $teamId ?? $server->team_id + ); + } + logger()->debug($server ? 'Deleting server from Coolify' : 'Server already deleted from Coolify, skipping Coolify deletion'); // If server is already deleted from Coolify, skip this part @@ -182,4 +191,50 @@ class DeleteServer throw $e; } } + + private function deleteFromHostingerById(int $hostingerVirtualMachineId, ?int $cloudProviderTokenId, int $teamId): void + { + try { + $token = null; + + if ($cloudProviderTokenId) { + $token = CloudProviderToken::where('id', $cloudProviderTokenId) + ->where('team_id', $teamId) + ->where('provider', 'hostinger') + ->first(); + } + + if (! $token) { + $token = CloudProviderToken::where('team_id', $teamId) + ->where('provider', 'hostinger') + ->first(); + } + + if (! $token) { + throw new \RuntimeException('No Hostinger token found for the server team.'); + } + + $hostingerService = new HostingerService($token->token); + $subscriptionId = $hostingerService->getVirtualMachine($hostingerVirtualMachineId)['subscription_id'] ?? null; + + if (! $subscriptionId) { + throw new \RuntimeException('The Hostinger VPS has no subscription.'); + } + + $hostingerService->disableAutoRenewal($subscriptionId); + + logger()->debug('Disabled Hostinger auto-renewal, so Hostinger deletes the VPS at the end of the paid period', [ + 'hostinger_virtual_machine_id' => $hostingerVirtualMachineId, + 'team_id' => $teamId, + ]); + } catch (\Throwable $e) { + logger()->error('Failed to delete VPS from Hostinger', [ + 'error' => $e->getMessage(), + 'hostinger_virtual_machine_id' => $hostingerVirtualMachineId, + 'team_id' => $teamId, + ]); + + throw $e; + } + } } diff --git a/app/Actions/Server/ValidateServer.php b/app/Actions/Server/ValidateServer.php index 9bb15c2053..7c973130c4 100644 --- a/app/Actions/Server/ValidateServer.php +++ b/app/Actions/Server/ValidateServer.php @@ -63,6 +63,15 @@ class ValidateServer } } + if ($server->hostinger_virtual_machine_id) { + $status = $server->refreshHostingerState(); + if (in_array($status, ['stopping', 'stopped', 'suspending', 'suspended', 'destroying', 'destroyed', 'error'], true)) { + $this->error = 'Hostinger VPS 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 ad699e8f12..eca3397493 100644 --- a/app/Http/Controllers/Api/CloudProviderTokensController.php +++ b/app/Http/Controllers/Api/CloudProviderTokensController.php @@ -45,6 +45,10 @@ class CloudProviderTokensController extends Controller 'vultr' => Http::withHeaders([ 'Authorization' => 'Bearer '.$token, ])->timeout(10)->get('https://api.vultr.com/v2/account'), + 'hostinger' => Http::withToken($token) + ->acceptJson() + ->timeout(10) + ->get('https://developers.hostinger.com/api/vps/v1/virtual-machines'), default => null, }; @@ -90,7 +94,7 @@ class CloudProviderTokensController extends Controller properties: [ 'uuid' => ['type' => 'string'], 'name' => ['type' => 'string'], - 'provider' => ['type' => 'string', 'enum' => ['hetzner', 'digitalocean', 'vultr']], + 'provider' => ['type' => 'string', 'enum' => ['hetzner', 'digitalocean', 'vultr', 'hostinger']], 'team_id' => ['type' => 'integer'], 'servers_count' => ['type' => 'integer'], 'created_at' => ['type' => 'string'], @@ -208,7 +212,7 @@ class CloudProviderTokensController extends Controller type: 'object', required: ['provider', 'token', 'name'], properties: [ - 'provider' => ['type' => 'string', 'enum' => ['hetzner', 'digitalocean', 'vultr'], 'example' => 'hetzner', 'description' => 'The cloud provider.'], + 'provider' => ['type' => 'string', 'enum' => ['hetzner', 'digitalocean', 'vultr', 'hostinger'], '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.'], ], @@ -263,7 +267,7 @@ class CloudProviderTokensController extends Controller $body = $request->json()->all(); $validator = customApiValidator($body, [ - 'provider' => 'required|string|in:hetzner,digitalocean,vultr', + 'provider' => 'required|string|in:hetzner,digitalocean,vultr,hostinger', 'token' => 'required|string', 'name' => 'required|string|max:255', ]); diff --git a/app/Http/Controllers/Api/HostingerController.php b/app/Http/Controllers/Api/HostingerController.php new file mode 100644 index 0000000000..84017e7ded --- /dev/null +++ b/app/Http/Controllers/Api/HostingerController.php @@ -0,0 +1,365 @@ + []]], + tags: ['Hostinger'], + responses: [ + new OA\Response(response: 200, description: 'List of Hostinger VPS data centers.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 403, description: 'Hostinger denied access to this resource for the cloud provider token.'), + new OA\Response(response: 422, description: 'Validation failed.'), + new OA\Response(response: 429, description: 'Hostinger rate limit exceeded.'), + ] + )] + public function dataCenters(Request $request): JsonResponse + { + return $this->providerData($request, 'getDataCenters', 'data centers'); + } + + #[OA\Get( + path: '/hostinger/catalog', + operationId: 'get-hostinger-catalog', + summary: 'Get Hostinger VPS plans and prices', + security: [['bearerAuth' => []]], + tags: ['Hostinger'], + responses: [ + new OA\Response(response: 200, description: 'List of Hostinger VPS plans and prices.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 403, description: 'Hostinger denied access to this resource for the cloud provider token.'), + new OA\Response(response: 422, description: 'Validation failed.'), + new OA\Response(response: 429, description: 'Hostinger rate limit exceeded.'), + ] + )] + public function catalog(Request $request): JsonResponse + { + return $this->providerData($request, 'getCatalogItems', 'catalog'); + } + + #[OA\Get( + path: '/hostinger/templates', + operationId: 'get-hostinger-templates', + summary: 'Get Hostinger VPS operating system templates', + security: [['bearerAuth' => []]], + tags: ['Hostinger'], + responses: [ + new OA\Response(response: 200, description: 'List of Hostinger VPS templates.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 403, description: 'Hostinger denied access to this resource for the cloud provider token.'), + new OA\Response(response: 422, description: 'Validation failed.'), + new OA\Response(response: 429, description: 'Hostinger rate limit exceeded.'), + ] + )] + public function templates(Request $request): JsonResponse + { + return $this->providerData($request, 'getTemplates', 'templates'); + } + + #[OA\Get( + path: '/hostinger/ssh-keys', + operationId: 'get-hostinger-ssh-keys', + summary: 'Get Hostinger account SSH keys', + security: [['bearerAuth' => []]], + tags: ['Hostinger'], + responses: [ + new OA\Response(response: 200, description: 'List of Hostinger account SSH keys.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 403, description: 'Hostinger denied access to this resource for the cloud provider token.'), + new OA\Response(response: 422, description: 'Validation failed.'), + new OA\Response(response: 429, description: 'Hostinger rate limit exceeded.'), + ] + )] + public function sshKeys(Request $request): JsonResponse + { + return $this->providerData($request, 'getPublicKeys', 'SSH keys'); + } + + #[OA\Get( + path: '/hostinger/post-install-scripts', + operationId: 'get-hostinger-post-install-scripts', + summary: 'Get Hostinger post-install scripts', + security: [['bearerAuth' => []]], + tags: ['Hostinger'], + responses: [ + new OA\Response(response: 200, description: 'List of Hostinger post-install scripts.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 403, description: 'Hostinger denied access to this resource for the cloud provider token.'), + new OA\Response(response: 422, description: 'Validation failed.'), + new OA\Response(response: 429, description: 'Hostinger rate limit exceeded.'), + ] + )] + public function postInstallScripts(Request $request): JsonResponse + { + return $this->providerData($request, 'getPostInstallScripts', 'post-install scripts'); + } + + #[OA\Post( + path: '/servers/hostinger', + operationId: 'create-hostinger-server', + summary: 'Purchase a Hostinger VPS and create a linked server', + security: [['bearerAuth' => []]], + tags: ['Hostinger'], + responses: [ + new OA\Response(response: 201, description: 'Hostinger VPS purchased and linked to a Coolify server.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 422, description: 'Validation failed.'), + new OA\Response(response: 429, description: 'Hostinger rate limit exceeded.'), + ] + )] + public function createServer(Request $request): JsonResponse + { + $allowedFields = [ + 'cloud_provider_token_uuid', + 'cloud_provider_token_id', + 'item_id', + 'data_center_id', + 'template_id', + 'name', + 'private_key_uuid', + 'enable_backups', + 'public_key_ids', + 'post_install_script_id', + 'instant_validate', + ]; + $teamId = getTeamIdFromToken(); + + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $this->authorize('create', [Server::class]); + + $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', + 'item_id' => 'required|string', + 'data_center_id' => 'required|integer', + 'template_id' => 'required|integer', + 'name' => ['nullable', 'string', 'max:253', new ValidHostname], + 'private_key_uuid' => 'required|string', + 'enable_backups' => 'nullable|boolean', + 'public_key_ids' => 'nullable|array', + 'public_key_ids.*' => 'integer', + 'post_install_script_id' => 'nullable|integer', + '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); + } + + $token = $this->hostingerToken($request, $teamId); + if ($token instanceof JsonResponse) { + return $token; + } + + $privateKey = PrivateKey::whereTeamId($teamId)->whereUuid($request->private_key_uuid)->first(); + if (! $privateKey) { + return response()->json(['message' => 'Private key not found.'], 404); + } + + $virtualMachineId = null; + + try { + $hostingerService = new HostingerService($token->token); + $normalizedServerName = strtolower(trim($request->name ?: generate_random_name())); + $setup = [ + 'data_center_id' => $request->integer('data_center_id'), + 'template_id' => $request->integer('template_id'), + 'hostname' => $normalizedServerName, + 'enable_backups' => $request->boolean('enable_backups'), + 'public_key' => [ + 'name' => $privateKey->name, + 'key' => $privateKey->getPublicKey(), + ], + ]; + if ($request->filled('post_install_script_id')) { + $setup['post_install_script_id'] = $request->integer('post_install_script_id'); + } + + $virtualMachine = $hostingerService->purchaseVirtualMachine([ + 'item_id' => $request->item_id, + 'setup' => $setup, + ]); + $virtualMachineId = (int) $virtualMachine['id']; + + $server = Server::create([ + 'name' => $normalizedServerName, + 'ip' => Server::PLACEHOLDER_IP, + 'user' => 'root', + 'port' => 22, + 'team_id' => $teamId, + 'private_key_id' => $privateKey->id, + 'cloud_provider_token_id' => $token->id, + 'hostinger_virtual_machine_id' => $virtualMachine['id'], + 'hostinger_virtual_machine_status' => $virtualMachine['state'] ?? null, + ]); + + $server->proxy->set('status', 'exited'); + $server->proxy->set('type', ProxyTypes::TRAEFIK->value); + $server->save(); + + try { + if ($request->array('public_key_ids')) { + $hostingerService->attachPublicKeys((int) $virtualMachine['id'], $request->array('public_key_ids')); + } + $virtualMachine = $hostingerService->waitForPublicIp($virtualMachine); + $ipAddress = $hostingerService->getPublicIpAddress($virtualMachine); + if ($ipAddress) { + $server->update([ + 'ip' => $ipAddress, + 'hostinger_virtual_machine_status' => $virtualMachine['state'] ?? $server->hostinger_virtual_machine_status, + ]); + } + } catch (\Throwable $e) { + report($e); + } + + $ipAddress = $server->fresh()->ip; + + if ($request->boolean('instant_validate') && ! $server->hasPlaceholderIp()) { + ValidateServer::dispatch($server); + } + + auditLog('api.hostinger_virtual_machine.created', [ + 'team_id' => $teamId, + 'server_uuid' => $server->uuid, + 'server_name' => $server->name, + 'hostinger_virtual_machine_id' => $virtualMachine['id'], + 'ip' => $ipAddress, + ]); + + return response()->json([ + 'uuid' => $server->uuid, + 'hostinger_virtual_machine_id' => $virtualMachine['id'], + 'ip' => $ipAddress, + 'provisioning' => $ipAddress === Server::PLACEHOLDER_IP, + ])->setStatusCode(201); + } catch (RateLimitException $e) { + return $this->rateLimitResponse($e); + } catch (\Throwable $e) { + logger()->error('Failed to create Hostinger server', [ + 'error' => $e->getMessage(), + 'team_id' => $teamId, + 'hostinger_virtual_machine_id' => $virtualMachineId, + ]); + + if ($e->getCode() === 202) { + return response()->json(['message' => $e->getMessage()], 202); + } + + return response()->json(array_filter([ + 'message' => $virtualMachineId + ? 'The Hostinger VPS was purchased but could not be saved in Coolify. Manage it in hPanel.' + : 'Failed to create Hostinger server.', + 'hostinger_virtual_machine_id' => $virtualMachineId, + ]), 500); + } + } + + private function providerData(Request $request, string $method, string $resource): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $token = $this->hostingerToken($request, $teamId); + if ($token instanceof JsonResponse) { + return $token; + } + + try { + return response()->json((new HostingerService($token->token))->{$method}()); + } catch (RateLimitException $e) { + return $this->rateLimitResponse($e); + } catch (\Throwable $e) { + $hostingerErrorPrefix = 'Hostinger API error: '; + if (in_array($e->getCode(), [401, 403], true) && str_starts_with($e->getMessage(), $hostingerErrorPrefix)) { + $hostingerMessage = substr($e->getMessage(), strlen($hostingerErrorPrefix)); + + return response()->json(['message' => "Hostinger denied access to {$resource}: {$hostingerMessage}"], 403); + } + + return response()->json(['message' => "Failed to fetch Hostinger {$resource}."], 500); + } + } + + private function rateLimitResponse(RateLimitException $e): JsonResponse + { + $response = response()->json(['message' => $e->getMessage()], 429); + if ($e->retryAfter !== null) { + $response->header('Retry-After', $e->retryAfter); + } + + return $response; + } + + private function hostingerToken(Request $request, int $teamId): CloudProviderToken|JsonResponse + { + $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); + } + + $tokenUuid = $request->cloud_provider_token_uuid ?? $request->cloud_provider_token_id; + $token = CloudProviderToken::whereTeamId($teamId) + ->whereUuid($tokenUuid) + ->where('provider', 'hostinger') + ->first(); + + if (! $token) { + return response()->json(['message' => 'Hostinger cloud provider token not found.'], 404); + } + + $this->authorize('view', $token); + + return $token; + } +} diff --git a/app/Jobs/ServerCloudProviderStatusCheckJob.php b/app/Jobs/ServerCloudProviderStatusCheckJob.php index cd2e51df7d..46d0898c93 100644 --- a/app/Jobs/ServerCloudProviderStatusCheckJob.php +++ b/app/Jobs/ServerCloudProviderStatusCheckJob.php @@ -47,6 +47,9 @@ class ServerCloudProviderStatusCheckJob implements ShouldBeEncrypted, ShouldQueu 'digitalocean' => $this->server->digitalocean_droplet_id ? $this->server->refreshDigitalOceanState() : null, + 'hostinger' => $this->server->hostinger_virtual_machine_id + ? $this->server->refreshHostingerState() + : null, default => null, }; } catch (\Throwable $e) { diff --git a/app/Jobs/ServerManagerJob.php b/app/Jobs/ServerManagerJob.php index a8f2a50a31..ac1c551982 100644 --- a/app/Jobs/ServerManagerJob.php +++ b/app/Jobs/ServerManagerJob.php @@ -99,7 +99,8 @@ class ServerManagerJob implements ShouldBeEncrypted, ShouldQueue $servers->each(function (Server $server) { $hasCloudResource = $server->hetzner_server_id || $server->vultr_instance_id - || $server->digitalocean_droplet_id; + || $server->digitalocean_droplet_id + || $server->hostinger_virtual_machine_id; if ($hasCloudResource && $server->cloudProviderToken) { ServerCloudProviderStatusCheckJob::dispatch($server); diff --git a/app/Livewire/Security/CloudProviderToken/Show.php b/app/Livewire/Security/CloudProviderToken/Show.php index d8006f7740..f2389628bd 100644 --- a/app/Livewire/Security/CloudProviderToken/Show.php +++ b/app/Livewire/Security/CloudProviderToken/Show.php @@ -85,6 +85,7 @@ class Show extends Component 'hetzner' => $this->validateHetznerToken($this->cloudProviderToken->token), 'digitalocean' => $this->validateDigitalOceanToken($this->cloudProviderToken->token), 'vultr' => $this->validateVultrToken($this->cloudProviderToken->token), + 'hostinger' => $this->validateHostingerToken($this->cloudProviderToken->token), default => false, }; @@ -141,6 +142,7 @@ class Show extends Component return match ($this->cloudProviderToken->provider) { 'digitalocean' => 'DigitalOcean', 'vultr' => 'Vultr', + 'hostinger' => 'Hostinger', default => 'Hetzner', }; } @@ -181,6 +183,19 @@ class Show extends Component } } + private function validateHostingerToken(string $token): bool + { + try { + return Http::withToken($token) + ->acceptJson() + ->timeout(10) + ->get('https://developers.hostinger.com/api/vps/v1/virtual-machines') + ->successful(); + } catch (\Throwable) { + return false; + } + } + public function render() { return view('livewire.security.cloud-provider-token.show'); diff --git a/app/Livewire/Security/CloudProviderTokenForm.php b/app/Livewire/Security/CloudProviderTokenForm.php index 2c31d22035..4bdf3f5f3a 100644 --- a/app/Livewire/Security/CloudProviderTokenForm.php +++ b/app/Livewire/Security/CloudProviderTokenForm.php @@ -5,6 +5,7 @@ namespace App\Livewire\Security; use App\Livewire\Server\CloudProviderToken\Show as ServerCloudProviderTokenShow; use App\Livewire\Server\New\ByDigitalOcean; use App\Livewire\Server\New\ByHetzner; +use App\Livewire\Server\New\ByHostinger; use App\Models\CloudProviderToken; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Http; @@ -41,7 +42,7 @@ class CloudProviderTokenForm extends Component protected function rules(): array { return [ - 'provider' => 'required|string|in:hetzner,digitalocean,vultr', + 'provider' => 'required|string|in:hetzner,digitalocean,vultr,hostinger', 'token' => 'required|string', 'name' => 'required|string|max:255', 'description' => 'nullable|string|max:1000', @@ -86,6 +87,15 @@ class CloudProviderTokenForm extends Component return $response->successful(); } + if ($provider === 'hostinger') { + $response = Http::withToken($token) + ->acceptJson() + ->timeout(10) + ->get('https://developers.hostinger.com/api/vps/v1/virtual-machines'); + + return $response->successful(); + } + return false; } catch (\Throwable $e) { return false; @@ -128,6 +138,7 @@ class CloudProviderTokenForm extends Component if ($savedToken->provider === 'digitalocean') { $this->dispatch('tokenAdded.digitalocean', tokenId: $savedToken->id)->to(ByDigitalOcean::class); + $this->dispatch('tokenAdded.digitalocean', tokenId: $savedToken->id)->to(ServerCloudProviderTokenShow::class); } if ($savedToken->provider === 'hetzner') { @@ -135,6 +146,11 @@ class CloudProviderTokenForm extends Component $this->dispatch('tokenAdded.hetzner', tokenId: $savedToken->id)->to(ServerCloudProviderTokenShow::class); } + if ($savedToken->provider === 'hostinger') { + $this->dispatch('tokenAdded.hostinger', tokenId: $savedToken->id)->to(ByHostinger::class); + $this->dispatch('tokenAdded.hostinger', tokenId: $savedToken->id)->to(ServerCloudProviderTokenShow::class); + } + if ($this->modal_mode) { $this->dispatch('close-modal'); } diff --git a/app/Livewire/Security/CloudProviderTokens.php b/app/Livewire/Security/CloudProviderTokens.php index d16c729fca..549576ff6f 100644 --- a/app/Livewire/Security/CloudProviderTokens.php +++ b/app/Livewire/Security/CloudProviderTokens.php @@ -59,6 +59,13 @@ class CloudProviderTokens extends Component } else { $this->dispatch('error', 'Vultr token validation failed. Please check the token.'); } + } elseif ($token->provider === 'hostinger') { + $isValid = $this->validateHostingerToken($token->token); + if ($isValid) { + $this->dispatch('success', 'Hostinger token is valid.'); + } else { + $this->dispatch('error', 'Hostinger token validation failed. Please check the token.'); + } } else { $this->dispatch('error', 'Unknown provider.'); } @@ -114,6 +121,19 @@ class CloudProviderTokens extends Component } } + private function validateHostingerToken(string $token): bool + { + try { + return Http::withToken($token) + ->acceptJson() + ->timeout(10) + ->get('https://developers.hostinger.com/api/vps/v1/virtual-machines') + ->successful(); + } catch (\Throwable) { + 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 e8216d0549..9d27340013 100644 --- a/app/Livewire/Server/CloudProviderToken/Show.php +++ b/app/Livewire/Server/CloudProviderToken/Show.php @@ -36,13 +36,25 @@ class Show extends Component { return [ 'tokenAdded.hetzner' => 'handleTokenAdded', + 'tokenAdded.digitalocean' => 'handleTokenAdded', + 'tokenAdded.hostinger' => 'handleTokenAdded', ]; } public function loadTokens() { - $this->provider = $this->server->vultr_instance_id ? 'vultr' : 'hetzner'; - $this->providerName = $this->provider === 'vultr' ? 'Vultr' : 'Hetzner'; + $this->provider = match (true) { + filled($this->server->vultr_instance_id) => 'vultr', + filled($this->server->digitalocean_droplet_id) => 'digitalocean', + filled($this->server->hostinger_virtual_machine_id) => 'hostinger', + default => 'hetzner', + }; + $this->providerName = match ($this->provider) { + 'vultr' => 'Vultr', + 'digitalocean' => 'DigitalOcean', + 'hostinger' => 'Hostinger', + default => 'Hetzner', + }; $this->cloudProviderTokens = CloudProviderToken::where('team_id', $this->server->team_id) ->where('provider', $this->provider) @@ -96,9 +108,7 @@ class Show extends Component private function validateTokenForServer(CloudProviderToken $token): array { try { - $endpoint = $token->provider === 'vultr' - ? 'https://api.vultr.com/v2/account' - : 'https://api.hetzner.cloud/v1/servers'; + $endpoint = $this->validationEndpoint($token->provider); $response = Http::withHeaders([ 'Authorization' => 'Bearer '.$token->token, @@ -137,6 +147,33 @@ class Show extends Component } } + if ($this->server->digitalocean_droplet_id) { + $serverResponse = Http::withToken($token->token) + ->timeout(10) + ->get("https://api.digitalocean.com/v2/droplets/{$this->server->digitalocean_droplet_id}"); + + if (! $serverResponse->successful()) { + return [ + 'valid' => false, + 'error' => 'This token cannot access this droplet. It may belong to a different DigitalOcean account.', + ]; + } + } + + if ($this->server->hostinger_virtual_machine_id) { + $serverResponse = Http::withToken($token->token) + ->acceptJson() + ->timeout(10) + ->get("https://developers.hostinger.com/api/vps/v1/virtual-machines/{$this->server->hostinger_virtual_machine_id}"); + + if (! $serverResponse->successful()) { + return [ + 'valid' => false, + 'error' => 'This token cannot access this VPS. It may belong to a different Hostinger account.', + ]; + } + } + return ['valid' => true]; } catch (\Throwable $e) { return [ @@ -156,9 +193,7 @@ class Show extends Component return; } - $endpoint = $token->provider === 'vultr' - ? 'https://api.vultr.com/v2/account' - : 'https://api.hetzner.cloud/v1/servers'; + $endpoint = $this->validationEndpoint($token->provider); $response = Http::withHeaders([ 'Authorization' => 'Bearer '.$token->token, @@ -184,6 +219,16 @@ class Show extends Component } } + private function validationEndpoint(string $provider): string + { + return match ($provider) { + 'vultr' => 'https://api.vultr.com/v2/account', + 'digitalocean' => 'https://api.digitalocean.com/v2/account', + 'hostinger' => 'https://developers.hostinger.com/api/vps/v1/virtual-machines', + default => 'https://api.hetzner.cloud/v1/servers', + }; + } + public function render() { return view('livewire.server.cloud-provider-token.show'); diff --git a/app/Livewire/Server/Create.php b/app/Livewire/Server/Create.php index 7edfcaf72b..1f1aae51ea 100644 --- a/app/Livewire/Server/Create.php +++ b/app/Livewire/Server/Create.php @@ -21,7 +21,7 @@ class Create extends Component public function mount(?string $selectedType = null, ?string $selectedTokenUuid = null): void { - $this->selectedType = in_array($selectedType, ['hetzner', 'vultr', 'digital-ocean', 'manual'], true) + $this->selectedType = in_array($selectedType, ['hetzner', 'vultr', 'digital-ocean', 'hostinger', 'manual'], true) ? $selectedType : null; $this->selectedTokenUuid = $this->selectedType && $this->selectedType !== 'manual' ? $selectedTokenUuid : null; diff --git a/app/Livewire/Server/CreatePage.php b/app/Livewire/Server/CreatePage.php index 158f28351b..be901b9db6 100644 --- a/app/Livewire/Server/CreatePage.php +++ b/app/Livewire/Server/CreatePage.php @@ -28,12 +28,14 @@ class CreatePage extends Component 'hetzner' => 'hetzner', 'vultr' => 'vultr', 'digital-ocean' => 'digitalocean', + 'hostinger' => 'hostinger', default => null, }; $this->tokenProviderName = match ($type) { 'hetzner' => 'Hetzner', 'vultr' => 'Vultr', 'digital-ocean' => 'DigitalOcean', + 'hostinger' => 'Hostinger', default => null, }; $this->hasProviderTokens = $this->tokenProvider @@ -43,6 +45,7 @@ class CreatePage extends Component 'hetzner' => 'Hetzner', 'vultr' => 'Vultr', 'digital-ocean' => 'DigitalOcean', + 'hostinger' => 'Hostinger', 'manual' => 'Manual', default => 'New Server', }; diff --git a/app/Livewire/Server/Delete.php b/app/Livewire/Server/Delete.php index 359f94e0fb..c3539aa282 100644 --- a/app/Livewire/Server/Delete.php +++ b/app/Livewire/Server/Delete.php @@ -20,6 +20,8 @@ class Delete extends Component public bool $delete_from_digitalocean = false; + public bool $delete_from_hostinger = false; + public bool $force_delete_resources = false; public function mount(string $server_uuid) @@ -41,6 +43,7 @@ class Delete extends Component $this->delete_from_hetzner = in_array('delete_from_hetzner', $selectedActions); $this->delete_from_vultr = in_array('delete_from_vultr', $selectedActions); $this->delete_from_digitalocean = in_array('delete_from_digitalocean', $selectedActions); + $this->delete_from_hostinger = in_array('delete_from_hostinger', $selectedActions); $this->force_delete_resources = in_array('force_delete_resources', $selectedActions); } try { @@ -72,7 +75,9 @@ class Delete extends Component $this->delete_from_vultr, $this->server->vultr_instance_id, $this->delete_from_digitalocean, - $this->server->digitalocean_droplet_id + $this->server->digitalocean_droplet_id, + $this->delete_from_hostinger, + $this->server->hostinger_virtual_machine_id ); return redirectRoute($this, 'server.index'); @@ -118,6 +123,14 @@ class Delete extends Component ]; } + if ($this->server->hostinger_virtual_machine_id) { + $checkboxes[] = [ + 'id' => 'delete_from_hostinger', + 'label' => 'Also delete VPS from Hostinger (at the end of the paid period)', + 'default_warning' => 'The VPS on Hostinger will NOT be deleted, and Hostinger will keep billing for it.', + ]; + } + return view('livewire.server.delete', [ 'checkboxes' => $checkboxes, ]); diff --git a/app/Livewire/Server/New/ByHostinger.php b/app/Livewire/Server/New/ByHostinger.php new file mode 100644 index 0000000000..c05fcf7d5d --- /dev/null +++ b/app/Livewire/Server/New/ByHostinger.php @@ -0,0 +1,514 @@ +authorize('viewAny', CloudProviderToken::class); + $this->loadTokens(); + $this->selectTokenFromUrl($selectedTokenUuid); + $this->server_name = generate_random_name(); + $this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get(); + + if ($this->private_keys->isNotEmpty()) { + $this->private_key_id = $this->private_keys->first()->id; + } + + if ($this->selectedTokenUuid) { + $this->current_step = 2; + $this->loading_data = true; + } + + return null; + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function getListeners(): array + { + return [ + 'tokenAdded.hostinger' => 'handleTokenAdded', + 'privateKeyCreated' => 'handlePrivateKeyCreated', + 'modalClosed' => 'resetSelection', + ]; + } + + 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_data_center_id' => 'required|integer', + 'selected_template_id' => 'required|integer', + 'selected_price_id' => 'required|string', + 'private_key_id' => 'required|integer|exists:private_keys,id,team_id,'.currentTeam()->id, + 'enable_backups' => 'required|boolean', + 'selected_public_key_ids' => 'array', + 'selected_public_key_ids.*' => 'integer', + 'selected_post_install_script_id' => 'nullable|integer', + ]); + } + + return $rules; + } + + protected function messages(): array + { + return [ + 'selected_token_id.required' => 'Please select a Hostinger token.', + 'selected_token_id.exists' => 'Selected token not found.', + ]; + } + + public function loadTokens(): void + { + $this->available_tokens = CloudProviderToken::ownedByCurrentTeam() + ->where('provider', 'hostinger') + ->get(); + } + + public function handleTokenAdded(int $tokenId): mixed + { + $this->loadTokens(); + $this->selected_token_id = $tokenId; + + return $this->nextStep(); + } + + public function handlePrivateKeyCreated(int $keyId): void + { + $this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get(); + $this->private_key_id = $keyId; + $this->resetErrorBag('private_key_id'); + } + + public function resetSelection(): void + { + $this->selected_token_id = null; + $this->current_step = 1; + } + + public function nextStep(): mixed + { + $this->validate([ + 'selected_token_id' => 'required|integer|exists:cloud_provider_tokens,id', + ]); + + try { + if (! $this->selectedTokenUuid) { + $token = $this->available_tokens->firstWhere('id', $this->selected_token_id); + + if ($token) { + return $this->redirectRoute('server.create.token', [ + 'type' => 'hostinger', + 'token_uuid' => $token->uuid, + ], navigate: true); + } + } + + $this->current_step = 2; + $this->loading_data = true; + + return null; + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function loadHostingerData(): void + { + $token = $this->getHostingerToken(); + + if (! $token) { + $this->loading_data = false; + $this->dispatch('error', 'Please select a valid Hostinger token.'); + + return; + } + + $this->loading_data = true; + $this->provider_data_error = null; + + try { + $hostingerService = new HostingerService($token); + $this->data_centers = $hostingerService->getDataCenters(); + $this->templates = collect($hostingerService->getTemplates()) + ->sortBy('name') + ->values() + ->toArray(); + $this->catalog_items = $hostingerService->getCatalogItems(); + $this->hostinger_public_keys = $this->optionalProviderData(fn () => $hostingerService->getPublicKeys()); + $this->post_install_scripts = $this->optionalProviderData(fn () => $hostingerService->getPostInstallScripts()); + $this->selectDefaultTemplate(); + } catch (\Throwable $e) { + $this->provider_data_error = $e->getMessage(); + $this->dispatch('error', $this->provider_data_error); + } finally { + $this->loading_data = false; + } + } + + public function updatedSelectedOsTemplateId(): void + { + if ($this->selected_os_template_id !== null) { + $this->selected_app_template_id = null; + } + + $this->selected_template_id = $this->selected_os_template_id ?? $this->selected_app_template_id; + } + + public function updatedSelectedAppTemplateId(): void + { + if ($this->selected_app_template_id !== null) { + $this->selected_os_template_id = null; + } + + $this->selected_template_id = $this->selected_app_template_id ?? $this->selected_os_template_id; + } + + /** + * Plain operating system templates. + */ + public function getOsTemplatesProperty(): array + { + return collect($this->templates) + ->reject(fn (array $template): bool => $this->templateIncludesApplication($template)) + ->values() + ->all(); + } + + /** + * Operating system templates that also install an application or control panel. + */ + public function getAppTemplatesProperty(): array + { + return collect($this->templates) + ->filter(fn (array $template): bool => $this->templateIncludesApplication($template)) + ->values() + ->all(); + } + + public function getPriceOptionsProperty(): array + { + return collect($this->catalog_items) + ->flatMap(function (array $item): array { + $planSize = $this->planSize($item); + + return collect($item['prices'] ?? []) + ->map(fn (array $price): array => array_merge($price, [ + 'plan_name' => $item['name'] ?? $item['id'] ?? 'VPS', + 'plan_size' => $planSize, + ])) + ->all(); + }) + ->sort(fn (array $a, array $b): int => [$a['plan_size'], $this->periodInMonths($a)] <=> [$b['plan_size'], $this->periodInMonths($b)]) + ->values() + ->toArray(); + } + + public function getSelectedPriceProperty(): ?array + { + return collect($this->priceOptions)->firstWhere('id', $this->selected_price_id); + } + + public function priceLabel(array $price): string + { + $currency = strtoupper($price['currency'] ?? 'USD'); + $firstPeriodPrice = (int) ($price['first_period_price'] ?? $price['price'] ?? 0); + $renewalPrice = (int) ($price['price'] ?? $firstPeriodPrice); + $period = (int) ($price['period'] ?? 1); + $periodUnit = $price['period_unit'] ?? 'month'; + $periodLabel = $period.' '.str($periodUnit)->plural($period); + + return sprintf( + '%s — %s %.2f for %s, then %s %.2f', + $price['plan_name'] ?? 'VPS', + $currency, + $firstPeriodPrice / 100, + $periodLabel, + $currency, + $renewalPrice / 100 + ); + } + + public function submit(): mixed + { + $this->validate(); + + $virtualMachineId = null; + + try { + $this->authorize('create', Server::class); + + if (Team::serverLimitReached()) { + return $this->dispatch('error', 'You have reached the server limit for your subscription.'); + } + + $privateKey = PrivateKey::ownedByCurrentTeam()->findOrFail($this->private_key_id); + $hostingerService = new HostingerService($this->getHostingerToken()); + + if (! $this->providerSelectionsAreValid($hostingerService)) { + return null; + } + + $normalizedServerName = strtolower(trim($this->server_name)); + $setup = [ + 'data_center_id' => $this->selected_data_center_id, + 'template_id' => $this->selected_template_id, + 'hostname' => $normalizedServerName, + 'enable_backups' => $this->enable_backups, + 'public_key' => [ + 'name' => $privateKey->name, + 'key' => $privateKey->getPublicKey(), + ], + ]; + if ($this->selected_post_install_script_id) { + $setup['post_install_script_id'] = $this->selected_post_install_script_id; + } + + $virtualMachine = $hostingerService->purchaseVirtualMachine([ + 'item_id' => $this->selected_price_id, + 'setup' => $setup, + ]); + $virtualMachineId = (int) $virtualMachine['id']; + + $server = Server::create([ + 'name' => $normalizedServerName, + 'ip' => Server::PLACEHOLDER_IP, + 'user' => 'root', + 'port' => 22, + 'team_id' => currentTeam()->id, + 'private_key_id' => $privateKey->id, + 'cloud_provider_token_id' => $this->selected_token_id, + 'hostinger_virtual_machine_id' => $virtualMachine['id'], + 'hostinger_virtual_machine_status' => $virtualMachine['state'] ?? null, + ]); + + $server->proxy->set('status', 'exited'); + $server->proxy->set('type', ProxyTypes::TRAEFIK->value); + $server->save(); + + try { + if ($this->selected_public_key_ids) { + $hostingerService->attachPublicKeys((int) $virtualMachine['id'], $this->selected_public_key_ids); + } + $virtualMachine = $hostingerService->waitForPublicIp($virtualMachine); + $ipAddress = $hostingerService->getPublicIpAddress($virtualMachine); + if ($ipAddress) { + $server->update([ + 'ip' => $ipAddress, + 'hostinger_virtual_machine_status' => $virtualMachine['state'] ?? $server->hostinger_virtual_machine_status, + ]); + } + } catch (\Throwable $e) { + report($e); + } + + if ($this->from_onboarding) { + currentTeam()->update(['show_boarding' => false]); + refreshSession(); + } + + return redirectRoute($this, 'server.show', [$server->uuid]); + } catch (\Throwable $e) { + if ($virtualMachineId) { + logger()->warning('Hostinger VPS was purchased but could not be saved in Coolify', [ + 'team_id' => currentTeam()->id, + 'hostinger_virtual_machine_id' => $virtualMachineId, + ]); + $this->dispatch('error', "Hostinger VPS {$virtualMachineId} was purchased but could not be saved in Coolify. Manage it in hPanel."); + } + + return handleError($e, $this); + } + } + + private function selectTokenFromUrl(?string $selectedTokenUuid): void + { + if (! $selectedTokenUuid) { + return; + } + + $token = $this->available_tokens->firstWhere('uuid', $selectedTokenUuid); + + if ($token) { + $this->selectedTokenUuid = $selectedTokenUuid; + $this->selected_token_id = $token->id; + } + } + + private function getHostingerToken(): string + { + $token = $this->available_tokens->firstWhere('id', $this->selected_token_id); + + return $token?->token ?? ''; + } + + private function selectDefaultTemplate(): void + { + if ($this->selected_template_id !== null) { + return; + } + + $defaultTemplate = collect($this->osTemplates)->firstWhere('name', 'Ubuntu 24.04 LTS'); + + if ($defaultTemplate) { + $this->selected_os_template_id = (int) $defaultTemplate['id']; + $this->selected_template_id = $this->selected_os_template_id; + } + } + + private function templateIncludesApplication(array $template): bool + { + return str_contains((string) ($template['name'] ?? ''), ' with '); + } + + /** + * The plan size from the KVM plan id (hostingercom-vps-kvm4 is 4), falling back to its CPU count. + */ + private function planSize(array $item): int + { + if (preg_match('/-vps-kvm(\d+)$/', (string) ($item['id'] ?? ''), $matches) === 1) { + return (int) $matches[1]; + } + + return (int) ($item['metadata']['cpus'] ?? 0); + } + + private function periodInMonths(array $price): float + { + $period = (int) ($price['period'] ?? 0); + + return match ($price['period_unit'] ?? 'month') { + 'day' => $period / 30, + 'week' => $period * 7 / 30, + 'year' => $period * 12, + 'none' => PHP_INT_MAX, + default => $period, + }; + } + + /** + * Load an optional Hostinger list. Some tokens cannot read these lists, so a failure must not block the form. + */ + private function optionalProviderData(callable $callback): array + { + try { + return $callback(); + } catch (\Throwable $e) { + report($e); + + return []; + } + } + + private function providerSelectionsAreValid(HostingerService $hostingerService): bool + { + $priceIds = collect($hostingerService->getCatalogItems()) + ->flatMap(fn (array $item) => collect($item['prices'] ?? [])->pluck('id')); + $dataCenterIds = collect($hostingerService->getDataCenters())->pluck('id')->map(fn ($id) => (int) $id); + $templateIds = collect($hostingerService->getTemplates())->pluck('id')->map(fn ($id) => (int) $id); + $publicKeyIds = collect($this->selected_public_key_ids ? $hostingerService->getPublicKeys() : [])->pluck('id')->map(fn ($id) => (int) $id); + $postInstallScriptIds = collect($this->selected_post_install_script_id ? $hostingerService->getPostInstallScripts() : [])->pluck('id')->map(fn ($id) => (int) $id); + + if (! $priceIds->contains($this->selected_price_id)) { + $this->addError('selected_price_id', 'The selected Hostinger plan or billing period is no longer available.'); + } + + if (! $dataCenterIds->contains($this->selected_data_center_id)) { + $this->addError('selected_data_center_id', 'The selected Hostinger data center is no longer available.'); + } + + if (! $templateIds->contains($this->selected_template_id)) { + $this->addError('selected_template_id', 'The selected Hostinger operating system is no longer available.'); + } + + if (collect($this->selected_public_key_ids)->contains(fn ($id) => ! $publicKeyIds->contains((int) $id))) { + $this->addError('selected_public_key_ids', 'One or more selected Hostinger SSH keys are no longer available.'); + } + + if ($this->selected_post_install_script_id && ! $postInstallScriptIds->contains($this->selected_post_install_script_id)) { + $this->addError('selected_post_install_script_id', 'The selected Hostinger post-install script is no longer available.'); + } + + return ! $this->getErrorBag()->isNotEmpty(); + } + + public function render() + { + return view('livewire.server.new.by-hostinger'); + } +} diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index 8357e89257..839f545ae2 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -10,6 +10,7 @@ use App\Models\Server; use App\Rules\ValidServerIp; use App\Services\DigitalOceanService; use App\Services\HetznerService; +use App\Services\HostingerService; use App\Services\ServerTransfer\ServerTransferClaimer; use App\Services\VultrService; use App\Support\ValidationPatterns; @@ -82,12 +83,16 @@ class Show extends Component public ?string $digitalOceanDropletStatus = null; + public ?string $hostingerVirtualMachineStatus = null; + public bool $hetznerServerManuallyStarted = false; public bool $vultrInstanceManuallyStarted = false; public bool $digitalOceanDropletManuallyStarted = false; + public bool $hostingerVirtualMachineManuallyStarted = false; + public bool $isValidating = false; // Hetzner linking properties @@ -127,6 +132,18 @@ class Show extends Component public bool $digitalOceanNoMatchFound = false; + public Collection $availableHostingerTokens; + + public ?int $selectedHostingerTokenId = null; + + public ?string $manualHostingerVirtualMachineId = null; + + public ?array $matchedHostingerVirtualMachine = null; + + public ?string $hostingerSearchError = null; + + public bool $hostingerNoMatchFound = false; + public function getListeners() { $teamId = $this->server->team_id ?? auth()->user()->currentTeam()->id; @@ -206,12 +223,14 @@ class Show extends Component $this->hetznerServerStatus = $this->server->hetzner_server_status; $this->vultrInstanceStatus = $this->server->vultr_instance_status; $this->digitalOceanDropletStatus = $this->server->digitalocean_droplet_status; + $this->hostingerVirtualMachineStatus = $this->server->hostinger_virtual_machine_status; $this->isValidating = $this->server->is_validating ?? false; // Load cloud provider tokens for linking $this->loadHetznerTokens(); $this->loadVultrTokens(); $this->loadDigitalOceanTokens(); + $this->loadHostingerTokens(); } catch (\Throwable $e) { return handleError($e, $this); @@ -658,6 +677,30 @@ class Show extends Component } } + public function checkHostingerVirtualMachineStatus(bool $manual = false): mixed + { + try { + $this->authorize('view', $this->server); + if (! $this->server->hostinger_virtual_machine_id || ! $this->server->cloudProviderToken) { + $this->dispatch('error', 'This server is not associated with a Hostinger VPS or token.'); + + return null; + } + + $this->hostingerVirtualMachineStatus = $this->server->refreshHostingerState(); + $this->server->refresh(); + $this->ip = $this->server->ip; + + if ($manual) { + $this->dispatch('success', 'VPS status refreshed: '.ucfirst($this->hostingerVirtualMachineStatus ?? 'unknown')); + } + + return null; + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function handleServerValidated($event = null) { // Check if event is for this server @@ -679,6 +722,7 @@ class Show extends Component $this->loadHetznerTokens(); $this->loadVultrTokens(); $this->loadDigitalOceanTokens(); + $this->loadHostingerTokens(); $this->dispatch('refreshServerShow'); $this->dispatch('refreshServer'); @@ -750,6 +794,30 @@ class Show extends Component } } + public function startHostingerVirtualMachine(): mixed + { + try { + $this->authorize('update', $this->server); + if (! $this->server->hostinger_virtual_machine_id || ! $this->server->cloudProviderToken) { + $this->dispatch('error', 'This server is not associated with a Hostinger VPS or token.'); + + return null; + } + + $hostingerService = new HostingerService($this->server->cloudProviderToken->token); + $hostingerService->startVirtualMachine((int) $this->server->hostinger_virtual_machine_id); + + $this->hostingerVirtualMachineStatus = 'starting'; + $this->server->update(['hostinger_virtual_machine_status' => 'starting']); + $this->hostingerVirtualMachineManuallyStarted = true; + $this->dispatch('success', 'Hostinger VPS is starting...'); + + return null; + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function refreshServerMetadata(): void { try { @@ -804,6 +872,13 @@ class Show extends Component ->get(); } + public function loadHostingerTokens(): void + { + $this->availableHostingerTokens = CloudProviderToken::ownedByCurrentTeam() + ->where('provider', 'hostinger') + ->get(); + } + #[Computed] public function limaStartCommand(): ?string { @@ -1077,6 +1152,136 @@ class Show extends Component } } + public function searchHostingerVirtualMachine(): void + { + $this->hostingerSearchError = null; + $this->hostingerNoMatchFound = false; + $this->matchedHostingerVirtualMachine = null; + + if (! $this->selectedHostingerTokenId) { + $this->hostingerSearchError = 'Please select a Hostinger token.'; + + return; + } + + try { + $this->authorize('update', $this->server); + $token = $this->availableHostingerTokens->firstWhere('id', $this->selectedHostingerTokenId); + + if (! $token) { + $this->hostingerSearchError = 'Invalid token selected.'; + + return; + } + + $matched = (new HostingerService($token->token))->findVirtualMachineByIp($this->server->ip); + + if ($matched) { + $this->matchedHostingerVirtualMachine = $matched; + } else { + $this->hostingerNoMatchFound = true; + } + } catch (\Throwable $e) { + $this->hostingerSearchError = 'Failed to search Hostinger virtual machines: '.$e->getMessage(); + } + } + + public function searchHostingerVirtualMachineById(): void + { + $this->hostingerSearchError = null; + $this->hostingerNoMatchFound = false; + $this->matchedHostingerVirtualMachine = null; + + if (! $this->selectedHostingerTokenId) { + $this->hostingerSearchError = 'Please select a Hostinger token first.'; + + return; + } + + if (! $this->manualHostingerVirtualMachineId) { + $this->hostingerSearchError = 'Please enter a Hostinger Virtual Machine ID.'; + + return; + } + + try { + $this->authorize('update', $this->server); + $token = $this->availableHostingerTokens->firstWhere('id', $this->selectedHostingerTokenId); + + if (! $token) { + $this->hostingerSearchError = 'Invalid token selected.'; + + return; + } + + $virtualMachine = (new HostingerService($token->token)) + ->getVirtualMachine((int) $this->manualHostingerVirtualMachineId); + + if ($virtualMachine) { + $this->matchedHostingerVirtualMachine = $virtualMachine; + } else { + $this->hostingerNoMatchFound = true; + } + } catch (\Throwable $e) { + $this->hostingerSearchError = 'Failed to fetch Hostinger virtual machine: '.$e->getMessage(); + } + } + + public function linkToHostinger(): mixed + { + if (! $this->matchedHostingerVirtualMachine) { + $this->dispatch('error', 'No Hostinger virtual machine selected.'); + + return null; + } + + try { + $this->authorize('update', $this->server); + $token = $this->availableHostingerTokens->firstWhere('id', $this->selectedHostingerTokenId); + + if (! $token) { + $this->dispatch('error', 'Invalid token selected.'); + + return null; + } + + $hostingerService = new HostingerService($token->token); + $virtualMachine = $hostingerService->getVirtualMachine((int) $this->matchedHostingerVirtualMachine['id']); + + if (! $virtualMachine) { + $this->dispatch('error', 'Could not find Hostinger virtual machine with ID: '.$this->matchedHostingerVirtualMachine['id']); + + return null; + } + + $updates = [ + 'cloud_provider_token_id' => $this->selectedHostingerTokenId, + 'hostinger_virtual_machine_id' => $virtualMachine['id'], + 'hostinger_virtual_machine_status' => $virtualMachine['state'] ?? null, + ]; + $ip = $hostingerService->getPublicIpAddress($virtualMachine); + if ($ip) { + $updates['ip'] = $ip; + } + + $this->server->update($updates); + $this->hostingerVirtualMachineStatus = $virtualMachine['state'] ?? null; + $this->matchedHostingerVirtualMachine = null; + $this->selectedHostingerTokenId = null; + $this->manualHostingerVirtualMachineId = null; + $this->hostingerNoMatchFound = false; + $this->hostingerSearchError = null; + + $this->dispatch('success', 'Server successfully linked to Hostinger!'); + $this->dispatch('close-modal'); + $this->dispatch('refreshServerShow'); + + return null; + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function searchVultrInstance(): void { $this->vultrSearchError = null; diff --git a/app/Livewire/Server/ValidateAndInstall.php b/app/Livewire/Server/ValidateAndInstall.php index 33b77418d6..407f5f31d2 100644 --- a/app/Livewire/Server/ValidateAndInstall.php +++ b/app/Livewire/Server/ValidateAndInstall.php @@ -145,6 +145,18 @@ class ValidateAndInstall extends Component } } + if ($this->server->hostinger_virtual_machine_id) { + $status = $this->server->refreshHostingerState(); + $this->server->refresh(); + + if (in_array($status, ['stopping', 'stopped', 'suspending', 'suspended', 'destroying', 'destroyed', 'error'], true)) { + $this->error = 'Hostinger VPS 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/Mcp/Concerns/BuildsResponse.php b/app/Mcp/Concerns/BuildsResponse.php index 280e26b9e7..f6311f22bb 100644 --- a/app/Mcp/Concerns/BuildsResponse.php +++ b/app/Mcp/Concerns/BuildsResponse.php @@ -24,7 +24,7 @@ trait BuildsResponse // raw IDs / morph types (uuid is the public identifier) 'id', 'team_id', 'tokenable_id', 'tokenable_type', 'server_id', 'private_key_id', 'cloud_provider_token_id', - 'hetzner_server_id', 'digitalocean_droplet_id', 'environment_id', 'destination_id', + 'hetzner_server_id', 'digitalocean_droplet_id', 'hostinger_virtual_machine_id', 'environment_id', 'destination_id', 'source_id', 'repository_project_id', 'application_id', 'service_id', 'project_id', 'parent_id', 'resourceable', 'resourceable_id', 'resourceable_type', diff --git a/app/Models/Server.php b/app/Models/Server.php index b7e4e9ba53..bc20bc07b8 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -21,6 +21,7 @@ use App\Notifications\Server\Reachable; use App\Notifications\Server\Unreachable; use App\Services\DigitalOceanService; use App\Services\HetznerService; +use App\Services\HostingerService; use App\Services\VultrService; use App\Support\ValidationPatterns; use App\Traits\Auditable; @@ -345,6 +346,8 @@ class Server extends BaseModel 'vultr_instance_status', 'digitalocean_droplet_id', 'digitalocean_droplet_status', + 'hostinger_virtual_machine_id', + 'hostinger_virtual_machine_status', 'is_validating', 'validation_logs', 'detected_traefik_version', @@ -523,6 +526,27 @@ class Server extends BaseModel return $status; } + public function refreshHostingerState(): ?string + { + if (! $this->hostinger_virtual_machine_id || ! $this->cloudProviderToken || $this->cloudProviderToken->provider !== 'hostinger') { + return $this->hostinger_virtual_machine_status; + } + + $hostingerService = new HostingerService($this->cloudProviderToken->token); + $virtualMachine = $hostingerService->getVirtualMachine((int) $this->hostinger_virtual_machine_id); + + if (empty($virtualMachine)) { + return $this->hostinger_virtual_machine_status; + } + + $status = $virtualMachine['state'] ?? null; + $ip = $hostingerService->getPublicIpAddress($virtualMachine); + $this->persistProviderState(['hostinger_virtual_machine_status' => $status]); + $this->backfillPlaceholderIp($ip); + + return $status; + } + protected function isCoolifyHost(): Attribute { return Attribute::make( diff --git a/app/Services/HostingerService.php b/app/Services/HostingerService.php new file mode 100644 index 0000000000..d90f28ef27 --- /dev/null +++ b/app/Services/HostingerService.php @@ -0,0 +1,224 @@ +token) + ->acceptJson() + ->asJson() + ->timeout(30) + ->connectTimeout(10) + ->retry( + $method === 'get' ? 3 : 1, + fn (int $attempt) => $attempt * 100, + fn (\Exception $exception) => ! $exception instanceof RequestException || $exception->response->serverError(), + throw: false + ) + ->{$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 + ); + } + + $message = $response->json('message') + ?? $response->json('error') + ?? 'Unknown error'; + + throw new \Exception('Hostinger API error: '.$message, $response->status()); + } + + return $response->json() ?? []; + } + + public function getDataCenters(): array + { + return $this->request('get', '/api/vps/v1/data-centers'); + } + + public function getTemplates(): array + { + return $this->request('get', '/api/vps/v1/templates'); + } + + /** + * Get the KVM VPS plans. Game Panel plans share the VPS category but are not offered for Coolify servers. + */ + public function getCatalogItems(): array + { + return collect($this->request('get', '/api/billing/v1/catalog', ['category' => 'VPS'])) + ->filter(fn ($item) => is_array($item) && preg_match('/-vps-kvm\d+$/', (string) ($item['id'] ?? '')) === 1) + ->values() + ->all(); + } + + public function getPublicKeys(): array + { + return $this->request('get', '/api/vps/v1/public-keys')['data'] ?? []; + } + + public function getPostInstallScripts(): array + { + return $this->request('get', '/api/vps/v1/post-install-scripts')['data'] ?? []; + } + + public function attachPublicKeys(int $virtualMachineId, array $publicKeyIds): array + { + return $this->request('post', "/api/vps/v1/public-keys/attach/{$virtualMachineId}", [ + 'ids' => array_map('intval', array_values($publicKeyIds)), + ]); + } + + public function purchaseVirtualMachine(array $params): array + { + // Hostinger accepts only a fully qualified hostname and uses its own default otherwise. + if (! str_contains($params['setup']['hostname'] ?? '', '.')) { + unset($params['setup']['hostname']); + } + + $existingVirtualMachineIds = collect($this->getVirtualMachines())->pluck('id')->all(); + + try { + $response = $this->request('post', '/api/vps/v1/virtual-machines', $params); + } catch (\Throwable $e) { + // Hostinger can charge for the VPS before its setup fails, so keep the VPS if one was created. + $virtualMachine = $this->findPurchasedVirtualMachine($existingVirtualMachineIds); + if (! $virtualMachine) { + throw $e; + } + + logger()->warning('Hostinger charged for a VPS but its setup failed', [ + 'hostinger_virtual_machine_id' => $virtualMachine['id'], + 'error' => $e->getMessage(), + ]); + + return $this->setupPurchasedVirtualMachine($virtualMachine, $params['setup']); + } + + if (empty($response['virtual_machine']['id'])) { + logger()->warning('Hostinger VPS order did not return a virtual machine', Arr::only($response, ['id', 'subscription_id', 'status', 'message'])); + + throw new \Exception('Hostinger order '.($response['id'] ?? 'unknown').' is waiting for payment. Finish the VPS setup in hPanel.', 202); + } + + return $response['virtual_machine']; + } + + private function findPurchasedVirtualMachine(array $existingVirtualMachineIds): ?array + { + try { + return collect($this->getVirtualMachines()) + ->first(fn (array $virtualMachine) => ! in_array($virtualMachine['id'] ?? null, $existingVirtualMachineIds, true)); + } catch (\Throwable) { + return null; + } + } + + private function setupPurchasedVirtualMachine(array $virtualMachine, array $setup): array + { + if (($virtualMachine['state'] ?? null) !== 'initial') { + return $virtualMachine; + } + + try { + return $this->request('post', $this->virtualMachineEndpoint((int) $virtualMachine['id']).'/setup', $setup); + } catch (\Throwable $e) { + logger()->warning('Hostinger VPS setup retry failed', [ + 'hostinger_virtual_machine_id' => $virtualMachine['id'], + 'error' => $e->getMessage(), + ]); + + return $virtualMachine; + } + } + + public function getVirtualMachine(int $virtualMachineId): array + { + return $this->request('get', $this->virtualMachineEndpoint($virtualMachineId)); + } + + public function getVirtualMachines(): array + { + return $this->request('get', '/api/vps/v1/virtual-machines'); + } + + /** + * Briefly wait for a public IP. VPS setup usually takes longer, so the server status check fills in the IP later. + */ + public function waitForPublicIp(array $virtualMachine, int $attempts = 10, int $sleepMilliseconds = 1000): array + { + if ($this->getPublicIpAddress($virtualMachine) || empty($virtualMachine['id'])) { + return $virtualMachine; + } + + for ($attempt = 0; $attempt < $attempts; $attempt++) { + usleep($sleepMilliseconds * 1000); + + $virtualMachine = $this->getVirtualMachine((int) $virtualMachine['id']); + + if ($this->getPublicIpAddress($virtualMachine)) { + return $virtualMachine; + } + } + + return $virtualMachine; + } + + public function getPublicIpAddress(array $virtualMachine): ?string + { + foreach (['ipv4', 'ipv6'] as $version) { + foreach ($virtualMachine[$version] ?? [] as $ipAddress) { + if (filled($ipAddress['address'] ?? null)) { + return $ipAddress['address']; + } + } + } + + return null; + } + + public function disableAutoRenewal(string $subscriptionId): array + { + return $this->request('delete', "/api/billing/v1/subscriptions/{$subscriptionId}/auto-renewal/disable"); + } + + public function startVirtualMachine(int $virtualMachineId): array + { + return $this->request('post', $this->virtualMachineEndpoint($virtualMachineId).'/start'); + } + + public function findVirtualMachineByIp(string $ip): ?array + { + foreach ($this->getVirtualMachines() as $virtualMachine) { + foreach (['ipv4', 'ipv6'] as $version) { + foreach ($virtualMachine[$version] ?? [] as $ipAddress) { + if (($ipAddress['address'] ?? null) === $ip) { + return $virtualMachine; + } + } + } + } + + return null; + } + + private function virtualMachineEndpoint(int $virtualMachineId): string + { + return '/api/vps/v1/virtual-machines/'.$virtualMachineId; + } +} diff --git a/database/migrations/2026_07_10_210239_add_hostinger_virtual_machine_fields_to_servers_table.php b/database/migrations/2026_07_10_210239_add_hostinger_virtual_machine_fields_to_servers_table.php new file mode 100644 index 0000000000..496b2a3bce --- /dev/null +++ b/database/migrations/2026_07_10_210239_add_hostinger_virtual_machine_fields_to_servers_table.php @@ -0,0 +1,32 @@ +bigInteger('hostinger_virtual_machine_id')->nullable()->after('digitalocean_droplet_status'); + $table->string('hostinger_virtual_machine_status')->nullable()->after('hostinger_virtual_machine_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('servers', function (Blueprint $table) { + $table->dropColumn([ + 'hostinger_virtual_machine_status', + 'hostinger_virtual_machine_id', + ]); + }); + } +}; diff --git a/openapi.json b/openapi.json index 572091bba7..192d6f49b9 100644 --- a/openapi.json +++ b/openapi.json @@ -6318,7 +6318,8 @@ "enum": [ "hetzner", "digitalocean", - "vultr" + "vultr", + "hostinger" ] }, "team_id": { @@ -6377,7 +6378,8 @@ "enum": [ "hetzner", "digitalocean", - "vultr" + "vultr", + "hostinger" ], "example": "hetzner", "description": "The cloud provider." @@ -12873,6 +12875,189 @@ ] } }, + "\/hostinger\/data-centers": { + "get": { + "tags": [ + "Hostinger" + ], + "summary": "Get Hostinger VPS data centers", + "operationId": "get-hostinger-data-centers", + "responses": { + "200": { + "description": "List of Hostinger VPS data centers." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "403": { + "description": "Hostinger denied access to this resource for the cloud provider token." + }, + "422": { + "description": "Validation failed." + }, + "429": { + "description": "Hostinger rate limit exceeded." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/hostinger\/catalog": { + "get": { + "tags": [ + "Hostinger" + ], + "summary": "Get Hostinger VPS plans and prices", + "operationId": "get-hostinger-catalog", + "responses": { + "200": { + "description": "List of Hostinger VPS plans and prices." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "403": { + "description": "Hostinger denied access to this resource for the cloud provider token." + }, + "422": { + "description": "Validation failed." + }, + "429": { + "description": "Hostinger rate limit exceeded." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/hostinger\/templates": { + "get": { + "tags": [ + "Hostinger" + ], + "summary": "Get Hostinger VPS operating system templates", + "operationId": "get-hostinger-templates", + "responses": { + "200": { + "description": "List of Hostinger VPS templates." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "403": { + "description": "Hostinger denied access to this resource for the cloud provider token." + }, + "422": { + "description": "Validation failed." + }, + "429": { + "description": "Hostinger rate limit exceeded." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/hostinger\/ssh-keys": { + "get": { + "tags": [ + "Hostinger" + ], + "summary": "Get Hostinger account SSH keys", + "operationId": "get-hostinger-ssh-keys", + "responses": { + "200": { + "description": "List of Hostinger account SSH keys." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "403": { + "description": "Hostinger denied access to this resource for the cloud provider token." + }, + "422": { + "description": "Validation failed." + }, + "429": { + "description": "Hostinger rate limit exceeded." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/hostinger\/post-install-scripts": { + "get": { + "tags": [ + "Hostinger" + ], + "summary": "Get Hostinger post-install scripts", + "operationId": "get-hostinger-post-install-scripts", + "responses": { + "200": { + "description": "List of Hostinger post-install scripts." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "403": { + "description": "Hostinger denied access to this resource for the cloud provider token." + }, + "422": { + "description": "Validation failed." + }, + "429": { + "description": "Hostinger rate limit exceeded." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/servers\/hostinger": { + "post": { + "tags": [ + "Hostinger" + ], + "summary": "Purchase a Hostinger VPS and create a linked server", + "operationId": "create-hostinger-server", + "responses": { + "201": { + "description": "Hostinger VPS purchased and linked to a Coolify server." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "422": { + "description": "Validation failed." + }, + "429": { + "description": "Hostinger rate limit exceeded." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/settings\/email": { "get": { "tags": [ @@ -25077,6 +25262,10 @@ "name": "Hetzner", "description": "Hetzner" }, + { + "name": "Hostinger", + "description": "Hostinger" + }, { "name": "Settings", "description": "Settings" diff --git a/openapi.yaml b/openapi.yaml index 0884149b38..5983cbe38d 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -4075,7 +4075,7 @@ paths: schema: type: array items: - properties: { uuid: { type: string }, name: { type: string }, provider: { type: string, enum: [hetzner, digitalocean, vultr] }, team_id: { type: integer }, servers_count: { type: integer }, created_at: { type: string }, updated_at: { type: string } } + properties: { uuid: { type: string }, name: { type: string }, provider: { type: string, enum: [hetzner, digitalocean, vultr, hostinger] }, team_id: { type: integer }, servers_count: { type: integer }, created_at: { type: string }, updated_at: { type: string } } type: object '401': $ref: '#/components/responses/401' @@ -4103,7 +4103,7 @@ paths: properties: provider: type: string - enum: [hetzner, digitalocean, vultr] + enum: [hetzner, digitalocean, vultr, hostinger] example: hetzner description: 'The cloud provider.' token: @@ -8247,6 +8247,124 @@ paths: security: - bearerAuth: [] + /hostinger/data-centers: + get: + tags: + - Hostinger + summary: 'Get Hostinger VPS data centers' + operationId: get-hostinger-data-centers + responses: + '200': + description: 'List of Hostinger VPS data centers.' + '401': + $ref: '#/components/responses/401' + '403': + description: 'Hostinger denied access to this resource for the cloud provider token.' + '422': + description: 'Validation failed.' + '429': + description: 'Hostinger rate limit exceeded.' + security: + - + bearerAuth: [] + /hostinger/catalog: + get: + tags: + - Hostinger + summary: 'Get Hostinger VPS plans and prices' + operationId: get-hostinger-catalog + responses: + '200': + description: 'List of Hostinger VPS plans and prices.' + '401': + $ref: '#/components/responses/401' + '403': + description: 'Hostinger denied access to this resource for the cloud provider token.' + '422': + description: 'Validation failed.' + '429': + description: 'Hostinger rate limit exceeded.' + security: + - + bearerAuth: [] + /hostinger/templates: + get: + tags: + - Hostinger + summary: 'Get Hostinger VPS operating system templates' + operationId: get-hostinger-templates + responses: + '200': + description: 'List of Hostinger VPS templates.' + '401': + $ref: '#/components/responses/401' + '403': + description: 'Hostinger denied access to this resource for the cloud provider token.' + '422': + description: 'Validation failed.' + '429': + description: 'Hostinger rate limit exceeded.' + security: + - + bearerAuth: [] + /hostinger/ssh-keys: + get: + tags: + - Hostinger + summary: 'Get Hostinger account SSH keys' + operationId: get-hostinger-ssh-keys + responses: + '200': + description: 'List of Hostinger account SSH keys.' + '401': + $ref: '#/components/responses/401' + '403': + description: 'Hostinger denied access to this resource for the cloud provider token.' + '422': + description: 'Validation failed.' + '429': + description: 'Hostinger rate limit exceeded.' + security: + - + bearerAuth: [] + /hostinger/post-install-scripts: + get: + tags: + - Hostinger + summary: 'Get Hostinger post-install scripts' + operationId: get-hostinger-post-install-scripts + responses: + '200': + description: 'List of Hostinger post-install scripts.' + '401': + $ref: '#/components/responses/401' + '403': + description: 'Hostinger denied access to this resource for the cloud provider token.' + '422': + description: 'Validation failed.' + '429': + description: 'Hostinger rate limit exceeded.' + security: + - + bearerAuth: [] + /servers/hostinger: + post: + tags: + - Hostinger + summary: 'Purchase a Hostinger VPS and create a linked server' + operationId: create-hostinger-server + responses: + '201': + description: 'Hostinger VPS purchased and linked to a Coolify server.' + '401': + $ref: '#/components/responses/401' + '422': + description: 'Validation failed.' + '429': + description: 'Hostinger rate limit exceeded.' + security: + - + bearerAuth: [] /settings/email: get: tags: @@ -16078,6 +16196,9 @@ tags: - name: Hetzner description: Hetzner + - + name: Hostinger + description: Hostinger - name: Settings description: Settings diff --git a/resources/views/components/hostinger-icon.blade.php b/resources/views/components/hostinger-icon.blade.php new file mode 100644 index 0000000000..9616c8d965 --- /dev/null +++ b/resources/views/components/hostinger-icon.blade.php @@ -0,0 +1,7 @@ + + Hostinger + + diff --git a/resources/views/components/server/sidebar.blade.php b/resources/views/components/server/sidebar.blade.php index f444210e00..ed3a19435c 100644 --- a/resources/views/components/server/sidebar.blade.php +++ b/resources/views/components/server/sidebar.blade.php @@ -41,7 +41,7 @@ 'active' => $activeMenu === 'cloud-provider-token', 'icon' => 'subscription', 'group' => 'Settings', - 'visible' => (bool) ($server->hetzner_server_id || $server->vultr_instance_id), + 'visible' => (bool) ($server->hetzner_server_id || $server->vultr_instance_id || $server->digitalocean_droplet_id || $server->hostinger_virtual_machine_id), ], [ 'label' => 'CA Certificate', diff --git a/resources/views/livewire/boarding/index.blade.php b/resources/views/livewire/boarding/index.blade.php index bc22715931..71c065dac3 100644 --- a/resources/views/livewire/boarding/index.blade.php +++ b/resources/views/livewire/boarding/index.blade.php @@ -207,6 +207,23 @@ + + +
+
+ +
+

Hostinger

+

+ Deploy servers directly from your Hostinger account. +

+
+
+
+
+ +
@endif 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 271ad7367d..d6f14e188d 100644 --- a/resources/views/livewire/security/cloud-provider-token-form.blade.php +++ b/resources/views/livewire/security/cloud-provider-token-form.blade.php @@ -9,6 +9,7 @@ get providerConsoleUrl() { if (this.selectedProvider === 'hetzner') return 'https://console.hetzner.com/projects'; if (this.selectedProvider === 'vultr') return 'https://console.vultr.com/user/apiaccess/'; + if (this.selectedProvider === 'hostinger') return 'https://hpanel.hostinger.com/profile/api'; return 'https://cloud.digitalocean.com/account/api/tokens'; } }"> @@ -18,6 +19,7 @@ ['value' => 'hetzner', 'label' => 'Hetzner'], ['value' => 'digitalocean', 'label' => 'DigitalOcean'], ['value' => 'vultr', 'label' => 'Vultr'], + ['value' => 'hostinger', 'label' => 'Hostinger'], ]" /> @else 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 5c4fe5d98f..e4fe7c19d1 100644 --- a/resources/views/livewire/server/cloud-provider-token/show.blade.php +++ b/resources/views/livewire/server/cloud-provider-token/show.blade.php @@ -10,7 +10,7 @@
- @if ($server->hetzner_server_id || $server->vultr_instance_id) + @if ($server->hetzner_server_id || $server->vultr_instance_id || $server->digitalocean_droplet_id || $server->hostinger_virtual_machine_id) @@ -67,7 +67,7 @@ @endif diff --git a/resources/views/livewire/server/create.blade.php b/resources/views/livewire/server/create.blade.php index d1530985e5..3bed1f90d0 100644 --- a/resources/views/livewire/server/create.blade.php +++ b/resources/views/livewire/server/create.blade.php @@ -50,7 +50,7 @@

Create a server with a cloud provider.

@@ -113,6 +127,10 @@ + @elseif ($selectedType === 'hostinger') + @else diff --git a/resources/views/livewire/server/delete.blade.php b/resources/views/livewire/server/delete.blade.php index fb5e9c3172..2e96838e63 100644 --- a/resources/views/livewire/server/delete.blade.php +++ b/resources/views/livewire/server/delete.blade.php @@ -21,12 +21,28 @@ It currently contains managed resources. Enable force deletion in the confirmation only if those resources should also be removed. @endif + @if ($server->hostinger_virtual_machine_id) + Hostinger deletes the VPS only at the end of its paid period, and only if you select this in the confirmation dialog. + Manage the VPS and billing in hPanel. + @endif

Type the server name in the confirmation dialog to continue.

+ @php + $deleteActions = ['This server will be permanently deleted from Coolify.']; + $deleteWarningMessage = null; + if ($server->hostinger_virtual_machine_id) { + $deleteWarningMessage = 'Hostinger deletes a VPS only at the end of its paid period.
' + .'If you select to delete the VPS from Hostinger, Coolify turns off its auto-renewal. ' + .'The VPS keeps running until the paid period ends. Then Hostinger deletes it with all its data. ' + .'To delete it now or to request a refund, ' + .'cancel the VPS in hPanel.'; + } + @endphp diff --git a/resources/views/livewire/server/new/by-hostinger.blade.php b/resources/views/livewire/server/new/by-hostinger.blade.php new file mode 100644 index 0000000000..34010ba5d9 --- /dev/null +++ b/resources/views/livewire/server/new/by-hostinger.blade.php @@ -0,0 +1,147 @@ +
+ @if ($limit_reached) + + @elseif ($current_step === 1) +
+ +

+ New to Hostinger? + Create an account + through Coolify's affiliate link. +

+
+ @elseif ($current_step === 2) +
+ @if ($loading_data) + +
+ +
+
+ @elseif ($provider_data_error) + + +
{{ $provider_data_error }}
+
+ +
+ @else + @php + $dataCenterOptions = collect($data_centers)->map(fn ($dataCenter) => [ + 'value' => $dataCenter['id'], + 'label' => ($dataCenter['city'] ?? $dataCenter['name'] ?? $dataCenter['id']) + . (!empty($dataCenter['location']) ? ' · ' . strtoupper($dataCenter['location']) : ''), + ])->values()->all(); + $priceOptions = collect($this->priceOptions)->map(fn ($price) => [ + 'value' => $price['id'], + 'label' => $this->priceLabel($price), + ])->values()->all(); + $templateOption = fn ($template) => [ + 'value' => $template['id'], + 'label' => $template['name'] ?? $template['description'] ?? $template['id'], + ]; + $osTemplateOptions = collect($this->osTemplates)->map($templateOption)->values()->all(); + $appTemplateOptions = collect($this->appTemplates)->map($templateOption)->values()->all(); + $privateKeyOptions = $private_keys->map(fn ($key) => [ + 'value' => $key->id, + 'label' => $key->name, + ])->values()->all(); + $postInstallScriptOptions = collect($post_install_scripts)->map(fn ($script) => [ + 'value' => $script['id'], + 'label' => $script['name'], + ])->values()->all(); + @endphp + +
+ + + + + +
+
+ +
+ + + + + @if ($private_keys->isEmpty()) +
+ +
+ A private key is required. + + + + + + +
+
+ @else + + @endif +
+
+ + +
+
+ +
+ + @foreach ($hostinger_public_keys as $publicKey) + + @endforeach + + @if ($post_install_scripts) + + @endif + + The VPS is purchased with your Hostinger account's default payment method. Review the + selected plan and billing period before continuing. + +
+
+
+ @endif +
+ @endif +
diff --git a/resources/views/livewire/server/show.blade.php b/resources/views/livewire/server/show.blade.php index 565fda89a3..baf2508069 100644 --- a/resources/views/livewire/server/show.blade.php +++ b/resources/views/livewire/server/show.blade.php @@ -1,5 +1,5 @@
+ x-init="@if ($server->hetzner_server_id && $server->cloudProviderToken && !$hetznerServerStatus) $wire.checkHetznerServerStatus(); @endif @if ($server->vultr_instance_id && $server->cloudProviderToken) $wire.checkVultrInstanceStatus(); @endif @if ($server->digitalocean_droplet_id && $server->cloudProviderToken && !$digitalOceanDropletStatus) $wire.checkDigitalOceanDropletStatus(); @endif @if ($server->hostinger_virtual_machine_id && $server->cloudProviderToken && !$hostingerVirtualMachineStatus) $wire.checkHostingerVirtualMachineStatus(); @endif"> {{ data_get_str($server, 'name')->limit(24) }} | Server | Coolify @@ -18,23 +18,26 @@ filled($server->hetzner_server_id) => 'Hetzner', filled($server->digitalocean_droplet_id) => 'DigitalOcean', filled($server->vultr_instance_id) => 'Vultr', + filled($server->hostinger_virtual_machine_id) => 'Hostinger', default => null, }; $providerStatus = match ($provider) { 'Hetzner' => $hetznerServerStatus, 'DigitalOcean' => $digitalOceanDropletStatus, 'Vultr' => $vultrInstanceStatus, + 'Hostinger' => $hostingerVirtualMachineStatus, default => null, }; $providerStatusType = match (true) { in_array($providerStatus, ['running', 'active']) => 'success', - in_array($providerStatus, ['starting', 'initializing', 'pending', 'new']) => 'warning', - in_array($providerStatus, ['off', 'stopped', 'suspended', 'archive', 'deleted']) => 'error', + in_array($providerStatus, ['starting', 'initializing', 'pending', 'new', 'initial', 'creating', 'unsuspending', 'recreating', 'restoring']) => 'warning', + in_array($providerStatus, ['off', 'stopped', 'suspended', 'archive', 'deleted', 'destroyed', 'error']) => 'error', default => 'neutral', }; $hasLinkableCloudProviders = (!$server->hetzner_server_id && $availableHetznerTokens->isNotEmpty()) || (!$server->vultr_instance_id && $availableVultrTokens->isNotEmpty()) - || (!$server->digitalocean_droplet_id && $availableDigitalOceanTokens->isNotEmpty()); + || (!$server->digitalocean_droplet_id && $availableDigitalOceanTokens->isNotEmpty()) + || (!$server->hostinger_virtual_machine_id && $availableHostingerTokens->isNotEmpty()); $hetznerMatch = $matchedHetznerServer ? [ 'name' => $matchedHetznerServer['name'] ?? 'Hetzner server', @@ -56,6 +59,13 @@ 'status' => $matchedVultrInstance['status'] ?? null, ] : null; + $hostingerMatch = $matchedHostingerVirtualMachine + ? [ + 'name' => $matchedHostingerVirtualMachine['hostname'] ?? 'Hostinger VPS', + 'id' => $matchedHostingerVirtualMachine['id'] ?? null, + 'status' => $matchedHostingerVirtualMachine['state'] ?? null, + ] + : null; @endphp
@@ -107,6 +117,18 @@ :searchError="$vultrSearchError" :noMatch="$vultrNoMatchFound" :matched="$vultrMatch" /> @endif + @if (!$server->hostinger_virtual_machine_id && $availableHostingerTokens->isNotEmpty()) + + @endif
@endif @@ -272,6 +294,12 @@ title="Refresh provider status"> + @elseif ($provider === 'Hostinger') + + + @endif @if ($server->cloudProviderToken) @if ($provider === 'Hetzner' && !$server->isFunctional() && $hetznerServerStatus === 'off') diff --git a/routes/api.php b/routes/api.php index a44b95a1d2..54d500973b 100644 --- a/routes/api.php +++ b/routes/api.php @@ -13,6 +13,7 @@ use App\Http\Controllers\Api\DigitalOceanController; use App\Http\Controllers\Api\GithubController; use App\Http\Controllers\Api\GitlabController; use App\Http\Controllers\Api\HetznerController; +use App\Http\Controllers\Api\HostingerController; use App\Http\Controllers\Api\InstanceEmailSettingsController; use App\Http\Controllers\Api\IntegrationTokensController; use App\Http\Controllers\Api\NotificationsController; @@ -232,6 +233,13 @@ Route::group([ Route::get('/digitalocean/ssh-keys', [DigitalOceanController::class, 'sshKeys'])->middleware(['api.ability:read']); Route::post('/servers/digitalocean', [DigitalOceanController::class, 'createServer'])->middleware(['api.ability:write']); + Route::get('/hostinger/data-centers', [HostingerController::class, 'dataCenters'])->middleware(['api.ability:read']); + Route::get('/hostinger/catalog', [HostingerController::class, 'catalog'])->middleware(['api.ability:read']); + Route::get('/hostinger/templates', [HostingerController::class, 'templates'])->middleware(['api.ability:read']); + Route::get('/hostinger/ssh-keys', [HostingerController::class, 'sshKeys'])->middleware(['api.ability:read']); + Route::get('/hostinger/post-install-scripts', [HostingerController::class, 'postInstallScripts'])->middleware(['api.ability:read']); + Route::post('/servers/hostinger', [HostingerController::class, 'createServer'])->middleware(['api.ability:write']); + Route::get('/resources', [ResourcesController::class, 'resources'])->middleware(['api.ability:read']); Route::get('/tags', [TagsController::class, 'tags'])->middleware(['api.ability:read']); diff --git a/routes/web.php b/routes/web.php index 65a9cb4d48..3294c6edf7 100644 --- a/routes/web.php +++ b/routes/web.php @@ -377,8 +377,8 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/registries', ServerDockerRegistries::class)->name('registries.index'); Route::get('/servers/import', ServerTransferImport::class)->name('server.transfer.import')->middleware('can:create,'.Server::class); Route::get('/servers/new', ServerCreatePage::class)->name('server.create')->middleware('can:create,'.Server::class); - Route::get('/servers/new/{type}/{token_uuid}', ServerCreatePage::class)->name('server.create.token')->middleware('can:create,'.Server::class)->whereIn('type', ['hetzner', 'vultr', 'digital-ocean']); - Route::get('/servers/new/{type}', ServerCreatePage::class)->name('server.create.type')->middleware('can:create,'.Server::class)->whereIn('type', ['hetzner', 'vultr', 'digital-ocean', 'manual']); + Route::get('/servers/new/{type}/{token_uuid}', ServerCreatePage::class)->name('server.create.token')->middleware('can:create,'.Server::class)->whereIn('type', ['hetzner', 'vultr', 'digital-ocean', 'hostinger']); + Route::get('/servers/new/{type}', ServerCreatePage::class)->name('server.create.type')->middleware('can:create,'.Server::class)->whereIn('type', ['hetzner', 'vultr', 'digital-ocean', 'hostinger', 'manual']); Route::prefix('server/{server_uuid}')->group(function () { Route::get('/', ServerShow::class)->name('server.show'); diff --git a/scripts/dev b/scripts/dev index 5a02758a58..0da92917a2 100755 --- a/scripts/dev +++ b/scripts/dev @@ -382,9 +382,10 @@ start_backend() { local host="${DEV_BIND_ADDRESS:-127.0.0.1}" [[ "$host" == 0.0.0.0 ]] && host=127.0.0.1 echo "Using KVM profile: $profile" - # The host command must reach this instance's database and encrypt with this instance's - # APP_KEY, not the ones in .env, and must not create root-owned files in storage/logs. - LOG_CHANNEL=stderr APP_KEY="$(env_value "$instance" APP_KEY)" \ + # Use this instance database and encryption key, without a stale environment or config cache. + # Send logs to stderr to avoid root-owned files in storage/logs. + APP_ENV=local APP_CONFIG_CACHE="$ROOT/bootstrap/cache/dev-qemu-$$.php" \ + LOG_CHANNEL=stderr APP_KEY="$(env_value "$instance" APP_KEY)" \ DB_HOST="$host" DB_PORT="$(env_value "$instance" FORWARD_DB_PORT)" \ REDIS_HOST="$host" REDIS_PORT="$(env_value "$instance" FORWARD_REDIS_PORT)" \ DEVELOPMENT_QEMU_INSTANCE="$instance" \ diff --git a/tests/Feature/BoardingActionsLayoutTest.php b/tests/Feature/BoardingActionsLayoutTest.php new file mode 100644 index 0000000000..71e7b824e4 --- /dev/null +++ b/tests/Feature/BoardingActionsLayoutTest.php @@ -0,0 +1,84 @@ +toContain('class="mx-auto mt-6 flex w-full max-w-3xl flex-col items-center gap-3"'); +}); + +test('server validation opens in the centered process dialog', function () { + $view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php')); + + expect($view) + ->toContain('') + ->toContain('@click="processDialogOpen = true"') + ->not->toContain(''); +}); + +test('DigitalOcean is available as an onboarding server provider', function () { + $view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php')); + + expect($view) + ->toContain('') + ->toContain('') + ->toContain('Deploy servers directly from your DigitalOcean account.') + ->toContain(''); +}); + +test('Hostinger is available as an onboarding server provider', function () { + $view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php')); + + expect($view) + ->toContain('') + ->toContain('') + ->toContain('Deploy servers directly from your Hostinger account.') + ->toContain(''); +}); + +test('server type details are shown on the relevant cards instead of a technical details panel', function () { + $view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php')); + + expect($view) + ->toContain('aria-label="About this machine"') + ->toContain('aria-label="About remote servers"') + ->toContain('Not recommended for production workloads due to resource contention.') + ->toContain('Any SSH-accessible server, including cloud VPS, bare metal, and self-hosted infrastructure.') + ->not->toContain('') + ->not->toContain('') + ->not->toContain(''); +}); + +test('server type cards use the standard card hover treatment', function () { + $view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php')); + $hoverClasses = 'shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:shadow-md'; + preg_match_all('/min-h-36[^\"]*'.preg_quote($hoverClasses, '/').'/', $view, $matches); + + expect($matches[0])->toHaveCount(6) + ->and(substr_count($view, 'group relative cursor-pointer'))->toBe(6) + ->and($view)->not->toContain('hover:border-coollabs/35 hover:bg-coollabs/[0.03]') + ->and($view)->not->toContain('dark:hover:border-warning/25 dark:hover:bg-warning/[0.04]'); +}); + +test('existing SSH key selection does not show a redundant value tooltip', function () { + $view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php')); + + expect($view)->toContain('label="Existing SSH key" :options="$privateKeyOptions" :tooltip="false"'); +}); + +test('server connection step does not render a technical details panel', function () { + $view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php')); + + expect($view) + ->not->toContain('') + ->not->toContain('') + ->not->toContain(''); +}); + +test('project step does not render a technical details panel', function () { + $view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php')); + + expect($view) + ->not->toContain('') + ->not->toContain('') + ->not->toContain(''); +}); diff --git a/tests/Feature/CloudProviderLinkDropdownTest.php b/tests/Feature/CloudProviderLinkDropdownTest.php index 1f14482cf6..714b896e01 100644 --- a/tests/Feature/CloudProviderLinkDropdownTest.php +++ b/tests/Feature/CloudProviderLinkDropdownTest.php @@ -59,19 +59,66 @@ it('shows link cloud provider dropdown with available unlinked providers', funct 'name' => 'Test DigitalOcean Token', ]); + CloudProviderToken::query()->create([ + 'team_id' => $this->team->id, + 'provider' => 'hostinger', + 'token' => 'test-hostinger-token', + 'name' => 'Test Hostinger Token', + ]); + Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]) ->assertSee('Link provider') ->assertSee('Hetzner') ->assertSee('DigitalOcean') ->assertSee('Vultr') + ->assertSee('Hostinger') ->assertSee('Hetzner Token') ->assertSee('DigitalOcean Token') ->assertSee('Vultr Token') + ->assertSee('Hostinger Token') ->assertSee('Server ID') ->assertSee('Droplet ID') ->assertSee('Instance ID') + ->assertSee('Virtual Machine ID') ->assertSee('Search by server IP') - ->assertSee('Search'); + ->assertSee('Search ID'); +}); + +it('links a manually added server to a Hostinger VPS by IP', function () { + $token = CloudProviderToken::query()->create([ + 'team_id' => $this->team->id, + 'provider' => 'hostinger', + 'token' => 'test-hostinger-token', + 'name' => 'Test Hostinger Token', + ]); + + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([ + [ + 'id' => 17923, + 'hostname' => 'hostinger.example.com', + 'state' => 'running', + 'ipv4' => [['address' => '1.2.3.4']], + ], + ]), + 'https://developers.hostinger.com/api/vps/v1/virtual-machines/17923' => Http::response([ + 'id' => 17923, + 'hostname' => 'hostinger.example.com', + 'state' => 'running', + 'ipv4' => [['address' => '1.2.3.4']], + ]), + ]); + + Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]) + ->set('selectedHostingerTokenId', $token->id) + ->call('searchHostingerVirtualMachine') + ->assertSet('matchedHostingerVirtualMachine.id', 17923) + ->call('linkToHostinger') + ->assertDispatched('success'); + + expect($this->server->fresh()->hostinger_virtual_machine_id)->toBe(17923) + ->and($this->server->fresh()->hostinger_virtual_machine_status)->toBe('running') + ->and($this->server->fresh()->cloud_provider_token_id)->toBe($token->id); }); it('hides link cloud provider dropdown when no providers can be linked', function () { diff --git a/tests/Feature/CloudProviderTokenShowTest.php b/tests/Feature/CloudProviderTokenShowTest.php index fad6be2287..1c657c644c 100644 --- a/tests/Feature/CloudProviderTokenShowTest.php +++ b/tests/Feature/CloudProviderTokenShowTest.php @@ -49,6 +49,24 @@ test('server cloud provider token cards show token descriptions', function () { ->assertSee('Used for production servers in the EU region.'); }); +test('server cloud provider token page supports Hostinger VPS servers', function () { + $server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'hostinger_virtual_machine_id' => 17923, + ]); + + CloudProviderToken::factory()->create([ + 'team_id' => $this->team->id, + 'provider' => 'hostinger', + 'name' => 'Production Hostinger', + ]); + + Livewire::test(Show::class, ['server_uuid' => $server->uuid]) + ->assertSet('provider', 'hostinger') + ->assertSet('providerName', 'Hostinger') + ->assertSee('Production Hostinger'); +}); + describe('after the user switches the session team', function () { beforeEach(function () { $this->withoutDefer(); diff --git a/tests/Feature/DevScriptTest.php b/tests/Feature/DevScriptTest.php index 1864c012f9..232bde15de 100644 --- a/tests/Feature/DevScriptTest.php +++ b/tests/Feature/DevScriptTest.php @@ -245,6 +245,30 @@ BASH); ->not->toContain('--profile testing-host up'); }); +it('runs the host qemu command with a clean local environment', function () { + if (! function_exists('posix_geteuid') || posix_geteuid() !== 0 || ! file_exists('/dev/kvm') || filetype('/dev/kvm') !== 'char' || ! is_readable('/dev/kvm') || ! is_writable('/dev/kvm')) { + $this->markTestSkipped('KVM and root access are required for this launcher branch.'); + } + + file_put_contents($this->devRoot.'/bin/php', <<<'BASH' +#!/usr/bin/env bash +[[ "$APP_ENV" == local && "$APP_CONFIG_CACHE" == */bootstrap/cache/dev-qemu-*.php ]] || { + echo 'This command may only run in development mode.' >&2 + exit 1 +} +printf 'php %s ENV=%s CACHE=%s\n' "$*" "$APP_ENV" "$APP_CONFIG_CACHE" >> "$DEV_TEST_LOG" +BASH); + + $process = runDevScript('wt', ['start', 'ubuntu-root'], [ + 'COOLIFY_DEV_SERVER_BACKEND' => 'auto', + 'APP_ENV' => 'production', + 'APP_CONFIG_CACHE' => '/tmp/stale-config.php', + ]); + + expect($process->isSuccessful())->toBeTrue($process->getErrorOutput()) + ->and(devScriptLog())->toContain('php artisan dev:qemu ubuntu-root --as-localhost ENV=local CACHE='); +}); + it('starts a lima vm as localhost on macos and seeds its forwarded ssh port', function () { ($this->fakeLima)(); runDevScript('main', ['start']); diff --git a/tests/Feature/HostingerApiTest.php b/tests/Feature/HostingerApiTest.php new file mode 100644 index 0000000000..bdb5c309e4 --- /dev/null +++ b/tests/Feature/HostingerApiTest.php @@ -0,0 +1,273 @@ +whereKey(0)->delete(); + InstanceSettings::unguarded(fn () => InstanceSettings::query()->create([ + 'id' => 0, + 'is_api_enabled' => true, + ])); + Once::flush(); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + session(['currentTeam' => $this->team]); + + $this->bearerToken = $this->user->createToken('test-token', ['*'])->plainTextToken; + $this->hostingerToken = CloudProviderToken::factory()->create([ + 'team_id' => $this->team->id, + 'provider' => 'hostinger', + 'token' => 'hostinger-api-token', + ]); + $this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); +}); + +it('gets Hostinger data centers', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/data-centers' => Http::response([ + ['id' => 19, 'city' => 'Amsterdam', 'location' => 'nl'], + ]), + ]); + + $this->withToken($this->bearerToken) + ->getJson('/api/v1/hostinger/data-centers?cloud_provider_token_id='.$this->hostingerToken->uuid) + ->assertSuccessful() + ->assertJsonFragment(['id' => 19, 'city' => 'Amsterdam']); +}); + +it('gets Hostinger SSH keys and post-install scripts', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/public-keys' => Http::response([ + 'data' => [['id' => 42, 'name' => 'Operations']], + ]), + 'https://developers.hostinger.com/api/vps/v1/post-install-scripts' => Http::response([ + 'data' => [['id' => 73, 'name' => 'Bootstrap Coolify']], + ]), + ]); + + $query = '?cloud_provider_token_id='.$this->hostingerToken->uuid; + + $this->withToken($this->bearerToken) + ->getJson('/api/v1/hostinger/ssh-keys'.$query) + ->assertSuccessful() + ->assertJsonFragment(['id' => 42, 'name' => 'Operations']); + + $this->withToken($this->bearerToken) + ->getJson('/api/v1/hostinger/post-install-scripts'.$query) + ->assertSuccessful() + ->assertJsonFragment(['id' => 73, 'name' => 'Bootstrap Coolify']); +}); + +it('gets only Hostinger KVM plans through the API', function () { + Http::fake([ + 'https://developers.hostinger.com/api/billing/v1/catalog*' => Http::response([ + ['id' => 'hostingercom-vps-kvm1', 'name' => 'KVM 1', 'prices' => []], + ['id' => 'hostingercom-vps-kvmminecraftalex', 'name' => 'Game Panel 1', 'prices' => []], + ]), + ]); + + $this->withToken($this->bearerToken) + ->getJson('/api/v1/hostinger/catalog?cloud_provider_token_id='.$this->hostingerToken->uuid) + ->assertSuccessful() + ->assertJsonCount(1) + ->assertJsonFragment(['id' => 'hostingercom-vps-kvm1']) + ->assertJsonMissing(['id' => 'hostingercom-vps-kvmminecraftalex']); +}); + +it('returns 403 with the Hostinger message when Hostinger denies a provider list', function (int $hostingerStatus) { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/post-install-scripts' => Http::response([ + 'message' => '[VPS:2000] Unauthorized', + ], $hostingerStatus), + ]); + + $this->withToken($this->bearerToken) + ->getJson('/api/v1/hostinger/post-install-scripts?cloud_provider_token_id='.$this->hostingerToken->uuid) + ->assertForbidden() + ->assertExactJson(['message' => 'Hostinger denied access to post-install scripts: [VPS:2000] Unauthorized']); +})->with([401, 403]); + +it('returns 429 when Hostinger rate limits a provider list', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/templates' => Http::response([ + 'message' => 'Too many requests.', + ], 429, ['Retry-After' => '30']), + ]); + + $this->withToken($this->bearerToken) + ->getJson('/api/v1/hostinger/templates?cloud_provider_token_id='.$this->hostingerToken->uuid) + ->assertStatus(429) + ->assertHeader('Retry-After', '30') + ->assertExactJson(['message' => 'Rate limit exceeded. Please try again later.']); +}); + +it('returns a generic error when a Hostinger provider list fails', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/data-centers' => Http::response([ + 'message' => 'Invalid request', + ], 422), + ]); + + $this->withToken($this->bearerToken) + ->getJson('/api/v1/hostinger/data-centers?cloud_provider_token_id='.$this->hostingerToken->uuid) + ->assertServerError() + ->assertExactJson(['message' => 'Failed to fetch Hostinger data centers.']); +}); + +it('creates a Hostinger VPS server through the API', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([ + 'order' => ['id' => 2957086, 'status' => 'completed'], + 'virtual_machine' => [ + 'id' => 17923, + 'state' => 'creating', + 'ipv4' => [['address' => '203.0.113.10']], + ], + ]), + ]); + + $this->withToken($this->bearerToken) + ->postJson('/api/v1/servers/hostinger', [ + 'cloud_provider_token_id' => $this->hostingerToken->uuid, + 'item_id' => 'hostingercom-vps-kvm2-usd-1m', + 'data_center_id' => 19, + 'template_id' => 1130, + 'name' => 'api-hostinger.example.com', + 'private_key_uuid' => $this->privateKey->uuid, + 'enable_backups' => true, + ]) + ->assertCreated() + ->assertJsonFragment([ + 'hostinger_virtual_machine_id' => 17923, + 'ip' => '203.0.113.10', + ]); + + $this->assertDatabaseHas('servers', [ + 'team_id' => $this->team->id, + 'hostinger_virtual_machine_id' => 17923, + 'ip' => '203.0.113.10', + ]); + + Http::assertSent(fn ($request) => $request->method() === 'POST' + && $request->url() === 'https://developers.hostinger.com/api/vps/v1/virtual-machines' + && $request['setup']['enable_backups'] === true); +}); + +it('turns paid Hostinger backups off when the API request does not enable them', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([ + 'virtual_machine' => [ + 'id' => 17923, + 'state' => 'creating', + 'ipv4' => [['address' => '203.0.113.10']], + ], + ]), + ]); + + $this->withToken($this->bearerToken) + ->postJson('/api/v1/servers/hostinger', [ + 'cloud_provider_token_uuid' => $this->hostingerToken->uuid, + 'item_id' => 'hostingercom-vps-kvm1-usd-1m', + 'data_center_id' => 19, + 'template_id' => 1130, + 'private_key_uuid' => $this->privateKey->uuid, + ]) + ->assertCreated(); + + Http::assertSent(fn ($request) => $request->method() === 'POST' + && $request->url() === 'https://developers.hostinger.com/api/vps/v1/virtual-machines' + && $request['setup']['enable_backups'] === false); +}); + +it('keeps a charged Hostinger VPS when the purchase returns an error through the API', function () { + $listRequests = 0; + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines/2008976/setup' => Http::response([ + 'id' => 2008976, + 'state' => 'creating', + 'ipv4' => [['address' => '72.61.180.163']], + ]), + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => function ($request) use (&$listRequests) { + if ($request->method() === 'POST') { + return Http::response(['message' => '[VPS:2004] Wrong hostname FQDN format'], 422); + } + + return Http::response($listRequests++ === 0 ? [] : [['id' => 2008976, 'state' => 'initial']]); + }, + ]); + + $this->withToken($this->bearerToken) + ->postJson('/api/v1/servers/hostinger', [ + 'cloud_provider_token_id' => $this->hostingerToken->uuid, + 'item_id' => 'hostingercom-vps-kvm1-usd-1m', + 'data_center_id' => 19, + 'template_id' => 1077, + 'private_key_uuid' => $this->privateKey->uuid, + ]) + ->assertCreated() + ->assertJsonFragment([ + 'hostinger_virtual_machine_id' => 2008976, + 'ip' => '72.61.180.163', + ]); + + $this->assertDatabaseHas('servers', [ + 'team_id' => $this->team->id, + 'hostinger_virtual_machine_id' => 2008976, + 'ip' => '72.61.180.163', + ]); +}); + +it('reports a Hostinger order that is waiting for payment through the API', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([ + 'id' => 2957086, + 'status' => 'payment_initiated', + ], 202), + ]); + + $this->withToken($this->bearerToken) + ->postJson('/api/v1/servers/hostinger', [ + 'cloud_provider_token_id' => $this->hostingerToken->uuid, + 'item_id' => 'hostingercom-vps-kvm2-usd-1m', + 'data_center_id' => 19, + 'template_id' => 1130, + 'private_key_uuid' => $this->privateKey->uuid, + ]) + ->assertStatus(202) + ->assertJsonFragment(['message' => 'Hostinger order 2957086 is waiting for payment. Finish the VPS setup in hPanel.']); + + $this->assertDatabaseMissing('servers', ['team_id' => $this->team->id]); +}); + +it('accepts Hostinger cloud provider tokens through the API', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([]), + ]); + + $this->withToken($this->bearerToken) + ->postJson('/api/v1/cloud-tokens', [ + 'provider' => 'hostinger', + 'token' => 'another-hostinger-token', + 'name' => 'Another Hostinger Token', + ]) + ->assertCreated(); + + $this->assertDatabaseHas('cloud_provider_tokens', [ + 'team_id' => $this->team->id, + 'provider' => 'hostinger', + 'name' => 'Another Hostinger Token', + ]); +}); diff --git a/tests/Feature/HostingerServerCreationTest.php b/tests/Feature/HostingerServerCreationTest.php new file mode 100644 index 0000000000..48f24aa2eb --- /dev/null +++ b/tests/Feature/HostingerServerCreationTest.php @@ -0,0 +1,484 @@ + 'array', + 'session.driver' => 'array', + ]); + + InstanceSettings::unguarded(fn () => InstanceSettings::query()->create([ + 'id' => 0, + ])); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + $this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); + $this->token = CloudProviderToken::factory()->create([ + 'team_id' => $this->team->id, + 'provider' => 'hostinger', + 'token' => 'hostinger-token', + 'name' => 'Production Hostinger', + ]); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); +}); + +it('offers Hostinger as a server provider', function () { + Livewire::test(Create::class) + ->assertSee('Hostinger') + ->assertSee(route('server.create.type', ['type' => 'hostinger']), false); + + Livewire::test(CreatePage::class, ['type' => 'hostinger']) + ->assertSet('title', 'Hostinger') + ->assertSet('tokenProvider', 'hostinger'); +}); + +it('uses the current cloud provider UI and Hostinger affiliate link', function () { + Livewire::test(ByHostinger::class) + ->assertSee('Hostinger account') + ->assertSee('https://www.hostinger.com/vps/coolify-hosting?ref=coolify.io', false) + ->assertSee("through Coolify's affiliate link.", false); + + Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid]) + ->set('loading_data', false) + ->assertSee('Hostinger server') + ->assertSee('Advanced options') + ->assertSee('Buy and create'); +}); + +it('leaves paid Hostinger backups off by default', function () { + Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid]) + ->assertSet('enable_backups', false) + ->set('loading_data', false) + ->assertSee('Enable weekly Hostinger backups (extra cost)') + ->assertSee('Hostinger charges extra for backups. Check the price in hPanel before you enable them.'); +}); + +it('selects Hostinger account SSH keys with the same dropdown as Hetzner', function () { + Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid]) + ->set('loading_data', false) + ->set('hostinger_public_keys', [['id' => 42, 'name' => 'Operations']]) + ->assertSee('Extra SSH keys') + ->assertSee('Existing keys from the Hostinger account.') + ->assertSee('Search SSH keys') + ->assertSeeHtml('') + ->assertDontSeeHtml('Operations') + ->assertDontSeeHtml('id="hostinger-public-key-42-'); + + Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid]) + ->set('loading_data', false) + ->set('hostinger_public_keys', []) + ->assertSee('No account keys found'); +}); + +it('purchases a Hostinger VPS and creates the linked Coolify server', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/data-centers' => Http::response([ + ['id' => 19, 'name' => 'nl-ams', 'city' => 'Amsterdam', 'location' => 'nl'], + ]), + 'https://developers.hostinger.com/api/vps/v1/templates' => Http::response([ + ['id' => 1130, 'name' => 'Ubuntu 24.04 LTS'], + ]), + 'https://developers.hostinger.com/api/billing/v1/catalog*' => Http::response([ + [ + 'id' => 'hostingercom-vps-kvm2', + 'name' => 'KVM 2', + 'category' => 'VPS', + 'prices' => [ + [ + 'id' => 'hostingercom-vps-kvm2-usd-1m', + 'currency' => 'USD', + 'price' => 1799, + 'first_period_price' => 899, + 'period' => 1, + 'period_unit' => 'month', + ], + ], + ], + ]), + 'https://developers.hostinger.com/api/vps/v1/public-keys' => Http::response(['data' => []]), + 'https://developers.hostinger.com/api/vps/v1/post-install-scripts' => Http::response(['data' => []]), + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([ + 'order' => ['id' => 2957086, 'status' => 'completed'], + 'virtual_machine' => [ + 'id' => 17923, + 'hostname' => 'coolify-hostinger.example.com', + 'state' => 'creating', + 'ipv4' => [['address' => '203.0.113.10']], + ], + ]), + ]); + + Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid]) + ->call('loadHostingerData') + ->set('server_name', 'coolify-hostinger.example.com') + ->set('selected_data_center_id', 19) + ->set('selected_template_id', 1130) + ->set('selected_price_id', 'hostingercom-vps-kvm2-usd-1m') + ->set('private_key_id', $this->privateKey->id) + ->set('enable_backups', true) + ->call('submit') + ->assertHasNoErrors(); + + $server = Server::query()->where('hostinger_virtual_machine_id', 17923)->firstOrFail(); + + expect($server->name)->toBe('coolify-hostinger.example.com') + ->and($server->ip)->toBe('203.0.113.10') + ->and($server->hostinger_virtual_machine_status)->toBe('creating') + ->and($server->cloud_provider_token_id)->toBe($this->token->id); + + Http::assertSent(fn ($request) => $request->method() === 'POST' + && $request->url() === 'https://developers.hostinger.com/api/vps/v1/virtual-machines' + && $request['item_id'] === 'hostingercom-vps-kvm2-usd-1m' + && $request['setup']['hostname'] === 'coolify-hostinger.example.com' + && $request['setup']['public_key']['key'] === $this->privateKey->getPublicKey()); +}); + +it('keeps a purchased Hostinger VPS linked while its public IP is pending', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/data-centers' => Http::response([['id' => 19, 'name' => 'nl-ams', 'city' => null]]), + 'https://developers.hostinger.com/api/vps/v1/templates' => Http::response([['id' => 1130, 'name' => 'Ubuntu']]), + 'https://developers.hostinger.com/api/billing/v1/catalog*' => Http::response([ + ['id' => 'hostingercom-vps-kvm2', 'name' => 'KVM 2', 'prices' => [['id' => 'kvm2-monthly']]], + ]), + 'https://developers.hostinger.com/api/vps/v1/public-keys' => Http::response(['data' => []]), + 'https://developers.hostinger.com/api/vps/v1/post-install-scripts' => Http::response(['data' => []]), + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([ + 'virtual_machine' => ['id' => 17923, 'state' => 'creating', 'ipv4' => []], + ]), + 'https://developers.hostinger.com/api/vps/v1/virtual-machines/17923' => Http::response([ + 'message' => 'Still provisioning', + ], 503), + ]); + + Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid]) + ->call('loadHostingerData') + ->set('server_name', 'pending-hostinger.example.com') + ->set('selected_data_center_id', 19) + ->set('selected_template_id', 1130) + ->set('selected_price_id', 'kvm2-monthly') + ->set('private_key_id', $this->privateKey->id) + ->call('submit') + ->assertHasNoErrors(); + + expect(Server::query()->where('hostinger_virtual_machine_id', 17923)->firstOrFail()->ip) + ->toBe(Server::PLACEHOLDER_IP); +}); + +it('loads the Hostinger form when optional provider lists are not authorized', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/data-centers' => Http::response([['id' => 19, 'name' => 'nl-ams', 'city' => 'Amsterdam']]), + 'https://developers.hostinger.com/api/vps/v1/templates' => Http::response([['id' => 1130, 'name' => 'Ubuntu']]), + 'https://developers.hostinger.com/api/billing/v1/catalog*' => Http::response([ + ['id' => 'hostingercom-vps-kvm2', 'name' => 'KVM 2', 'prices' => [['id' => 'kvm2-monthly']]], + ]), + 'https://developers.hostinger.com/api/vps/v1/public-keys' => Http::response(['message' => '[VPS:2000] Unauthorized'], 403), + 'https://developers.hostinger.com/api/vps/v1/post-install-scripts' => Http::response(['message' => '[VPS:2000] Unauthorized'], 403), + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([ + 'virtual_machine' => ['id' => 17923, 'state' => 'creating', 'ipv4' => [['address' => '203.0.113.10']]], + ]), + ]); + + Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid]) + ->call('loadHostingerData') + ->assertSet('provider_data_error', null) + ->assertSet('hostinger_public_keys', []) + ->assertSet('post_install_scripts', []) + ->assertSee('Buy and create') + ->set('server_name', 'optional-lists.example.com') + ->set('selected_data_center_id', 19) + ->set('selected_template_id', 1130) + ->set('selected_price_id', 'kvm2-monthly') + ->set('private_key_id', $this->privateKey->id) + ->call('submit') + ->assertHasNoErrors(); + + expect(Server::query()->where('hostinger_virtual_machine_id', 17923)->exists())->toBeTrue(); +}); + +it('revalidates the selected Hostinger price before making a purchase', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/data-centers' => Http::response([ + ['id' => 19, 'name' => 'nl-ams', 'city' => 'Amsterdam'], + ]), + 'https://developers.hostinger.com/api/vps/v1/templates' => Http::response([ + ['id' => 1130, 'name' => 'Ubuntu 24.04 LTS'], + ]), + 'https://developers.hostinger.com/api/billing/v1/catalog*' => Http::response([ + [ + 'id' => 'hostingercom-vps-kvm2', + 'name' => 'KVM 2', + 'prices' => [['id' => 'hostingercom-vps-kvm2-usd-1m']], + ], + ]), + 'https://developers.hostinger.com/api/vps/v1/public-keys' => Http::response(['data' => []]), + 'https://developers.hostinger.com/api/vps/v1/post-install-scripts' => Http::response(['data' => []]), + ]); + + Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid]) + ->call('loadHostingerData') + ->set('server_name', 'coolify-hostinger.example.com') + ->set('selected_data_center_id', 19) + ->set('selected_template_id', 1130) + ->set('selected_price_id', 'tampered-price-id') + ->set('private_key_id', $this->privateKey->id) + ->call('submit') + ->assertHasErrors('selected_price_id'); + + Http::assertNotSent(fn ($request) => $request->method() === 'POST'); +}); + +function fakeHostingerProvisioningOptions(array $overrides = []): void +{ + $price = fn (string $plan, int $period, string $unit, string $suffix) => [ + 'id' => "hostingercom-vps-{$plan}-usd-{$suffix}", + 'currency' => 'USD', + 'price' => 1000, + 'first_period_price' => 500, + 'period' => $period, + 'period_unit' => $unit, + ]; + $prices = fn (string $plan) => [ + $price($plan, 2, 'year', '2y'), + $price($plan, 1, 'month', '1m'), + $price($plan, 1, 'year', '1y'), + ]; + + Http::fake(array_merge([ + 'https://developers.hostinger.com/api/vps/v1/data-centers' => Http::response([ + ['id' => 19, 'name' => 'nl-ams', 'city' => 'Amsterdam', 'location' => 'nl'], + ]), + 'https://developers.hostinger.com/api/vps/v1/templates' => Http::response([ + ['id' => 1121, 'name' => 'AlmaLinux 9 with CyberPanel'], + ['id' => 1130, 'name' => 'Ubuntu 24.04 LTS'], + ['id' => 1077, 'name' => 'Debian 13'], + ['id' => 1150, 'name' => 'Ubuntu 24.04 with Coolify'], + ]), + 'https://developers.hostinger.com/api/billing/v1/catalog*' => Http::response([ + ['id' => 'hostingercom-vps-kvm8', 'name' => 'KVM 8', 'metadata' => ['cpus' => 8], 'prices' => $prices('kvm8')], + ['id' => 'hostingercom-vps-kvmminecraftalex', 'name' => 'Game Panel 1', 'metadata' => ['cpus' => 1], 'prices' => $prices('kvmminecraftalex')], + ['id' => 'hostingercom-vps-kvm1', 'name' => 'KVM 1', 'metadata' => ['cpus' => 1], 'prices' => $prices('kvm1')], + ['id' => 'hostingercom-vps-kvm2', 'name' => 'KVM 2', 'metadata' => ['cpus' => 2], 'prices' => $prices('kvm2')], + ]), + 'https://developers.hostinger.com/api/vps/v1/public-keys' => Http::response(['data' => []]), + 'https://developers.hostinger.com/api/vps/v1/post-install-scripts' => Http::response(['data' => []]), + ], $overrides)); +} + +it('lists only Hostinger KVM plans sorted by size and billing period', function () { + fakeHostingerProvisioningOptions(); + + $component = Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid]) + ->call('loadHostingerData'); + + expect(collect($component->instance()->priceOptions)->pluck('id')->all())->toBe([ + 'hostingercom-vps-kvm1-usd-1m', + 'hostingercom-vps-kvm1-usd-1y', + 'hostingercom-vps-kvm1-usd-2y', + 'hostingercom-vps-kvm2-usd-1m', + 'hostingercom-vps-kvm2-usd-1y', + 'hostingercom-vps-kvm2-usd-2y', + 'hostingercom-vps-kvm8-usd-1m', + 'hostingercom-vps-kvm8-usd-1y', + 'hostingercom-vps-kvm8-usd-2y', + ]); + + $component->assertDontSee('Game Panel'); +}); + +it('splits Hostinger templates into plain operating systems and operating systems with applications', function () { + fakeHostingerProvisioningOptions(); + + $component = Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid]) + ->call('loadHostingerData') + ->assertSet('selected_os_template_id', 1130) + ->assertSet('selected_app_template_id', null) + ->assertSet('selected_template_id', 1130) + ->assertSee('Operating system with application') + ->assertSee('can conflict with the Coolify proxy on ports 80 and 443', false); + + expect(collect($component->instance()->osTemplates)->pluck('name')->all()) + ->toBe(['Debian 13', 'Ubuntu 24.04 LTS']) + ->and(collect($component->instance()->appTemplates)->pluck('name')->all()) + ->toBe(['AlmaLinux 9 with CyberPanel', 'Ubuntu 24.04 with Coolify']); +}); + +it('keeps only one Hostinger operating system selection', function () { + fakeHostingerProvisioningOptions(); + + Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid]) + ->call('loadHostingerData') + ->set('selected_app_template_id', 1150) + ->assertSet('selected_os_template_id', null) + ->assertSet('selected_template_id', 1150) + ->set('selected_os_template_id', 1077) + ->assertSet('selected_app_template_id', null) + ->assertSet('selected_template_id', 1077); +}); + +it('purchases a Hostinger VPS with an operating system that includes an application', function () { + fakeHostingerProvisioningOptions([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([ + 'virtual_machine' => ['id' => 17923, 'state' => 'creating', 'ipv4' => [['address' => '203.0.113.10']]], + ]), + ]); + + Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid]) + ->call('loadHostingerData') + ->set('server_name', 'app-template.example.com') + ->set('selected_data_center_id', 19) + ->set('selected_price_id', 'hostingercom-vps-kvm2-usd-1m') + ->set('selected_app_template_id', 1150) + ->set('private_key_id', $this->privateKey->id) + ->call('submit') + ->assertHasNoErrors(); + + Http::assertSent(fn ($request) => $request->method() === 'POST' + && $request->url() === 'https://developers.hostinger.com/api/vps/v1/virtual-machines' + && $request['setup']['template_id'] === 1150); +}); + +it('rejects a Game Panel Hostinger plan before making a purchase', function () { + fakeHostingerProvisioningOptions(); + + Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid]) + ->call('loadHostingerData') + ->set('server_name', 'game-panel.example.com') + ->set('selected_data_center_id', 19) + ->set('selected_price_id', 'hostingercom-vps-kvmminecraftalex-usd-1m') + ->set('private_key_id', $this->privateKey->id) + ->call('submit') + ->assertHasErrors('selected_price_id'); + + Http::assertNotSent(fn ($request) => $request->method() === 'POST'); +}); + +it('starts a stopped Hostinger VPS from the server page', function () { + $server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $this->privateKey->id, + 'cloud_provider_token_id' => $this->token->id, + 'hostinger_virtual_machine_id' => 17923, + 'hostinger_virtual_machine_status' => 'stopped', + ]); + + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines/17923/start' => Http::response([ + 'id' => 456, + 'state' => 'starting', + ]), + ]); + + Livewire::test(Show::class, ['server_uuid' => $server->uuid]) + ->call('startHostingerVirtualMachine') + ->assertSet('hostingerVirtualMachineStatus', 'starting') + ->assertDispatched('success'); + + expect($server->fresh()->hostinger_virtual_machine_status)->toBe('starting'); +}); + +it('labels the Hostinger status refresh button on the server page', function () { + $server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $this->privateKey->id, + 'cloud_provider_token_id' => $this->token->id, + 'hostinger_virtual_machine_id' => 17923, + 'hostinger_virtual_machine_status' => 'running', + ]); + + Livewire::test(Show::class, ['server_uuid' => $server->uuid]) + ->assertSee('Hostinger · Running') + ->assertSee('Refresh status') + ->assertSeeHtml('wire:click.prevent="checkHostingerVirtualMachineStatus(true)"'); +}); + +it('does not validate a stopped Hostinger VPS over SSH', function () { + $server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $this->privateKey->id, + 'cloud_provider_token_id' => $this->token->id, + 'hostinger_virtual_machine_id' => 17923, + 'hostinger_virtual_machine_status' => 'stopped', + ]); + + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines/17923' => Http::response([ + 'id' => 17923, + 'state' => 'stopped', + 'ipv4' => [['address' => $server->ip]], + ]), + ]); + + Livewire::test(ValidateAndInstall::class, ['server' => $server]) + ->call('validateConnection') + ->assertSet('error', 'Hostinger VPS is stopped. Power it on before validating.'); +}); + +it('warns that deleting from Coolify does not cancel the Hostinger VPS', function () { + $server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $this->privateKey->id, + 'cloud_provider_token_id' => $this->token->id, + 'hostinger_virtual_machine_id' => 17923, + ]); + + Livewire::test(Delete::class, ['server_uuid' => $server->uuid]) + ->assertSee('Hostinger deletes the VPS only at the end of its paid period, and only if you select this in the confirmation dialog.') + ->assertSee('https://hpanel.hostinger.com/', false) + ->assertSeeHtml('Hostinger deletes a VPS only at the end of its paid period.') + ->assertSee('The VPS on Hostinger will NOT be deleted, and Hostinger will keep billing for it.'); +}); + +it('passes the Hostinger deletion choice to the server deletion', function () { + Queue::fake(); + $server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $this->privateKey->id, + 'cloud_provider_token_id' => $this->token->id, + 'hostinger_virtual_machine_id' => 17923, + ]); + + Livewire::test(Delete::class, ['server_uuid' => $server->uuid]) + ->assertSee('Also delete VPS from Hostinger (at the end of the paid period)') + ->call('delete', 'password', ['delete_from_hostinger']); + + Queue::assertPushed(JobDecorator::class, fn (JobDecorator $job) => $job->getAction() instanceof DeleteServer + && $job->getParameters()[9] === true + && $job->getParameters()[10] === 17923); +}); + +it('keeps the default deletion warning for servers without a Hostinger VPS', function () { + $server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $this->privateKey->id, + ]); + + Livewire::test(Delete::class, ['server_uuid' => $server->uuid]) + ->assertSee('This operation is permanent and cannot be undone.') + ->assertDontSee('Hostinger deletes a VPS'); +}); diff --git a/tests/Feature/ModelFillableCreationTest.php b/tests/Feature/ModelFillableCreationTest.php index 54870d3f8c..a71e1ea041 100644 --- a/tests/Feature/ModelFillableCreationTest.php +++ b/tests/Feature/ModelFillableCreationTest.php @@ -87,6 +87,8 @@ it('creates Server with all fillable attributes', function () { 'hetzner_server_status' => 'running', 'digitalocean_droplet_id' => 987654, 'digitalocean_droplet_status' => 'active', + 'hostinger_virtual_machine_id' => 17923, + 'hostinger_virtual_machine_status' => 'running', 'is_validating' => false, 'detected_traefik_version' => 'v2.10.0', 'traefik_outdated_info' => 'Up to date', @@ -106,6 +108,8 @@ it('creates Server with all fillable attributes', function () { expect($server->hetzner_server_status)->toBe('running'); expect($server->digitalocean_droplet_id)->toBe(987654); expect($server->digitalocean_droplet_status)->toBe('active'); + expect($server->hostinger_virtual_machine_id)->toBe(17923); + expect($server->hostinger_virtual_machine_status)->toBe('running'); expect($server->ip_previous)->toBe('10.0.0.1'); }); diff --git a/tests/Feature/Security/CloudProviderTokenFormTest.php b/tests/Feature/Security/CloudProviderTokenFormTest.php index a3d06fba11..f1e6af5cde 100644 --- a/tests/Feature/Security/CloudProviderTokenFormTest.php +++ b/tests/Feature/Security/CloudProviderTokenFormTest.php @@ -60,7 +60,35 @@ test('security cloud token form lets users choose every supported provider', fun ->assertSee('Provider') ->assertSee('Hetzner') ->assertSee('DigitalOcean') - ->assertSee('Vultr'); + ->assertSee('Vultr') + ->assertSee('Hostinger'); +}); + +test('adding a hostinger token from a modal validates and refreshes hostinger token selectors', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([], 200), + ]); + + $component = Livewire::test(CloudProviderTokenForm::class, [ + 'modal_mode' => true, + 'provider' => 'hostinger', + ]) + ->set('name', 'Production Hostinger') + ->set('token', 'hostinger-token') + ->call('addToken') + ->assertHasNoErrors() + ->assertDispatched('close-modal'); + + $dispatches = collect(data_get($component->effects, 'dispatches', [])); + + expect($dispatches->contains(fn (array $dispatch) => $dispatch['name'] === 'tokenAdded.hostinger' + && data_get($dispatch, 'to') === 'server.new.by-hostinger'))->toBeTrue(); + + $this->assertDatabaseHas('cloud_provider_tokens', [ + 'team_id' => $this->team->id, + 'provider' => 'hostinger', + 'name' => 'Production Hostinger', + ]); }); test('adding a cloud provider token stores an optional description', function () { diff --git a/tests/Feature/Security/CloudProviderTokenShowTest.php b/tests/Feature/Security/CloudProviderTokenShowTest.php index c3a1ce082e..822b242f64 100644 --- a/tests/Feature/Security/CloudProviderTokenShowTest.php +++ b/tests/Feature/Security/CloudProviderTokenShowTest.php @@ -94,6 +94,21 @@ test('cloud token detail page validates the token', function () { ->assertDispatched('success'); }); +test('cloud token detail page validates Hostinger tokens', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([], 200), + ]); + + $token = CloudProviderToken::factory()->create([ + 'team_id' => $this->team->id, + 'provider' => 'hostinger', + ]); + + Livewire::test(Show::class, ['cloud_token_uuid' => $token->uuid]) + ->call('validateToken') + ->assertDispatched('success'); +}); + test('cloud token detail page deletes unused tokens', function () { $token = CloudProviderToken::factory()->create([ 'team_id' => $this->team->id, diff --git a/tests/Feature/ServerCloudProviderStatusCheckJobTest.php b/tests/Feature/ServerCloudProviderStatusCheckJobTest.php index 10e1a8c9d8..ba0ceee459 100644 --- a/tests/Feature/ServerCloudProviderStatusCheckJobTest.php +++ b/tests/Feature/ServerCloudProviderStatusCheckJobTest.php @@ -60,6 +60,36 @@ it('syncs provider state for a placeholder server without scheduling SSH', funct Queue::assertNotPushed(ServerConnectionCheckJob::class); }); +it('schedules Hostinger provider state checks', function () { + $server = createCloudServerForStatusCheckJobTest('hostinger', [ + 'hostinger_virtual_machine_id' => 17923, + 'hostinger_virtual_machine_status' => 'creating', + ]); + + Queue::fake(); + (new ServerManagerJob)->handle(); + Queue::assertPushed(ServerCloudProviderStatusCheckJob::class, fn (ServerCloudProviderStatusCheckJob $job) => $job->server->is($server)); +}); + +it('runs Hostinger provider state checks', function () { + $server = createCloudServerForStatusCheckJobTest('hostinger', [ + 'hostinger_virtual_machine_id' => 17923, + 'hostinger_virtual_machine_status' => 'creating', + ]); + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines/17923' => Http::response([ + 'id' => 17923, + 'state' => 'running', + 'ipv4' => [['address' => '203.0.113.23']], + ]), + ]); + + (new ServerCloudProviderStatusCheckJob($server))->handle(); + + expect($server->fresh()->ip)->toBe('203.0.113.23') + ->and($server->fresh()->hostinger_virtual_machine_status)->toBe('running'); +}); + it('backfills a DigitalOcean placeholder without changing reachability', function () { $server = createCloudServerForStatusCheckJobTest('digitalocean', [ 'digitalocean_droplet_id' => 987, diff --git a/tests/Unit/CloudProviderTokenFormViewTest.php b/tests/Unit/CloudProviderTokenFormViewTest.php index 58e0e60777..f5666a0b39 100644 --- a/tests/Unit/CloudProviderTokenFormViewTest.php +++ b/tests/Unit/CloudProviderTokenFormViewTest.php @@ -1,5 +1,47 @@ toContain('and($view)->toContain("['value' => 'vultr', 'label' => 'Vultr']") + ->and($view)->toContain("['value' => 'digitalocean', 'label' => 'DigitalOcean']") + ->and($view)->toContain("['value' => 'hostinger', 'label' => 'Hostinger']") + ->and(substr_count($view, 'https://console.vultr.com/user/apiaccess/'))->toBe(1) + ->and($view)->not->toContain('cloudProviderTokens->where(\'provider\', $provider)->isEmpty()') + ->and($view)->not->toContain('') + ->and($component)->toContain("'provider' => 'required|string|in:hetzner,digitalocean,vultr,hostinger'"); +}); + +it('keeps provider affiliate links on server provider views', function () { + $tokenFormView = file_get_contents(__DIR__.'/../../resources/views/livewire/security/cloud-provider-token-form.blade.php'); + $hetznerView = file_get_contents(__DIR__.'/../../resources/views/livewire/server/new/by-hetzner.blade.php'); + $vultrView = file_get_contents(__DIR__.'/../../resources/views/livewire/server/new/by-vultr.blade.php'); + $digitalOceanView = file_get_contents(__DIR__.'/../../resources/views/livewire/server/new/by-digital-ocean.blade.php'); + + expect($tokenFormView) + ->not->toContain('https://coolify.io/hetzner') + ->not->toContain('https://coolify.io/vultr') + ->and($hetznerView)->toContain('https://coolify.io/hetzner') + ->and($vultrView)->toContain('https://coolify.io/vultr') + ->and($digitalOceanView)->toContain('https://coolify.io/digitalocean'); +}); + +it('uses the shared modal form on the cloud provider tokens page', function () { + $view = file_get_contents(__DIR__.'/../../resources/views/livewire/security/cloud-provider-tokens.blade.php'); + + expect($view)->toContain('New token') + ->and($view)->toContain('and($view)->toContain('and($view)->toContain("\$savedToken->provider === 'digitalocean' ? 'DigitalOcean' : ucfirst(\$savedToken->provider)") + ->and($view)->not->toContain('

New Token

') + ->and($view)->not->toContain(':modal_mode="false"') + ->and($view)->not->toContain('wire:click="validateToken') + ->and($view)->not->toContain('submitAction="deleteToken') + ->and($view)->not->toContain('Created {{ $savedToken->created_at->diffForHumans() }}'); +}); + it('gates new cloud tokens and keys saved token rows by id', function () { $view = file_get_contents(__DIR__.'/../../resources/views/livewire/security/cloud-provider-tokens.blade.php'); diff --git a/tests/Unit/HostingerDeleteServerTest.php b/tests/Unit/HostingerDeleteServerTest.php new file mode 100644 index 0000000000..ee74c1fefd --- /dev/null +++ b/tests/Unit/HostingerDeleteServerTest.php @@ -0,0 +1,90 @@ +team = Team::factory()->create(); + $this->token = CloudProviderToken::factory()->create([ + 'team_id' => $this->team->id, + 'provider' => 'hostinger', + 'token' => 'test-hostinger-token', + ]); + $privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $privateKey->id, + 'cloud_provider_token_id' => $this->token->id, + 'hostinger_virtual_machine_id' => 17923, + ]); + $this->server->delete(); +}); + +it('deletes the Hostinger VPS by disabling auto-renewal when requested', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines/17923' => Http::response([ + 'id' => 17923, + 'subscription_id' => 'Azz2Y9VWEjUO69lF', + ]), + 'https://developers.hostinger.com/api/billing/v1/subscriptions/Azz2Y9VWEjUO69lF/auto-renewal/disable' => Http::response([ + 'id' => 'Azz2Y9VWEjUO69lF', + 'is_auto_renewed' => false, + ]), + ]); + + DeleteServer::run( + serverId: $this->server->id, + cloudProviderTokenId: $this->token->id, + teamId: $this->team->id, + deleteFromHostinger: true, + hostingerVirtualMachineId: 17923, + ); + + Http::assertSent(fn ($request) => $request->method() === 'DELETE' + && $request->url() === 'https://developers.hostinger.com/api/billing/v1/subscriptions/Azz2Y9VWEjUO69lF/auto-renewal/disable'); + expect(Server::withTrashed()->find($this->server->id))->toBeNull(); +}); + +it('retains the server when the Hostinger VPS cannot be deleted', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines/17923' => Http::response([ + 'id' => 17923, + 'subscription_id' => 'Azz2Y9VWEjUO69lF', + ]), + 'https://developers.hostinger.com/api/billing/v1/subscriptions/Azz2Y9VWEjUO69lF/auto-renewal/disable' => Http::response([ + 'message' => 'Unauthorized', + ], 403), + ]); + + expect(fn () => DeleteServer::run( + serverId: $this->server->id, + cloudProviderTokenId: $this->token->id, + teamId: $this->team->id, + deleteFromHostinger: true, + hostingerVirtualMachineId: 17923, + ))->toThrow(Exception::class, 'Hostinger API error: Unauthorized'); + + expect(Server::withTrashed()->find($this->server->id))->not->toBeNull(); +}); + +it('does not call Hostinger when the VPS should be kept', function () { + DeleteServer::run( + serverId: $this->server->id, + cloudProviderTokenId: $this->token->id, + teamId: $this->team->id, + hostingerVirtualMachineId: 17923, + ); + + Http::assertNothingSent(); + expect(Server::withTrashed()->find($this->server->id))->toBeNull(); +}); diff --git a/tests/Unit/HostingerIconTest.php b/tests/Unit/HostingerIconTest.php new file mode 100644 index 0000000000..bbb84c6fb9 --- /dev/null +++ b/tests/Unit/HostingerIconTest.php @@ -0,0 +1,10 @@ +toContain('viewBox="0 0 26 30"') + ->toContain('M0.000249566 14.046V0.000497794') + ->toContain('Hostinger'); +}); diff --git a/tests/Unit/HostingerServerStateTest.php b/tests/Unit/HostingerServerStateTest.php new file mode 100644 index 0000000000..c4073e2dbb --- /dev/null +++ b/tests/Unit/HostingerServerStateTest.php @@ -0,0 +1,45 @@ +create(); + $token = CloudProviderToken::factory()->create([ + 'team_id' => $team->id, + 'provider' => 'hostinger', + 'token' => 'hostinger-token', + ]); + $privateKey = PrivateKey::factory()->create(['team_id' => $team->id]); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'private_key_id' => $privateKey->id, + 'cloud_provider_token_id' => $token->id, + 'hostinger_virtual_machine_id' => 17923, + 'hostinger_virtual_machine_status' => 'creating', + 'ip' => '0.0.0.0', + ]); + + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines/17923' => Http::response([ + 'id' => 17923, + 'state' => 'running', + 'ipv4' => [['address' => '203.0.113.10']], + ]), + ]); + + expect($server->refreshHostingerState())->toBe('running') + ->and($server->fresh()->hostinger_virtual_machine_status)->toBe('running') + ->and($server->fresh()->ip)->toBe('203.0.113.10'); +}); diff --git a/tests/Unit/HostingerServiceTest.php b/tests/Unit/HostingerServiceTest.php new file mode 100644 index 0000000000..15cb16a422 --- /dev/null +++ b/tests/Unit/HostingerServiceTest.php @@ -0,0 +1,339 @@ + Http::response([ + ['id' => 19, 'name' => 'nl-ams', 'city' => 'Amsterdam', 'location' => 'nl'], + ]), + 'https://developers.hostinger.com/api/vps/v1/templates' => Http::response([ + ['id' => 1130, 'name' => 'Ubuntu 24.04 LTS'], + ]), + 'https://developers.hostinger.com/api/billing/v1/catalog?category=VPS' => Http::response([ + [ + 'id' => 'hostingercom-vps-kvm2', + 'name' => 'KVM 2', + 'category' => 'VPS', + 'prices' => [ + [ + 'id' => 'hostingercom-vps-kvm2-usd-1m', + 'currency' => 'USD', + 'price' => 1799, + 'first_period_price' => 899, + 'period' => 1, + 'period_unit' => 'month', + ], + ], + ], + ]), + ]); + + $service = new HostingerService('test-token'); + + expect($service->getDataCenters()[0]['city'])->toBe('Amsterdam') + ->and($service->getTemplates()[0]['name'])->toBe('Ubuntu 24.04 LTS') + ->and($service->getCatalogItems()[0]['prices'][0]['price'])->toBe(1799); +}); + +it('returns only KVM plans from the Hostinger VPS catalog', function () { + Http::fake([ + 'https://developers.hostinger.com/api/billing/v1/catalog?category=VPS' => Http::response([ + ['id' => 'hostingercom-vps-kvm1', 'name' => 'KVM 1', 'prices' => []], + ['id' => 'hostingercom-vps-kvmminecraftalex', 'name' => 'Game Panel 1', 'prices' => []], + ['id' => 'hostingercom-vps-kvm8', 'name' => 'KVM 8', 'prices' => []], + ['id' => 'hostingercom-vps-kvmminecraftwolf', 'name' => 'Game Panel 8', 'prices' => []], + ['name' => 'Missing id', 'prices' => []], + ]), + ]); + + expect(collect((new HostingerService('test-token'))->getCatalogItems())->pluck('id')->all()) + ->toBe(['hostingercom-vps-kvm1', 'hostingercom-vps-kvm8']); +}); + +it('fetches and attaches Hostinger account SSH keys', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/public-keys' => Http::response([ + 'data' => [['id' => 42, 'name' => 'Operations', 'key' => 'ssh-ed25519 AAAA']], + ]), + 'https://developers.hostinger.com/api/vps/v1/public-keys/attach/17923' => Http::response([ + 'id' => 456, + 'state' => 'running', + ]), + ]); + + $service = new HostingerService('test-token'); + + expect($service->getPublicKeys()[0]['id'])->toBe(42) + ->and($service->attachPublicKeys(17923, ['42'])['id'])->toBe(456); + + Http::assertSent(fn ($request) => $request->url() === 'https://developers.hostinger.com/api/vps/v1/public-keys/attach/17923' + && $request['ids'] === [42]); +}); + +it('fetches Hostinger post-install scripts', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/post-install-scripts' => Http::response([ + 'data' => [['id' => 73, 'name' => 'Bootstrap Coolify']], + ]), + ]); + + expect((new HostingerService('test-token'))->getPostInstallScripts()[0]['id'])->toBe(73); +}); + +it('purchases a Hostinger virtual machine with its setup options', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([ + 'order' => ['id' => 2957086, 'status' => 'completed'], + 'virtual_machine' => [ + 'id' => 17923, + 'hostname' => 'coolify-test.example.com', + 'state' => 'creating', + 'ipv4' => [['address' => '203.0.113.10']], + ], + ]), + ]); + + $virtualMachine = (new HostingerService('test-token'))->purchaseVirtualMachine([ + 'item_id' => 'hostingercom-vps-kvm2-usd-1m', + 'setup' => [ + 'data_center_id' => 19, + 'template_id' => 1130, + 'hostname' => 'coolify-test.example.com', + 'enable_backups' => true, + 'public_key' => [ + 'name' => 'Coolify', + 'key' => 'ssh-ed25519 AAAA test@example.com', + ], + ], + ]); + + expect($virtualMachine['id'])->toBe(17923); + + Http::assertSent(fn ($request) => $request->method() === 'POST' + && $request->url() === 'https://developers.hostinger.com/api/vps/v1/virtual-machines' + && $request->hasHeader('Authorization', 'Bearer test-token') + && $request['item_id'] === 'hostingercom-vps-kvm2-usd-1m' + && $request['setup']['data_center_id'] === 19 + && $request['setup']['template_id'] === 1130 + && $request['setup']['enable_backups'] === true + && $request['setup']['public_key']['key'] === 'ssh-ed25519 AAAA test@example.com'); +}); + +it('only sends a fully qualified hostname to Hostinger', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([ + 'virtual_machine' => ['id' => 17923, 'state' => 'creating'], + ]), + ]); + + $service = new HostingerService('test-token'); + $service->purchaseVirtualMachine([ + 'item_id' => 'hostingercom-vps-kvm1-usd-1m', + 'setup' => ['data_center_id' => 19, 'template_id' => 1130, 'hostname' => 'coolify-server'], + ]); + $service->purchaseVirtualMachine([ + 'item_id' => 'hostingercom-vps-kvm1-usd-1m', + 'setup' => ['data_center_id' => 19, 'template_id' => 1130, 'hostname' => 'coolify.example.com'], + ]); + + $sentHostnames = Http::recorded()->filter(fn ($record) => $record[0]->method() === 'POST')->values()->map(fn ($record) => $record[0]['setup']['hostname'] ?? null)->all(); + + expect($sentHostnames)->toBe([null, 'coolify.example.com']); +}); + +it('does not retry a failed Hostinger purchase', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => fn ($request) => $request->method() === 'GET' + ? Http::response([]) + : Http::response(['message' => 'Server error'], 500), + ]); + + expect(fn () => (new HostingerService('test-token'))->purchaseVirtualMachine([ + 'item_id' => 'hostingercom-vps-kvm2-usd-1m', + 'setup' => ['data_center_id' => 19, 'template_id' => 1130], + ]))->toThrow(Exception::class, 'Hostinger API error: Server error'); + + Http::assertSentCount(3); + expect(Http::recorded()->filter(fn ($record) => $record[0]->method() === 'POST'))->toHaveCount(1); +}); + +it('sets up a charged Hostinger VPS when the purchase returns an error', function () { + $listRequests = 0; + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines/2008976/setup' => Http::response([ + 'id' => 2008976, + 'state' => 'creating', + 'ipv4' => [['address' => '72.61.180.163']], + ]), + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => function ($request) use (&$listRequests) { + if ($request->method() === 'POST') { + return Http::response(['message' => '[VPS:2004] Wrong hostname FQDN format'], 422); + } + + return Http::response($listRequests++ === 0 + ? [['id' => 100, 'state' => 'running']] + : [['id' => 100, 'state' => 'running'], ['id' => 2008976, 'state' => 'initial']]); + }, + ]); + + $virtualMachine = (new HostingerService('test-token'))->purchaseVirtualMachine([ + 'item_id' => 'hostingercom-vps-kvm1-usd-1m', + 'setup' => ['data_center_id' => 19, 'template_id' => 1077, 'enable_backups' => false], + ]); + + expect($virtualMachine['id'])->toBe(2008976) + ->and($virtualMachine['state'])->toBe('creating'); + + Http::assertSent(fn ($request) => $request->url() === 'https://developers.hostinger.com/api/vps/v1/virtual-machines/2008976/setup' + && $request['data_center_id'] === 19 + && $request['template_id'] === 1077 + && $request['enable_backups'] === false); +}); + +it('returns a charged Hostinger VPS that still needs setup when the setup retry fails', function () { + $listRequests = 0; + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines/2008976/setup' => Http::response(['message' => 'Setup failed'], 422), + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => function ($request) use (&$listRequests) { + if ($request->method() === 'POST') { + return Http::response(['message' => 'Setup failed'], 422); + } + + return Http::response($listRequests++ === 0 ? [] : [['id' => 2008976, 'state' => 'initial']]); + }, + ]); + + $virtualMachine = (new HostingerService('test-token'))->purchaseVirtualMachine([ + 'item_id' => 'hostingercom-vps-kvm1-usd-1m', + 'setup' => ['data_center_id' => 19, 'template_id' => 1077], + ]); + + expect($virtualMachine)->toBe(['id' => 2008976, 'state' => 'initial']); +}); + +it('reports a Hostinger purchase whose payment is still processing', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([ + 'id' => 2957086, + 'subscription_id' => 'Azz353Uhl1xC54pR0', + 'status' => 'payment_initiated', + 'message' => 'Payment is being processed.', + ], 202), + ]); + Log::shouldReceive('warning')->once()->with('Hostinger VPS order did not return a virtual machine', [ + 'id' => 2957086, + 'subscription_id' => 'Azz353Uhl1xC54pR0', + 'status' => 'payment_initiated', + 'message' => 'Payment is being processed.', + ]); + + expect(fn () => (new HostingerService('test-token'))->purchaseVirtualMachine([ + 'item_id' => 'hostingercom-vps-kvm2-usd-1m', + 'setup' => ['data_center_id' => 19, 'template_id' => 1130], + ]))->toThrow(Exception::class, 'Hostinger order 2957086 is waiting for payment. Finish the VPS setup in hPanel.'); +}); + +it('extracts a Hostinger public IPv4 address before falling back to IPv6', function () { + $virtualMachine = [ + 'ipv4' => [['address' => '203.0.113.10']], + 'ipv6' => [['address' => '2001:db8::10']], + ]; + + $service = new HostingerService('test-token'); + + expect($service->getPublicIpAddress($virtualMachine))->toBe('203.0.113.10') + ->and($service->getPublicIpAddress(['ipv4' => [], 'ipv6' => $virtualMachine['ipv6']]))->toBe('2001:db8::10'); +}); + +it('waits for Hostinger to assign a public IP', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines/17923' => Http::response([ + 'id' => 17923, + 'state' => 'running', + 'ipv4' => [['address' => '203.0.113.10']], + ]), + ]); + + $service = new HostingerService('test-token'); + $virtualMachine = $service->waitForPublicIp([ + 'id' => 17923, + 'state' => 'creating', + 'ipv4' => [], + ], sleepMilliseconds: 0); + + expect($service->getPublicIpAddress($virtualMachine))->toBe('203.0.113.10'); +}); + +it('stops waiting for a Hostinger public IP after 10 attempts by default', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines/17923' => Http::response([ + 'id' => 17923, + 'state' => 'initial', + 'ipv4' => [], + ]), + ]); + + $service = new HostingerService('test-token'); + $virtualMachine = $service->waitForPublicIp([ + 'id' => 17923, + 'state' => 'initial', + 'ipv4' => [], + ], sleepMilliseconds: 0); + + expect($service->getPublicIpAddress($virtualMachine))->toBeNull(); + Http::assertSentCount(10); +}); + +it('finds a Hostinger virtual machine by public IP', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([ + [ + 'id' => 17923, + 'ipv4' => [['address' => '203.0.113.10']], + 'ipv6' => [['address' => '2001:db8::10']], + ], + ]), + ]); + + $service = new HostingerService('test-token'); + + expect($service->findVirtualMachineByIp('203.0.113.10')['id'])->toBe(17923) + ->and($service->findVirtualMachineByIp('2001:db8::10')['id'])->toBe(17923) + ->and($service->findVirtualMachineByIp('198.51.100.1'))->toBeNull(); +}); + +it('starts a Hostinger virtual machine', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines/17923/start' => Http::response([ + 'id' => 456, + 'name' => 'start', + 'state' => 'running', + ]), + ]); + + $action = (new HostingerService('test-token'))->startVirtualMachine(17923); + + expect($action['state'])->toBe('running'); +}); + +it('raises the shared rate limit exception for Hostinger throttling', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/data-centers' => Http::response([ + 'message' => 'Too many requests.', + ], 429, ['Retry-After' => '30']), + ]); + + expect(fn () => (new HostingerService('test-token'))->getDataCenters()) + ->toThrow(RateLimitException::class, 'Rate limit exceeded. Please try again later.'); +});