mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
feat(hostinger): add cloud provider integration (#11176)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Actions\Server\ValidateServer;
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Exceptions\RateLimitException;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Rules\ValidHostname;
|
||||
use App\Services\HostingerService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class HostingerController extends Controller
|
||||
{
|
||||
#[OA\Get(
|
||||
path: '/hostinger/data-centers',
|
||||
operationId: 'get-hostinger-data-centers',
|
||||
summary: 'Get Hostinger VPS data centers',
|
||||
security: [['bearerAuth' => []]],
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,514 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Server\New;
|
||||
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Rules\ValidHostname;
|
||||
use App\Services\HostingerService;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Collection;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Component;
|
||||
|
||||
class ByHostinger extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public int $current_step = 1;
|
||||
|
||||
#[Locked]
|
||||
public Collection $available_tokens;
|
||||
|
||||
#[Locked]
|
||||
public $private_keys;
|
||||
|
||||
#[Locked]
|
||||
public $limit_reached;
|
||||
|
||||
public ?int $selected_token_id = null;
|
||||
|
||||
public ?string $selectedTokenUuid = null;
|
||||
|
||||
public array $data_centers = [];
|
||||
|
||||
public array $templates = [];
|
||||
|
||||
public array $catalog_items = [];
|
||||
|
||||
public array $hostinger_public_keys = [];
|
||||
|
||||
public array $post_install_scripts = [];
|
||||
|
||||
public ?int $selected_data_center_id = null;
|
||||
|
||||
public ?int $selected_template_id = null;
|
||||
|
||||
public ?int $selected_os_template_id = null;
|
||||
|
||||
public ?int $selected_app_template_id = null;
|
||||
|
||||
public ?string $selected_price_id = null;
|
||||
|
||||
public array $selected_public_key_ids = [];
|
||||
|
||||
public ?int $selected_post_install_script_id = null;
|
||||
|
||||
public string $server_name = '';
|
||||
|
||||
public ?int $private_key_id = null;
|
||||
|
||||
public bool $enable_backups = false;
|
||||
|
||||
public bool $loading_data = false;
|
||||
|
||||
public ?string $provider_data_error = null;
|
||||
|
||||
public bool $from_onboarding = false;
|
||||
|
||||
public function mount(?string $selectedTokenUuid = null): mixed
|
||||
{
|
||||
try {
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Exceptions\RateLimitException;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class HostingerService
|
||||
{
|
||||
private string $baseUrl = 'https://developers.hostinger.com';
|
||||
|
||||
public function __construct(private string $token) {}
|
||||
|
||||
private function request(string $method, string $endpoint, array $data = []): array
|
||||
{
|
||||
$response = Http::withToken($this->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;
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('servers', function (Blueprint $table) {
|
||||
$table->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',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
+191
-2
@@ -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"
|
||||
|
||||
+123
-2
@@ -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
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<svg {{ $attributes }} viewBox="0 0 26 30" fill="none" xmlns="http://www.w3.org/2000/svg" role="img"
|
||||
aria-label="Hostinger">
|
||||
<title>Hostinger</title>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M0.000249566 14.046V0.000497794L6.99464 3.78046V10.1086L16.2538 10.1132L23.3617 14.046H0.000249566ZM18.1472 8.95059V0L25.3302 3.68591V13.1797L18.1472 8.95059ZM18.1472 26.1177V19.8441L8.81663 19.8376C8.82534 19.8793 1.59081 15.8418 1.59081 15.8418L25.3302 15.9547V30L18.1472 26.1177ZM0 26.1177L0.000248849 16.9393L6.99464 21.0683V29.8033L0 26.1177Z"
|
||||
fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 606 B |
@@ -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',
|
||||
|
||||
@@ -207,6 +207,23 @@
|
||||
</x-slot:content>
|
||||
<livewire:server.new.by-digital-ocean :limit_reached="false" :from_onboarding="true" />
|
||||
</x-modal-input>
|
||||
<x-modal-input title="Connect a Hostinger Server" isFullWidth>
|
||||
<x-slot:content>
|
||||
<div
|
||||
class="group relative cursor-pointer flex h-full min-h-36 flex-col rounded-[10px] border border-neutral-200 bg-white p-4 text-left shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-white/[0.14]">
|
||||
<div class="flex h-full flex-col gap-4 text-left">
|
||||
<x-hostinger-icon class="size-10 shrink-0 text-[#673de6]" />
|
||||
<div class="min-h-0 flex-1">
|
||||
<h3 class="mb-1 text-[14px] font-semibold">Hostinger</h3>
|
||||
<p class="text-sm dark:text-neutral-400">
|
||||
Deploy servers directly from your Hostinger account.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-slot:content>
|
||||
<livewire:server.new.by-hostinger :limit_reached="false" :from_onboarding="true" />
|
||||
</x-modal-input>
|
||||
@endif
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -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
|
||||
<input type="hidden" wire:model="provider" />
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<x-server.sidebar :server="$server" activeMenu="cloud-provider-token" />
|
||||
|
||||
<div class="application-settings-form w-full">
|
||||
@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)
|
||||
<x-application.settings-section id="server-cloud-token-section"
|
||||
title="{{ $providerName }} token"
|
||||
helper="Choose the cloud credential used to manage this server." flush>
|
||||
@@ -67,7 +67,7 @@
|
||||
<x-application.settings-section title="Cloud token"
|
||||
helper="Cloud credentials are available for servers created through a supported provider.">
|
||||
<x-empty size="sm" title="No cloud provider integration"
|
||||
description="This server was not created through Hetzner or Vultr, so it does not require a managed cloud token."
|
||||
description="This server was not created through a supported cloud provider, so it does not require a managed cloud token."
|
||||
icon-name="keys" />
|
||||
</x-application.settings-section>
|
||||
@endif
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
<p class="application-settings-section-description">Create a server with a cloud provider.</p>
|
||||
</div>
|
||||
<div class="application-settings-section-body is-flush">
|
||||
<div class="grid grid-cols-1 gap-3 p-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div class="grid grid-cols-1 gap-3 p-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<a href="{{ route('server.create.type', ['type' => 'hetzner']) }}"
|
||||
class="group flex min-h-32 flex-col rounded-xl border border-neutral-200 bg-white p-3 shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:no-underline hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.05] dark:hover:border-white/[0.14]"
|
||||
{{ wireNavigate() }}>
|
||||
@@ -94,6 +94,20 @@
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="{{ route('server.create.type', ['type' => 'hostinger']) }}"
|
||||
class="group flex min-h-32 flex-col rounded-xl border border-neutral-200 bg-white p-3 shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:no-underline hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-white/[0.14]"
|
||||
{{ wireNavigate() }}>
|
||||
<div class="flex items-start">
|
||||
<x-hostinger-icon class="size-8 text-[#673de6]" />
|
||||
</div>
|
||||
<div class="mt-auto pt-5">
|
||||
<h3 class="text-[13px]! font-semibold! text-black dark:text-fg">Hostinger</h3>
|
||||
<p class="mt-1 text-[11px] leading-4 text-neutral-500 dark:text-fg-faint">
|
||||
Purchase and provision a new VPS.
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -113,6 +127,10 @@
|
||||
<livewire:server.new.by-digital-ocean :private_keys="$private_keys" :limit_reached="$limit_reached"
|
||||
:selected-token-uuid="$selectedTokenUuid"
|
||||
wire:key="new-server-digital-ocean-{{ $selectedTokenUuid ?? 'select' }}" />
|
||||
@elseif ($selectedType === 'hostinger')
|
||||
<livewire:server.new.by-hostinger :private_keys="$private_keys" :limit_reached="$limit_reached"
|
||||
:selected-token-uuid="$selectedTokenUuid"
|
||||
wire:key="new-server-hostinger-{{ $selectedTokenUuid ?? 'select' }}" />
|
||||
@else
|
||||
<livewire:server.new.by-ip :private_keys="$private_keys" :limit_reached="$limit_reached"
|
||||
key="new-server-manual" />
|
||||
|
||||
@@ -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.
|
||||
<a class="underline" href="https://hpanel.hostinger.com/" target="_blank"
|
||||
rel="noopener noreferrer">Manage the VPS and billing in hPanel.</a>
|
||||
@endif
|
||||
</p>
|
||||
<p>Type the server name in the confirmation dialog to continue.</p>
|
||||
<x-slot:action>
|
||||
@php
|
||||
$deleteActions = ['This server will be permanently deleted from Coolify.'];
|
||||
$deleteWarningMessage = null;
|
||||
if ($server->hostinger_virtual_machine_id) {
|
||||
$deleteWarningMessage = '<strong>Hostinger deletes a VPS only at the end of its paid period.</strong><br>'
|
||||
.'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, '
|
||||
.'<a class="underline font-semibold" href="https://hpanel.hostinger.com/" target="_blank" rel="noopener noreferrer">cancel the VPS in hPanel</a>.';
|
||||
}
|
||||
@endphp
|
||||
<x-modal-confirmation title="Confirm Server Deletion?" isErrorButton
|
||||
buttonTitle="Delete server" submitAction="delete"
|
||||
:actions="['This server will be permanently deleted from Coolify.']"
|
||||
:actions="$deleteActions" :warningMessage="$deleteWarningMessage"
|
||||
:checkboxes="$checkboxes" confirmationText="{{ $server->name }}"
|
||||
confirmationLabel="Please confirm by entering the Server Name below"
|
||||
shortConfirmationLabel="Server Name" />
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
<div class="w-full">
|
||||
@if ($limit_reached)
|
||||
<x-limit-reached name="servers" />
|
||||
@elseif ($current_step === 1)
|
||||
<div class="flex flex-col gap-6">
|
||||
<x-server.provider-token-picker provider="hostinger" providerLabel="Hostinger"
|
||||
:tokens="$available_tokens" />
|
||||
<p class="text-[11px] text-neutral-500 dark:text-fg-faint">
|
||||
New to Hostinger?
|
||||
<a href="https://www.hostinger.com/vps/coolify-hosting?ref=coolify.io" target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="font-medium text-coollabs hover:underline dark:text-warning">Create an account</a>
|
||||
through Coolify's affiliate link.
|
||||
</p>
|
||||
</div>
|
||||
@elseif ($current_step === 2)
|
||||
<div wire:init="loadHostingerData">
|
||||
@if ($loading_data)
|
||||
<x-application.settings-section title="Loading Hostinger"
|
||||
description="Fetching data centers, plans, billing periods, and operating systems.">
|
||||
<div class="flex min-h-40 items-center justify-center">
|
||||
<x-loading text="Loading Hostinger data..." />
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
@elseif ($provider_data_error)
|
||||
<x-application.settings-section title="Unable to load Hostinger"
|
||||
description="The selected token could not access the provider API.">
|
||||
<x-callout type="error" title="Provider request failed">
|
||||
<pre class="mt-2 whitespace-pre-wrap break-words text-[11px]">{{ $provider_data_error }}</pre>
|
||||
</x-callout>
|
||||
<div class="mt-4">
|
||||
<a class="button" href="{{ route('server.create.type', ['type' => 'hostinger']) }}"
|
||||
{{ wireNavigate() }}>Select another token</a>
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
@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
|
||||
|
||||
<form wire:submit="submit" class="flex flex-col gap-6">
|
||||
<x-application.settings-section title="Hostinger server"
|
||||
description="Choose the data center, plan, operating system, and Coolify SSH key.">
|
||||
<x-slot:actions>
|
||||
<button type="submit" class="button button-highlighted"
|
||||
wire:loading.attr="disabled" wire:target="submit"
|
||||
@disabled(!$private_key_id)>
|
||||
Buy and create
|
||||
<x-loading-on-button wire:loading wire:target="submit" />
|
||||
</button>
|
||||
</x-slot:actions>
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<div class="lg:col-span-2">
|
||||
<x-forms.input id="server_name" label="Server name"
|
||||
helper="A friendly name shown in Coolify and used as the VPS hostname." />
|
||||
</div>
|
||||
<x-forms.listbox id="selected_data_center_id" label="Data center" required live
|
||||
placeholder="Select a data center" :options="$dataCenterOptions" />
|
||||
<x-forms.listbox id="selected_price_id" label="Plan and billing period" required live
|
||||
:disabled="!$selected_data_center_id" placeholder="Select a plan"
|
||||
:options="$priceOptions" />
|
||||
<x-forms.listbox id="selected_os_template_id" label="Operating system" required live
|
||||
:disabled="!$selected_price_id" placeholder="Select an operating system"
|
||||
:options="$osTemplateOptions" />
|
||||
<x-forms.listbox id="selected_app_template_id" label="Operating system with application"
|
||||
live :disabled="!$selected_price_id" placeholder="Or select an operating system with an application"
|
||||
:options="$appTemplateOptions"
|
||||
helper="Replaces the operating system selected above. Applications and panels, such as another Coolify or CyberPanel, can conflict with the Coolify proxy on ports 80 and 443." />
|
||||
@if ($private_keys->isEmpty())
|
||||
<div>
|
||||
<label class="mb-1.5 flex w-fit items-center gap-1.5">Private key
|
||||
<x-highlighted text="*" />
|
||||
</label>
|
||||
<div
|
||||
class="flex min-h-8 items-center justify-between gap-3 rounded-lg border border-warning/30 bg-warning/5 px-3 py-2">
|
||||
<span class="text-[11px] text-neutral-600 dark:text-fg-dim">A private key is required.</span>
|
||||
<x-modal-input title="New Private Key">
|
||||
<x-slot:content>
|
||||
<button type="button" class="button">Create key</button>
|
||||
</x-slot:content>
|
||||
<livewire:security.private-key.create :modal_mode="true" from="server" />
|
||||
</x-modal-input>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<x-forms.listbox id="private_key_id" label="Private key" required
|
||||
placeholder="Select a private key" :options="$privateKeyOptions"
|
||||
helper="This key is added to the Hostinger VPS automatically." />
|
||||
@endif
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
|
||||
<x-application.settings-section title="Advanced options"
|
||||
description="Provider backups, account SSH keys, and post-install automation.">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="-mx-2.5 grid gap-3 lg:grid-cols-2">
|
||||
<x-forms.checkbox id="enable_backups" label="Enable weekly Hostinger backups (extra cost)" fullWidth
|
||||
helper="Hostinger charges extra for backups. Check the price in hPanel before you enable them." />
|
||||
</div>
|
||||
<x-forms.datalist label="Extra SSH keys" id="selected_public_key_ids"
|
||||
helper="Existing keys from the Hostinger account." :multiple="true"
|
||||
:disabled="count($hostinger_public_keys) === 0"
|
||||
:placeholder="count($hostinger_public_keys) ? 'Search SSH keys' : 'No account keys found'">
|
||||
@foreach ($hostinger_public_keys as $publicKey)
|
||||
<option value="{{ $publicKey['id'] }}">{{ $publicKey['name'] }}</option>
|
||||
@endforeach
|
||||
</x-forms.datalist>
|
||||
@if ($post_install_scripts)
|
||||
<x-forms.listbox id="selected_post_install_script_id"
|
||||
label="Hostinger post-install script"
|
||||
placeholder="No post-install script" :options="$postInstallScriptOptions"
|
||||
helper="Hostinger runs this as /post_install after setup and writes output to /post_install.log." />
|
||||
@endif
|
||||
<x-callout type="warning" title="This purchase is billed by Hostinger">
|
||||
The VPS is purchased with your Hostinger account's default payment method. Review the
|
||||
selected plan and billing period before continuing.
|
||||
</x-callout>
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@@ -1,5 +1,5 @@
|
||||
<div x-data
|
||||
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">
|
||||
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">
|
||||
<x-slot:title>
|
||||
{{ data_get_str($server, 'name')->limit(24) }} | Server | Coolify
|
||||
</x-slot>
|
||||
@@ -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
|
||||
|
||||
<form wire:submit.prevent="submit" class="application-settings-form flex flex-col gap-6">
|
||||
@@ -107,6 +117,18 @@
|
||||
:searchError="$vultrSearchError" :noMatch="$vultrNoMatchFound"
|
||||
:matched="$vultrMatch" />
|
||||
@endif
|
||||
@if (!$server->hostinger_virtual_machine_id && $availableHostingerTokens->isNotEmpty())
|
||||
<x-server.provider-link-modal :server="$server" provider="hostinger"
|
||||
providerLabel="Hostinger" tokenModel="selectedHostingerTokenId"
|
||||
:tokens="$availableHostingerTokens"
|
||||
manualModel="manualHostingerVirtualMachineId"
|
||||
manualLabel="Virtual Machine ID" manualPlaceholder="17923"
|
||||
searchByIdMethod="searchHostingerVirtualMachineById"
|
||||
searchByIpMethod="searchHostingerVirtualMachine"
|
||||
linkMethod="linkToHostinger"
|
||||
:searchError="$hostingerSearchError" :noMatch="$hostingerNoMatchFound"
|
||||
:matched="$hostingerMatch" />
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@@ -272,6 +294,12 @@
|
||||
title="Refresh provider status">
|
||||
<x-reicon name="refresh" class="size-3.5" />
|
||||
</x-forms.button>
|
||||
@elseif ($provider === 'Hostinger')
|
||||
<x-forms.button type="button" class="size-8! px-0!"
|
||||
wire:click.prevent="checkHostingerVirtualMachineStatus(true)"
|
||||
title="Refresh provider status">
|
||||
<x-reicon name="refresh" class="size-3.5" />
|
||||
</x-forms.button>
|
||||
@endif
|
||||
@if ($server->cloudProviderToken)
|
||||
@if ($provider === 'Hetzner' && !$server->isFunctional() && $hetznerServerStatus === 'off')
|
||||
|
||||
@@ -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']);
|
||||
|
||||
+2
-2
@@ -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');
|
||||
|
||||
+4
-3
@@ -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" \
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
test('boarding utility actions are centered', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php'));
|
||||
|
||||
expect($view)->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('<x-process-dialog closeWithX size="xl">')
|
||||
->toContain('@click="processDialogOpen = true"')
|
||||
->not->toContain('<x-slide-over closeWithX fullScreen>');
|
||||
});
|
||||
|
||||
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('<x-modal-input title="Connect a DigitalOcean Server" isFullWidth>')
|
||||
->toContain('<x-digital-ocean-icon class="size-10 shrink-0" />')
|
||||
->toContain('Deploy servers directly from your DigitalOcean account.')
|
||||
->toContain('<livewire:server.new.by-digital-ocean :limit_reached="false" :from_onboarding="true" />');
|
||||
});
|
||||
|
||||
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('<x-modal-input title="Connect a Hostinger Server" isFullWidth>')
|
||||
->toContain('<x-hostinger-icon class="size-10 shrink-0 text-[#673de6]" />')
|
||||
->toContain('Deploy servers directly from your Hostinger account.')
|
||||
->toContain('<livewire:server.new.by-hostinger :limit_reached="false" :from_onboarding="true" />');
|
||||
});
|
||||
|
||||
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('<x-highlighted text="Servers" />')
|
||||
->not->toContain('<x-highlighted text="Localhost:" />')
|
||||
->not->toContain('<x-highlighted text="Remote Server:" />');
|
||||
});
|
||||
|
||||
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('<x-highlighted text="Connection Requirements:" />')
|
||||
->not->toContain('<x-highlighted text="Hostname Resolution:" />')
|
||||
->not->toContain('<x-highlighted text="User Permissions:" />');
|
||||
});
|
||||
|
||||
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('<x-highlighted text="Project Organization:" />')
|
||||
->not->toContain('<x-highlighted text="Environments:" />')
|
||||
->not->toContain('<x-highlighted text="Team Access:" />');
|
||||
});
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
<?php
|
||||
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Once;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Http::preventStrayRequests();
|
||||
|
||||
InstanceSettings::query()->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',
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,484 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Server\DeleteServer;
|
||||
use App\Livewire\Server\Create;
|
||||
use App\Livewire\Server\CreatePage;
|
||||
use App\Livewire\Server\Delete;
|
||||
use App\Livewire\Server\New\ByHostinger;
|
||||
use App\Livewire\Server\Show;
|
||||
use App\Livewire\Server\ValidateAndInstall;
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
use Lorisleiva\Actions\Decorators\JobDecorator;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Http::preventStrayRequests();
|
||||
|
||||
config([
|
||||
'cache.default' => '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' => '<b>Operations</b>']])
|
||||
->assertSee('Extra SSH keys')
|
||||
->assertSee('Existing keys from the Hostinger account.')
|
||||
->assertSee('Search SSH keys')
|
||||
->assertSeeHtml('<option value="42"><b>Operations</b></option>')
|
||||
->assertDontSeeHtml('<b>Operations</b>')
|
||||
->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('<strong>Hostinger deletes a VPS only at the end of its paid period.</strong>')
|
||||
->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');
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,5 +1,47 @@
|
||||
<?php
|
||||
|
||||
it('allows selecting Vultr in cloud provider token forms', function () {
|
||||
$view = file_get_contents(__DIR__.'/../../resources/views/livewire/security/cloud-provider-token-form.blade.php');
|
||||
$component = file_get_contents(__DIR__.'/../../app/Livewire/Security/CloudProviderTokenForm.php');
|
||||
|
||||
expect($view)->toContain('<x-forms.listbox required id="provider" label="Provider"')
|
||||
->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('<x-forms.select required id="provider" label="Provider" disabled>')
|
||||
->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('<x-application.settings-section title="Cloud tokens"')
|
||||
->and($view)->toContain('<livewire:security.cloud-provider-token-form :modal_mode="true"')
|
||||
->and($view)->toContain("\$savedToken->provider === 'digitalocean' ? 'DigitalOcean' : ucfirst(\$savedToken->provider)")
|
||||
->and($view)->not->toContain('<h3>New Token</h3>')
|
||||
->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');
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Server\DeleteServer;
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Http::preventStrayRequests();
|
||||
|
||||
$this->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();
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
it('renders the official Hostinger mark', function () {
|
||||
$contents = file_get_contents(__DIR__.'/../../resources/views/components/hostinger-icon.blade.php');
|
||||
|
||||
expect($contents)
|
||||
->toContain('viewBox="0 0 26 30"')
|
||||
->toContain('M0.000249566 14.046V0.000497794')
|
||||
->toContain('Hostinger');
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Http::preventStrayRequests();
|
||||
});
|
||||
|
||||
it('refreshes Hostinger virtual machine status and public IP', function () {
|
||||
$team = Team::factory()->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');
|
||||
});
|
||||
@@ -0,0 +1,339 @@
|
||||
<?php
|
||||
|
||||
use App\Exceptions\RateLimitException;
|
||||
use App\Services\HostingerService;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Http::preventStrayRequests();
|
||||
});
|
||||
|
||||
it('fetches Hostinger provisioning options', 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?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.');
|
||||
});
|
||||
Reference in New Issue
Block a user