feat(vultr): add cloud provider integration (#10533)

This commit is contained in:
Andras Bacsai 2026-07-08 10:24:10 +02:00 committed by GitHub
commit 5936874ffd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
57 changed files with 3490 additions and 105 deletions

View file

@ -7,13 +7,14 @@
use App\Models\Team;
use App\Notifications\Server\HetznerDeletionFailed;
use App\Services\HetznerService;
use App\Services\VultrService;
use Lorisleiva\Actions\Concerns\AsAction;
class DeleteServer
{
use AsAction;
public function handle(int $serverId, bool $deleteFromHetzner = false, ?int $hetznerServerId = null, ?int $cloudProviderTokenId = null, ?int $teamId = null)
public function handle(int $serverId, bool $deleteFromHetzner = false, ?int $hetznerServerId = null, ?int $cloudProviderTokenId = null, ?int $teamId = null, bool $deleteFromVultr = false, ?string $vultrInstanceId = null)
{
$server = Server::withTrashed()->find($serverId);
@ -26,6 +27,16 @@ public function handle(int $serverId, bool $deleteFromHetzner = false, ?int $het
);
}
if ($deleteFromVultr && ($vultrInstanceId || ($server && $server->vultr_instance_id))) {
$this->deleteFromVultrById(
$vultrInstanceId ?? $server->vultr_instance_id,
$cloudProviderTokenId ?? $server->cloud_provider_token_id,
$teamId ?? $server->team_id
);
}
logger()->debug($server ? 'Deleting server from Coolify' : 'Server already deleted from Coolify, skipping Coolify deletion');
// If server is already deleted from Coolify, skip this part
if (! $server) {
return; // Server already force deleted from Coolify
@ -48,7 +59,10 @@ private function deleteFromHetznerById(int $hetznerServerId, ?int $cloudProvider
$token = null;
if ($cloudProviderTokenId) {
$token = CloudProviderToken::find($cloudProviderTokenId);
$token = CloudProviderToken::where('id', $cloudProviderTokenId)
->where('team_id', $teamId)
->where('provider', 'hetzner')
->first();
}
if (! $token) {
@ -79,4 +93,47 @@ private function deleteFromHetznerById(int $hetznerServerId, ?int $cloudProvider
$team?->notify(new HetznerDeletionFailed($hetznerServerId, $teamId, $e->getMessage()));
}
}
private function deleteFromVultrById(string $vultrInstanceId, ?int $cloudProviderTokenId, int $teamId): void
{
try {
$token = null;
if ($cloudProviderTokenId) {
$token = CloudProviderToken::where('id', $cloudProviderTokenId)
->where('team_id', $teamId)
->where('provider', 'vultr')
->first();
}
if (! $token) {
$token = CloudProviderToken::where('team_id', $teamId)
->where('provider', 'vultr')
->first();
}
if (! $token) {
logger()->debug('No Vultr token found for team, skipping Vultr deletion', [
'team_id' => $teamId,
'vultr_instance_id' => $vultrInstanceId,
]);
return;
}
$vultrService = new VultrService($token->token);
$vultrService->deleteInstance($vultrInstanceId);
logger()->debug('Deleted server from Vultr', [
'vultr_instance_id' => $vultrInstanceId,
'team_id' => $teamId,
]);
} catch (\Throwable $e) {
logger()->error('Failed to delete server from Vultr', [
'error' => $e->getMessage(),
'vultr_instance_id' => $vultrInstanceId,
'team_id' => $teamId,
]);
}
}
}

View file

@ -28,6 +28,19 @@ public function handle(Server $server)
$server->update([
'validation_logs' => null,
]);
if ($server->vultr_instance_id) {
$status = $server->refreshVultrState();
if (in_array($status, ['stopped', 'suspended', 'deleted'], true)) {
$this->error = $status === 'deleted'
? 'Vultr instance is deleted or no longer accessible. Relink this server before validating.'
: 'Vultr instance is '.($status ?? 'not running').'. Power it on before validating.';
$server->update([
'validation_logs' => $this->error,
]);
throw new \Exception($this->error);
}
}
['uptime' => $this->uptime, 'error' => $error] = $server->validateConnection();
if (! $this->uptime) {
$sanitizedError = htmlspecialchars($error ?? '', ENT_QUOTES, 'UTF-8');

View file

@ -42,6 +42,9 @@ private function validateProviderToken(string $provider, string $token): array
'digitalocean' => Http::withHeaders([
'Authorization' => 'Bearer '.$token,
])->timeout(10)->get('https://api.digitalocean.com/v2/account'),
'vultr' => Http::withHeaders([
'Authorization' => 'Bearer '.$token,
])->timeout(10)->get('https://api.vultr.com/v2/account'),
default => null,
};
@ -87,7 +90,7 @@ private function validateProviderToken(string $provider, string $token): array
properties: [
'uuid' => ['type' => 'string'],
'name' => ['type' => 'string'],
'provider' => ['type' => 'string', 'enum' => ['hetzner', 'digitalocean']],
'provider' => ['type' => 'string', 'enum' => ['hetzner', 'digitalocean', 'vultr']],
'team_id' => ['type' => 'integer'],
'servers_count' => ['type' => 'integer'],
'created_at' => ['type' => 'string'],
@ -205,7 +208,7 @@ public function show(Request $request)
type: 'object',
required: ['provider', 'token', 'name'],
properties: [
'provider' => ['type' => 'string', 'enum' => ['hetzner', 'digitalocean'], 'example' => 'hetzner', 'description' => 'The cloud provider.'],
'provider' => ['type' => 'string', 'enum' => ['hetzner', 'digitalocean', 'vultr'], 'example' => 'hetzner', 'description' => 'The cloud provider.'],
'token' => ['type' => 'string', 'example' => 'your-api-token-here', 'description' => 'The API token for the cloud provider.'],
'name' => ['type' => 'string', 'example' => 'My Hetzner Token', 'description' => 'A friendly name for the token.'],
],
@ -260,7 +263,7 @@ public function store(Request $request)
$body = $request->json()->all();
$validator = customApiValidator($body, [
'provider' => 'required|string|in:hetzner,digitalocean',
'provider' => 'required|string|in:hetzner,digitalocean,vultr',
'token' => 'required|string',
'name' => 'required|string|max:255',
]);

View file

@ -862,7 +862,9 @@ public function delete_server(Request $request)
false, // Don't delete from Hetzner via API
$server->hetzner_server_id,
$server->cloud_provider_token_id,
$server->team_id
$server->team_id,
false, // Don't delete from Vultr via API
$server->vultr_instance_id
);
auditLog('api.server.deleted', [

View file

@ -0,0 +1,402 @@
<?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\ValidCloudInitYaml;
use App\Rules\ValidHostname;
use App\Services\VultrService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
class VultrController extends Controller
{
private function getCloudProviderTokenUuid(Request $request): ?string
{
return $request->cloud_provider_token_uuid ?? $request->cloud_provider_token_id;
}
private function getVultrToken(Request $request): CloudProviderToken|JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$validator = customApiValidator($request->all(), [
'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string',
'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string',
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$token = CloudProviderToken::whereTeamId($teamId)
->whereUuid($this->getCloudProviderTokenUuid($request))
->where('provider', 'vultr')
->first();
if (! $token) {
return response()->json(['message' => 'Vultr cloud provider token not found.'], 404);
}
return $token;
}
#[OA\Get(
summary: 'Get Vultr Regions',
description: 'Get all available Vultr regions.',
path: '/vultr/regions',
operationId: 'get-vultr-regions',
security: [
['bearerAuth' => []],
],
tags: ['Vultr'],
responses: [
new OA\Response(response: 200, description: 'List of Vultr regions.'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
]
)]
public function regions(Request $request): JsonResponse
{
$token = $this->getVultrToken($request);
if ($token instanceof JsonResponse) {
return $token;
}
try {
return response()->json((new VultrService($token->token))->getRegions());
} catch (\Throwable) {
return response()->json(['message' => 'Failed to fetch Vultr regions.'], 500);
}
}
#[OA\Get(
summary: 'Get Vultr Plans',
description: 'Get all available Vultr plans.',
path: '/vultr/plans',
operationId: 'get-vultr-plans',
security: [
['bearerAuth' => []],
],
tags: ['Vultr'],
responses: [
new OA\Response(response: 200, description: 'List of Vultr plans.'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
]
)]
public function plans(Request $request): JsonResponse
{
$token = $this->getVultrToken($request);
if ($token instanceof JsonResponse) {
return $token;
}
try {
return response()->json((new VultrService($token->token))->getPlans());
} catch (\Throwable) {
return response()->json(['message' => 'Failed to fetch Vultr plans.'], 500);
}
}
#[OA\Get(
summary: 'Get Vultr Operating Systems',
description: 'Get all available Vultr operating systems.',
path: '/vultr/os',
operationId: 'get-vultr-operating-systems',
security: [
['bearerAuth' => []],
],
tags: ['Vultr'],
responses: [
new OA\Response(response: 200, description: 'List of Vultr operating systems.'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
]
)]
public function operatingSystems(Request $request): JsonResponse
{
$token = $this->getVultrToken($request);
if ($token instanceof JsonResponse) {
return $token;
}
try {
return response()->json((new VultrService($token->token))->getOperatingSystems());
} catch (\Throwable) {
return response()->json(['message' => 'Failed to fetch Vultr operating systems.'], 500);
}
}
#[OA\Get(
summary: 'Get Vultr SSH Keys',
description: 'Get all Vultr SSH keys available to the selected token.',
path: '/vultr/ssh-keys',
operationId: 'get-vultr-ssh-keys',
security: [
['bearerAuth' => []],
],
tags: ['Vultr'],
responses: [
new OA\Response(response: 200, description: 'List of Vultr SSH keys.'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
]
)]
public function sshKeys(Request $request): JsonResponse
{
$token = $this->getVultrToken($request);
if ($token instanceof JsonResponse) {
return $token;
}
try {
return response()->json((new VultrService($token->token))->getSshKeys());
} catch (\Throwable) {
return response()->json(['message' => 'Failed to fetch Vultr SSH keys.'], 500);
}
}
#[OA\Post(
summary: 'Create Vultr Server',
description: 'Create a Vultr instance and link it as a Coolify server.',
path: '/servers/vultr',
operationId: 'create-vultr-server',
security: [
['bearerAuth' => []],
],
tags: ['Vultr'],
responses: [
new OA\Response(response: 201, description: 'Vultr server created.'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 422, description: 'Validation failed.'),
new OA\Response(response: 429, description: 'Vultr API rate limit exceeded.'),
]
)]
public function createServer(Request $request): JsonResponse
{
$allowedFields = [
'cloud_provider_token_uuid',
'cloud_provider_token_id',
'region',
'plan',
'os_id',
'name',
'private_key_uuid',
'enable_ipv6',
'disable_public_ipv4',
'vultr_ssh_key_ids',
'cloud_init_script',
'instant_validate',
];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$return = validateIncomingRequest($request);
if ($return instanceof JsonResponse) {
return $return;
}
$validator = customApiValidator($request->all(), [
'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string',
'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string',
'region' => 'required|string',
'plan' => 'required|string',
'os_id' => 'required|integer',
'name' => ['nullable', 'string', 'max:253', new ValidHostname],
'private_key_uuid' => 'required|string',
'enable_ipv6' => 'nullable|boolean',
'disable_public_ipv4' => 'nullable|boolean',
'vultr_ssh_key_ids' => 'nullable|array',
'vultr_ssh_key_ids.*' => 'string',
'cloud_init_script' => ['nullable', 'string', new ValidCloudInitYaml],
'instant_validate' => 'nullable|boolean',
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors();
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
}
return response()->json([
'message' => 'Validation failed.',
'errors' => $errors,
], 422);
}
$team = Team::find($teamId);
if (Team::serverLimitReached($team)) {
return response()->json(['message' => 'Server limit reached for your subscription.'], 400);
}
if (! $request->name) {
$request->offsetSet('name', generate_random_name());
}
if (is_null($request->enable_ipv6)) {
$request->offsetSet('enable_ipv6', true);
}
if (is_null($request->disable_public_ipv4)) {
$request->offsetSet('disable_public_ipv4', false);
}
if (is_null($request->vultr_ssh_key_ids)) {
$request->offsetSet('vultr_ssh_key_ids', []);
}
if (is_null($request->instant_validate)) {
$request->offsetSet('instant_validate', false);
}
if ($request->disable_public_ipv4 && ! $request->enable_ipv6) {
return $this->networkConfigurationErrorResponse();
}
$token = CloudProviderToken::whereTeamId($teamId)
->whereUuid($this->getCloudProviderTokenUuid($request))
->where('provider', 'vultr')
->first();
if (! $token) {
return response()->json(['message' => 'Vultr cloud provider token not found.'], 404);
}
$privateKey = PrivateKey::whereTeamId($teamId)->whereUuid($request->private_key_uuid)->first();
if (! $privateKey) {
return response()->json(['message' => 'Private key not found.'], 404);
}
try {
$vultrService = new VultrService($token->token);
$publicKey = $privateKey->getPublicKey();
$existingKey = $this->findMatchingSshKey($vultrService->getSshKeys(), $publicKey);
if ($existingKey) {
$sshKeyId = $existingKey['id'];
} else {
$uploadedKey = $vultrService->uploadSshKey($privateKey->name, $publicKey);
$sshKeyId = $uploadedKey['id'];
}
$normalizedServerName = strtolower(trim($request->name));
$sshKeys = array_values(array_unique(array_merge([$sshKeyId], $request->vultr_ssh_key_ids)));
$params = [
'region' => $request->region,
'plan' => $request->plan,
'os_id' => $request->os_id,
'label' => $normalizedServerName,
'hostname' => $normalizedServerName,
'sshkey_id' => $sshKeys,
'enable_ipv6' => $request->enable_ipv6,
'disable_public_ipv4' => $request->disable_public_ipv4,
];
if (! empty($request->cloud_init_script)) {
$params['user_data'] = $request->cloud_init_script;
}
$vultrInstance = $vultrService->createInstance($params);
$ipAddress = $vultrService->getPublicIp($vultrInstance, $request->disable_public_ipv4, $request->enable_ipv6) ?? '0.0.0.0';
$server = Server::create([
'name' => $normalizedServerName,
'ip' => $ipAddress,
'user' => 'root',
'port' => 22,
'team_id' => $teamId,
'private_key_id' => $privateKey->id,
'cloud_provider_token_id' => $token->id,
'vultr_instance_id' => $vultrInstance['id'],
'vultr_instance_status' => $vultrInstance['status'] ?? null,
]);
$vultrInstance = $vultrService->waitForPublicIp($vultrInstance, $request->disable_public_ipv4, $request->enable_ipv6);
$assignedIpAddress = $vultrService->getPublicIp($vultrInstance, $request->disable_public_ipv4, $request->enable_ipv6);
if ($assignedIpAddress && $assignedIpAddress !== $server->ip) {
$ipAddress = $assignedIpAddress;
$server->update([
'ip' => $assignedIpAddress,
'vultr_instance_status' => $vultrInstance['status'] ?? $server->vultr_instance_status,
]);
}
$server->proxy->set('status', 'exited');
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
$server->save();
if ($request->instant_validate) {
ValidateServer::dispatch($server);
}
auditLog('api.vultr_server.created', [
'team_id' => $teamId,
'server_uuid' => $server->uuid,
'server_name' => $server->name,
'vultr_instance_id' => $vultrInstance['id'],
'ip' => $ipAddress,
]);
return response()->json([
'uuid' => $server->uuid,
'vultr_instance_id' => $vultrInstance['id'],
'ip' => $ipAddress,
])->setStatusCode(201);
} catch (RateLimitException $e) {
$response = response()->json(['message' => $e->getMessage()], 429);
if ($e->retryAfter !== null) {
$response->header('Retry-After', $e->retryAfter);
}
return $response;
} catch (\Throwable) {
return response()->json(['message' => 'Failed to create Vultr server.'], 500);
}
}
private function findMatchingSshKey(array $sshKeys, string $publicKey): ?array
{
$normalizedPublicKey = $this->normalizePublicKey($publicKey);
foreach ($sshKeys as $sshKey) {
if ($this->normalizePublicKey($sshKey['ssh_key'] ?? '') === $normalizedPublicKey) {
return $sshKey;
}
}
return null;
}
private function normalizePublicKey(string $publicKey): string
{
$parts = preg_split('/\s+/', trim($publicKey));
return implode(' ', array_slice($parts ?: [], 0, 2));
}
private function networkConfigurationErrorResponse(): JsonResponse
{
return response()->json([
'message' => 'Validation failed.',
'errors' => [
'enable_ipv6' => ['Enable IPv6 when disabling public IPv4.'],
],
], 422);
}
}

View file

@ -67,6 +67,10 @@ public function handle()
$this->checkHetznerStatus();
}
if ($this->server->vultr_instance_id && $this->server->cloudProviderToken) {
$this->checkVultrStatus();
}
// Temporarily disable mux if requested
if ($this->disableMux) {
$this->disableSshMux();
@ -185,6 +189,21 @@ private function checkHetznerStatus(): void
}
private function checkVultrStatus(): void
{
try {
$status = $this->server->refreshVultrState();
} catch (\Throwable) {
// Silently ignore transient Vultr API errors.
return;
}
if (in_array($status, ['stopped', 'suspended', 'deleted'], true)) {
throw new \Exception('Vultr instance is not running');
}
}
private function checkConnection(): bool
{
try {

View file

@ -31,7 +31,7 @@ public function mount()
protected function rules(): array
{
return [
'provider' => 'required|string|in:hetzner,digitalocean',
'provider' => 'required|string|in:hetzner,digitalocean,vultr',
'token' => 'required|string',
'name' => 'required|string|max:255',
];
@ -58,8 +58,21 @@ private function validateToken(string $provider, string $token): bool
return $response->successful();
}
// Add other providers here in the future
// if ($provider === 'digitalocean') { ... }
if ($provider === 'digitalocean') {
$response = Http::withHeaders([
'Authorization' => 'Bearer '.$token,
])->timeout(10)->get('https://api.digitalocean.com/v2/account');
return $response->successful();
}
if ($provider === 'vultr') {
$response = Http::withHeaders([
'Authorization' => 'Bearer '.$token,
])->timeout(10)->get('https://api.vultr.com/v2/account');
return $response->successful();
}
return false;
} catch (\Throwable $e) {

View file

@ -55,6 +55,13 @@ public function validateToken(int $tokenId)
} else {
$this->dispatch('error', 'DigitalOcean token validation failed. Please check the token.');
}
} elseif ($token->provider === 'vultr') {
$isValid = $this->validateVultrToken($token->token);
if ($isValid) {
$this->dispatch('success', 'Vultr token is valid.');
} else {
$this->dispatch('error', 'Vultr token validation failed. Please check the token.');
}
} else {
$this->dispatch('error', 'Unknown provider.');
}
@ -97,6 +104,19 @@ private function validateDigitalOceanToken(string $token): bool
}
}
private function validateVultrToken(string $token): bool
{
try {
$response = Http::withToken($token)
->timeout(10)
->get('https://api.vultr.com/v2/account');
return $response->successful();
} catch (\Throwable $e) {
return false;
}
}
public function deleteToken(int $tokenId)
{
try {

View file

@ -18,6 +18,10 @@ class Show extends Component
public $parameters = [];
public string $provider = 'hetzner';
public string $providerName = 'Hetzner';
public function mount(string $server_uuid)
{
try {
@ -37,8 +41,11 @@ public function getListeners()
public function loadTokens()
{
$this->provider = $this->server->vultr_instance_id ? 'vultr' : 'hetzner';
$this->providerName = $this->provider === 'vultr' ? 'Vultr' : 'Hetzner';
$this->cloudProviderTokens = CloudProviderToken::ownedByCurrentTeam()
->where('provider', 'hetzner')
->where('provider', $this->provider)
->get();
}
@ -78,7 +85,7 @@ public function setCloudProviderToken($tokenId)
'provider' => $ownedToken->provider,
]);
$this->dispatch('success', 'Hetzner token updated successfully.');
$this->dispatch('success', "{$this->providerName} token updated successfully.");
$this->dispatch('refreshServerShow');
} catch (\Exception $e) {
$this->server->refresh();
@ -89,10 +96,13 @@ public function setCloudProviderToken($tokenId)
private function validateTokenForServer(CloudProviderToken $token): array
{
try {
// First, validate the token itself
$endpoint = $token->provider === 'vultr'
? 'https://api.vultr.com/v2/account'
: 'https://api.hetzner.cloud/v1/servers';
$response = Http::withHeaders([
'Authorization' => 'Bearer '.$token->token,
])->timeout(10)->get('https://api.hetzner.cloud/v1/servers');
])->timeout(10)->get($endpoint);
if (! $response->successful()) {
return [
@ -101,7 +111,6 @@ private function validateTokenForServer(CloudProviderToken $token): array
];
}
// Check if this token can access the specific Hetzner server
if ($this->server->hetzner_server_id) {
$serverResponse = Http::withHeaders([
'Authorization' => 'Bearer '.$token->token,
@ -115,6 +124,19 @@ private function validateTokenForServer(CloudProviderToken $token): array
}
}
if ($this->server->vultr_instance_id) {
$serverResponse = Http::withHeaders([
'Authorization' => 'Bearer '.$token->token,
])->timeout(10)->get("https://api.vultr.com/v2/instances/{$this->server->vultr_instance_id}");
if (! $serverResponse->successful()) {
return [
'valid' => false,
'error' => 'This token cannot access this instance. It may belong to a different Vultr account.',
];
}
}
return ['valid' => true];
} catch (\Throwable $e) {
return [
@ -129,19 +151,23 @@ public function validateToken()
try {
$token = $this->server->cloudProviderToken;
if (! $token) {
$this->dispatch('error', 'No Hetzner token is associated with this server.');
$this->dispatch('error', "No {$this->providerName} token is associated with this server.");
return;
}
$endpoint = $token->provider === 'vultr'
? 'https://api.vultr.com/v2/account'
: 'https://api.hetzner.cloud/v1/servers';
$response = Http::withHeaders([
'Authorization' => 'Bearer '.$token->token,
])->timeout(10)->get('https://api.hetzner.cloud/v1/servers');
])->timeout(10)->get($endpoint);
if ($response->successful()) {
$this->dispatch('success', 'Hetzner token is valid and working.');
$this->dispatch('success', "{$this->providerName} token is valid and working.");
} else {
$this->dispatch('error', 'Hetzner token is invalid or has insufficient permissions.');
$this->dispatch('error', "{$this->providerName} token is invalid or has insufficient permissions.");
}
auditLog('ui.server.cloud_token_validated', [

View file

@ -16,6 +16,8 @@ class Delete extends Component
public bool $delete_from_hetzner = false;
public bool $delete_from_vultr = false;
public bool $force_delete_resources = false;
public function mount(string $server_uuid)
@ -35,6 +37,7 @@ public function delete($password, $selectedActions = [])
if (! empty($selectedActions)) {
$this->delete_from_hetzner = in_array('delete_from_hetzner', $selectedActions);
$this->delete_from_vultr = in_array('delete_from_vultr', $selectedActions);
$this->force_delete_resources = in_array('force_delete_resources', $selectedActions);
}
try {
@ -57,7 +60,9 @@ public function delete($password, $selectedActions = [])
$this->delete_from_hetzner,
$this->server->hetzner_server_id,
$this->server->cloud_provider_token_id,
$this->server->team_id
$this->server->team_id,
$this->delete_from_vultr,
$this->server->vultr_instance_id
);
return redirectRoute($this, 'server.index');
@ -87,6 +92,14 @@ public function render()
];
}
if ($this->server->vultr_instance_id) {
$checkboxes[] = [
'id' => 'delete_from_vultr',
'label' => 'Also delete server from Vultr',
'default_warning' => 'The actual server on Vultr will NOT be deleted.',
];
}
return view('livewire.server.delete', [
'checkboxes' => $checkboxes,
]);

View file

@ -0,0 +1,431 @@
<?php
namespace App\Livewire\Server\New;
use App\Enums\ProxyTypes;
use App\Models\CloudInitScript;
use App\Models\CloudProviderToken;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use App\Rules\ValidCloudInitYaml;
use App\Rules\ValidHostname;
use App\Services\VultrService;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Livewire\Attributes\Locked;
use Livewire\Component;
class ByVultr 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 array $regions = [];
public array $plans = [];
public array $operatingSystems = [];
public array $vultrSshKeys = [];
public ?string $selected_region = null;
public ?string $selected_plan = null;
public ?int $selected_os_id = null;
public array $selectedVultrSshKeyIds = [];
public string $server_name = '';
public ?int $private_key_id = null;
public bool $loading_data = false;
public bool $enable_ipv6 = true;
public bool $disable_public_ipv4 = false;
public ?string $cloud_init_script = null;
public bool $save_cloud_init_script = false;
public ?string $cloud_init_script_name = null;
public ?int $selected_cloud_init_script_id = null;
#[Locked]
public Collection $saved_cloud_init_scripts;
public bool $from_onboarding = false;
public function mount(): void
{
$this->authorize('viewAny', CloudProviderToken::class);
$this->loadTokens();
$this->loadSavedCloudInitScripts();
$this->server_name = generate_random_name();
$this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get();
if ($this->private_keys->count() > 0) {
$this->private_key_id = $this->private_keys->first()->id;
}
}
public function getListeners(): array
{
return [
'tokenAdded' => 'handleTokenAdded',
'privateKeyCreated' => 'handlePrivateKeyCreated',
'modalClosed' => 'resetSelection',
];
}
public function loadTokens(): void
{
$this->available_tokens = CloudProviderToken::ownedByCurrentTeam()
->where('provider', 'vultr')
->get();
}
public function loadSavedCloudInitScripts(): void
{
$this->saved_cloud_init_scripts = CloudInitScript::ownedByCurrentTeam()->get();
}
public function resetSelection(): void
{
$this->selected_token_id = null;
$this->current_step = 1;
$this->cloud_init_script = null;
$this->save_cloud_init_script = false;
$this->cloud_init_script_name = null;
$this->selected_cloud_init_script_id = null;
}
public function handleTokenAdded($tokenId): void
{
$this->loadTokens();
$this->selected_token_id = $tokenId;
$this->nextStep();
}
public function handlePrivateKeyCreated($keyId): void
{
$this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get();
$this->private_key_id = $keyId;
$this->resetErrorBag('private_key_id');
}
protected function rules(): array
{
$rules = [
'selected_token_id' => 'required|integer|exists:cloud_provider_tokens,id',
];
if ($this->current_step === 2) {
$rules = array_merge($rules, [
'server_name' => ['required', 'string', 'max:253', new ValidHostname],
'selected_region' => 'required|string',
'selected_plan' => 'required|string',
'selected_os_id' => 'required|integer',
'private_key_id' => 'required|integer|exists:private_keys,id,team_id,'.currentTeam()->id,
'selectedVultrSshKeyIds' => 'nullable|array',
'selectedVultrSshKeyIds.*' => 'string',
'enable_ipv6' => 'required|boolean',
'disable_public_ipv4' => 'required|boolean',
'cloud_init_script' => ['nullable', 'string', new ValidCloudInitYaml],
'save_cloud_init_script' => 'boolean',
'cloud_init_script_name' => 'nullable|string|max:255',
'selected_cloud_init_script_id' => 'nullable|integer|exists:cloud_init_scripts,id',
]);
}
return $rules;
}
protected function messages(): array
{
return [
'selected_token_id.required' => 'Please select a Vultr token.',
'selected_token_id.exists' => 'Selected token not found.',
];
}
public function selectToken(int $tokenId): void
{
$this->selected_token_id = $tokenId;
}
public function nextStep(): mixed
{
$this->validate([
'selected_token_id' => 'required|integer|exists:cloud_provider_tokens,id',
]);
try {
$vultrToken = $this->getVultrToken();
if (! $vultrToken) {
return $this->dispatch('error', 'Please select a valid Vultr token.');
}
$this->loadVultrData($vultrToken);
$this->current_step = 2;
} catch (\Throwable $e) {
return handleError($e, $this);
}
return null;
}
public function previousStep(): void
{
$this->current_step = 1;
}
public function updatedSelectedRegion(): void
{
$this->selected_plan = null;
}
public function updatedSelectedCloudInitScriptId($value): void
{
if ($value) {
$script = CloudInitScript::ownedByCurrentTeam()->findOrFail($value);
$this->cloud_init_script = $script->script;
$this->cloud_init_script_name = $script->name;
}
}
public function clearCloudInitScript(): void
{
$this->selected_cloud_init_script_id = null;
$this->cloud_init_script = '';
$this->cloud_init_script_name = '';
$this->save_cloud_init_script = false;
}
public function getAvailablePlansProperty(): array
{
if (! $this->selected_region) {
return $this->plans;
}
return collect($this->plans)
->filter(function ($plan) {
$locations = $plan['locations'] ?? [];
return empty($locations) || in_array($this->selected_region, $locations);
})
->values()
->toArray();
}
public function getSelectedServerPriceProperty(): ?string
{
if (! $this->selected_plan) {
return null;
}
$plan = collect($this->plans)->firstWhere('id', $this->selected_plan);
$monthlyCost = $plan['monthly_cost'] ?? null;
if ($monthlyCost === null) {
return null;
}
return '$'.number_format((float) $monthlyCost, 2);
}
private function getVultrToken(): string
{
if ($this->selected_token_id) {
$token = $this->available_tokens->firstWhere('id', $this->selected_token_id);
return $token ? $token->token : '';
}
return '';
}
private function loadVultrData(string $token): void
{
$this->loading_data = true;
try {
$vultrService = new VultrService($token);
$this->regions = collect($vultrService->getRegions())
->sortBy('id')
->values()
->toArray();
$this->plans = collect($vultrService->getPlans())
->sortBy('monthly_cost')
->values()
->toArray();
$this->operatingSystems = collect($vultrService->getOperatingSystems())
->sortBy('name')
->values()
->toArray();
$this->vultrSshKeys = $vultrService->getSshKeys();
$this->loading_data = false;
} catch (\Throwable $e) {
$this->loading_data = false;
throw $e;
}
}
private function createVultrServer(string $token): array
{
$vultrService = new VultrService($token);
$privateKey = PrivateKey::ownedByCurrentTeam()->findOrFail($this->private_key_id);
$publicKey = $privateKey->getPublicKey();
$existingKey = $this->findMatchingSshKey($vultrService->getSshKeys(), $publicKey);
if ($existingKey) {
$sshKeyId = $existingKey['id'];
} else {
$uploadedKey = $vultrService->uploadSshKey($privateKey->name, $publicKey);
$sshKeyId = $uploadedKey['id'];
}
$sshKeys = array_values(array_unique(array_merge([$sshKeyId], $this->selectedVultrSshKeyIds)));
$normalizedServerName = strtolower(trim($this->server_name));
$params = [
'region' => $this->selected_region,
'plan' => $this->selected_plan,
'os_id' => $this->selected_os_id,
'label' => $normalizedServerName,
'hostname' => $normalizedServerName,
'sshkey_id' => $sshKeys,
'enable_ipv6' => $this->enable_ipv6,
'disable_public_ipv4' => $this->disable_public_ipv4,
];
if (! empty($this->cloud_init_script)) {
$params['user_data'] = $this->cloud_init_script;
}
return $vultrService->createInstance($params);
}
public function submit(): mixed
{
$this->validate();
if (! $this->hasValidPublicNetworkConfiguration()) {
return null;
}
try {
$this->authorize('create', Server::class);
if (Team::serverLimitReached()) {
return $this->dispatch('error', 'You have reached the server limit for your subscription.');
}
if ($this->save_cloud_init_script && ! empty($this->cloud_init_script) && ! empty($this->cloud_init_script_name)) {
$this->authorize('create', CloudInitScript::class);
CloudInitScript::create([
'team_id' => currentTeam()->id,
'name' => $this->cloud_init_script_name,
'script' => $this->cloud_init_script,
]);
}
$vultrService = new VultrService($this->getVultrToken());
$vultrInstance = $this->createVultrServer($this->getVultrToken());
$ipAddress = $vultrService->getPublicIp($vultrInstance, $this->disable_public_ipv4, $this->enable_ipv6) ?? '0.0.0.0';
$server = Server::create([
'name' => strtolower(trim($this->server_name)),
'ip' => $ipAddress,
'user' => 'root',
'port' => 22,
'team_id' => currentTeam()->id,
'private_key_id' => $this->private_key_id,
'cloud_provider_token_id' => $this->selected_token_id,
'vultr_instance_id' => $vultrInstance['id'],
'vultr_instance_status' => $vultrInstance['status'] ?? null,
]);
$vultrInstance = $vultrService->waitForPublicIp($vultrInstance, $this->disable_public_ipv4, $this->enable_ipv6);
$assignedIpAddress = $vultrService->getPublicIp($vultrInstance, $this->disable_public_ipv4, $this->enable_ipv6);
if ($assignedIpAddress && $assignedIpAddress !== $server->ip) {
$server->update([
'ip' => $assignedIpAddress,
'vultr_instance_status' => $vultrInstance['status'] ?? $server->vultr_instance_status,
]);
}
$server->proxy->set('status', 'exited');
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
$server->save();
if ($this->from_onboarding) {
currentTeam()->update([
'show_boarding' => false,
]);
refreshSession();
}
return redirectRoute($this, 'server.show', [$server->uuid]);
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function render()
{
return view('livewire.server.new.by-vultr');
}
private function findMatchingSshKey(array $sshKeys, string $publicKey): ?array
{
$normalizedPublicKey = $this->normalizePublicKey($publicKey);
foreach ($sshKeys as $sshKey) {
if ($this->normalizePublicKey($sshKey['ssh_key'] ?? '') === $normalizedPublicKey) {
return $sshKey;
}
}
return null;
}
private function normalizePublicKey(string $publicKey): string
{
$parts = preg_split('/\s+/', trim($publicKey));
return implode(' ', array_slice($parts ?: [], 0, 2));
}
private function hasValidPublicNetworkConfiguration(): bool
{
if (! $this->disable_public_ipv4 || $this->enable_ipv6) {
return true;
}
$this->addError('enable_ipv6', 'Enable IPv6 when disabling public IPv4.');
return false;
}
}

View file

@ -9,6 +9,7 @@
use App\Models\Server;
use App\Rules\ValidServerIp;
use App\Services\HetznerService;
use App\Services\VultrService;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
@ -75,8 +76,12 @@ class Show extends Component
public ?string $hetznerServerStatus = null;
public ?string $vultrInstanceStatus = null;
public bool $hetznerServerManuallyStarted = false;
public bool $vultrInstanceManuallyStarted = false;
public bool $isValidating = false;
// Hetzner linking properties
@ -92,6 +97,18 @@ class Show extends Component
public bool $hetznerNoMatchFound = false;
public Collection $availableVultrTokens;
public ?int $selectedVultrTokenId = null;
public ?string $manualVultrInstanceId = null;
public ?array $matchedVultrInstance = null;
public ?string $vultrSearchError = null;
public bool $vultrNoMatchFound = false;
public function getListeners()
{
$teamId = $this->server->team_id ?? auth()->user()->currentTeam()->id;
@ -173,10 +190,12 @@ public function mount(string $server_uuid)
}
// Load saved Hetzner status and validation state
$this->hetznerServerStatus = $this->server->hetzner_server_status;
$this->vultrInstanceStatus = $this->server->vultr_instance_status;
$this->isValidating = $this->server->is_validating ?? false;
// Load Hetzner tokens for linking
// Load cloud provider tokens for linking
$this->loadHetznerTokens();
$this->loadVultrTokens();
} catch (\Throwable $e) {
return handleError($e, $this);
@ -289,6 +308,22 @@ public function validateServer($install = true)
{
try {
$this->authorize('update', $this->server);
if ($this->server->vultr_instance_id) {
$status = $this->server->refreshVultrState();
$this->server->refresh();
$this->vultrInstanceStatus = $this->server->vultr_instance_status;
$this->ip = $this->server->ip;
if (in_array($status, ['stopped', 'suspended', 'deleted'], true)) {
$message = $status === 'deleted'
? 'Vultr instance is deleted or no longer accessible. Relink this server before validating.'
: 'Vultr instance is '.($status ?? 'not running').'. Power it on before validating.';
$this->dispatch('error', $message);
return;
}
}
$this->validationLogs = $this->server->validation_logs = null;
$this->server->save();
$this->dispatch('init', $install);
@ -458,6 +493,27 @@ public function checkHetznerServerStatus(bool $manual = false)
}
}
public function checkVultrInstanceStatus(bool $manual = false)
{
try {
if (! $this->server->vultr_instance_id || ! $this->server->cloudProviderToken) {
$this->dispatch('error', 'This server is not associated with a Vultr instance or token.');
return;
}
$this->vultrInstanceStatus = $this->server->refreshVultrState();
$this->server->refresh();
$this->ip = $this->server->ip;
if ($manual) {
$this->dispatch('success', 'Instance status refreshed: '.ucfirst($this->vultrInstanceStatus ?? 'unknown'));
}
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function handleServerValidated($event = null)
{
// Check if event is for this server
@ -477,6 +533,7 @@ public function handleServerValidated($event = null)
// Reload Hetzner tokens in case the linking section should now be shown
$this->loadHetznerTokens();
$this->loadVultrTokens();
$this->dispatch('refreshServerShow');
$this->dispatch('refreshServer');
@ -504,6 +561,27 @@ public function startHetznerServer()
}
}
public function startVultrInstance()
{
try {
if (! $this->server->vultr_instance_id || ! $this->server->cloudProviderToken) {
$this->dispatch('error', 'This server is not associated with a Vultr instance or token.');
return;
}
$vultrService = new VultrService($this->server->cloudProviderToken->token);
$vultrService->startInstance($this->server->vultr_instance_id);
$this->vultrInstanceStatus = 'starting';
$this->server->update(['vultr_instance_status' => 'starting']);
$this->vultrInstanceManuallyStarted = true;
$this->dispatch('success', 'Vultr instance is starting...');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function refreshServerMetadata(): void
{
try {
@ -537,6 +615,13 @@ public function loadHetznerTokens(): void
->get();
}
public function loadVultrTokens(): void
{
$this->availableVultrTokens = CloudProviderToken::ownedByCurrentTeam()
->where('provider', 'vultr')
->get();
}
#[Computed]
public function limaStartCommand(): ?string
{
@ -678,6 +763,130 @@ public function linkToHetzner()
}
}
public function searchVultrInstance(): void
{
$this->vultrSearchError = null;
$this->vultrNoMatchFound = false;
$this->matchedVultrInstance = null;
if (! $this->selectedVultrTokenId) {
$this->vultrSearchError = 'Please select a Vultr token.';
return;
}
try {
$this->authorize('update', $this->server);
$token = $this->availableVultrTokens->firstWhere('id', $this->selectedVultrTokenId);
if (! $token) {
$this->vultrSearchError = 'Invalid token selected.';
return;
}
$vultrService = new VultrService($token->token);
$matched = $vultrService->findInstanceByIp($this->server->ip);
if ($matched) {
$this->matchedVultrInstance = $matched;
} else {
$this->vultrNoMatchFound = true;
}
} catch (\Throwable $e) {
$this->vultrSearchError = 'Failed to search Vultr instances: '.$e->getMessage();
}
}
public function searchVultrInstanceById(): void
{
$this->vultrSearchError = null;
$this->vultrNoMatchFound = false;
$this->matchedVultrInstance = null;
if (! $this->selectedVultrTokenId) {
$this->vultrSearchError = 'Please select a Vultr token first.';
return;
}
if (! $this->manualVultrInstanceId) {
$this->vultrSearchError = 'Please enter a Vultr Instance ID.';
return;
}
try {
$this->authorize('update', $this->server);
$token = $this->availableVultrTokens->firstWhere('id', $this->selectedVultrTokenId);
if (! $token) {
$this->vultrSearchError = 'Invalid token selected.';
return;
}
$vultrService = new VultrService($token->token);
$instanceData = $vultrService->getInstance($this->manualVultrInstanceId);
if (! empty($instanceData)) {
$this->matchedVultrInstance = $instanceData;
} else {
$this->vultrNoMatchFound = true;
}
} catch (\Throwable $e) {
$this->vultrSearchError = 'Failed to fetch Vultr instance: '.$e->getMessage();
}
}
public function linkToVultr()
{
if (! $this->matchedVultrInstance) {
$this->dispatch('error', 'No Vultr instance selected.');
return;
}
try {
$this->authorize('update', $this->server);
$token = $this->availableVultrTokens->firstWhere('id', $this->selectedVultrTokenId);
if (! $token) {
$this->dispatch('error', 'Invalid token selected.');
return;
}
$vultrService = new VultrService($token->token);
$instanceData = $vultrService->getInstance($this->matchedVultrInstance['id']);
if (empty($instanceData)) {
$this->dispatch('error', 'Could not find Vultr instance with ID: '.$this->matchedVultrInstance['id']);
return;
}
$this->server->update([
'cloud_provider_token_id' => $this->selectedVultrTokenId,
'vultr_instance_id' => $this->matchedVultrInstance['id'],
'vultr_instance_status' => $instanceData['status'] ?? null,
]);
$this->vultrInstanceStatus = $instanceData['status'] ?? null;
$this->matchedVultrInstance = null;
$this->selectedVultrTokenId = null;
$this->manualVultrInstanceId = null;
$this->vultrNoMatchFound = false;
$this->vultrSearchError = null;
$this->dispatch('success', 'Server successfully linked to Vultr!');
$this->dispatch('refreshServerShow');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function render()
{
return view('livewire.server.show');

View file

@ -92,6 +92,22 @@ public function validateConnection()
{
try {
$this->authorize('update', $this->server);
if ($this->server->vultr_instance_id) {
$status = $this->server->refreshVultrState();
$this->server->refresh();
if (in_array($status, ['stopped', 'suspended', 'deleted'], true)) {
$this->error = $status === 'deleted'
? 'Vultr instance is deleted or no longer accessible. Relink this server before validating.'
: 'Vultr instance is '.($status ?? 'not running').'. Power it on before validating.';
$this->server->update([
'validation_logs' => $this->error,
]);
return;
}
}
['uptime' => $this->uptime, 'error' => $error] = $this->server->validateConnection();
if (! $this->uptime) {
$sanitizedError = htmlspecialchars($error ?? '', ENT_QUOTES, 'UTF-8');

View file

@ -17,6 +17,7 @@
use App\Notifications\Server\Reachable;
use App\Notifications\Server\Unreachable;
use App\Services\ConfigurationRepository;
use App\Services\VultrService;
use App\Support\ValidationPatterns;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasMetrics;
@ -279,7 +280,10 @@ public static function flushIdentityMap(): void
'team_id',
'hetzner_server_id',
'hetzner_server_status',
'vultr_instance_id',
'vultr_instance_status',
'is_validating',
'validation_logs',
'detected_traefik_version',
'traefik_outdated_info',
'server_metadata',
@ -300,6 +304,51 @@ public function type()
return 'server';
}
public function refreshVultrState(): ?string
{
if (! $this->vultr_instance_id || ! $this->cloudProviderToken) {
return null;
}
$vultrService = new VultrService($this->cloudProviderToken->token);
try {
$instance = $vultrService->getInstance($this->vultr_instance_id);
} catch (\Throwable $e) {
if ((int) $e->getCode() !== 404) {
throw $e;
}
if ($this->vultr_instance_status !== 'deleted') {
$this->update(['vultr_instance_status' => 'deleted']);
$this->forceFill(['vultr_instance_status' => 'deleted']);
}
return 'deleted';
}
$status = ($instance['power_status'] ?? null) === 'stopped'
? 'stopped'
: ($instance['status'] ?? null);
$publicIp = $vultrService->getPublicIp($instance);
$updates = [];
if ($this->vultr_instance_status !== $status) {
$updates['vultr_instance_status'] = $status;
}
$hasPlaceholderIp = blank($this->ip) || in_array($this->ip, ['0.0.0.0', '::'], true);
if ($hasPlaceholderIp && $publicIp) {
$updates['ip'] = $publicIp;
}
if (! empty($updates)) {
$this->update($updates);
$this->forceFill($updates);
}
return $status;
}
protected function isCoolifyHost(): Attribute
{
return Attribute::make(

View file

@ -0,0 +1,191 @@
<?php
namespace App\Services;
use App\Exceptions\RateLimitException;
use Illuminate\Support\Facades\Http;
class VultrService
{
private string $baseUrl = 'https://api.vultr.com/v2';
public function __construct(private string $token) {}
private function request(string $method, string $endpoint, array $data = []): array
{
$response = Http::withHeaders([
'Authorization' => 'Bearer '.$this->token,
])
->timeout(30)
->retry(3, fn (int $attempt) => $attempt * 100)
->{$method}($this->baseUrl.$endpoint, $data);
if (! $response->successful()) {
if ($response->status() === 429) {
throw new RateLimitException(
'Rate limit exceeded. Please try again later.',
$response->header('Retry-After') !== null ? (int) $response->header('Retry-After') : null
);
}
throw new \Exception('Vultr API error: '.$response->json('error', 'Unknown error'), $response->status());
}
return $response->json() ?? [];
}
private function requestPaginated(string $endpoint, string $resourceKey, array $data = []): array
{
$allResults = [];
$cursor = null;
do {
$query = $data;
$query['per_page'] = 100;
if ($cursor !== null) {
$query['cursor'] = $cursor;
}
$response = $this->request('get', $endpoint, $query);
if (isset($response[$resourceKey])) {
$allResults = array_merge($allResults, $response[$resourceKey]);
}
$next = $response['meta']['links']['next'] ?? null;
$cursor = $this->cursorFromNextLink($next);
} while ($cursor !== null);
return $allResults;
}
private function cursorFromNextLink(?string $next): ?string
{
if (blank($next)) {
return null;
}
parse_str((string) parse_url($next, PHP_URL_QUERY), $query);
return $query['cursor'] ?? null;
}
public function getRegions(): array
{
return $this->requestPaginated('/regions', 'regions');
}
public function getPlans(): array
{
return $this->requestPaginated('/plans', 'plans');
}
public function getOperatingSystems(): array
{
return $this->requestPaginated('/os', 'os');
}
public function getSshKeys(): array
{
return $this->requestPaginated('/ssh-keys', 'ssh_keys');
}
public function uploadSshKey(string $name, string $publicKey): array
{
$response = $this->request('post', '/ssh-keys', [
'name' => $name,
'ssh_key' => $publicKey,
]);
return $response['ssh_key'] ?? [];
}
public function createInstance(array $params): array
{
if (! empty($params['user_data'])) {
$params['user_data'] = base64_encode($params['user_data']);
}
$response = $this->request('post', '/instances', $params);
return $response['instance'] ?? [];
}
public function waitForPublicIp(array $instance, bool $disablePublicIpv4 = false, bool $enableIpv6 = true, int $attempts = 6, int $sleepMilliseconds = 1000): array
{
if ($this->getPublicIp($instance, $disablePublicIpv4, $enableIpv6) || empty($instance['id'])) {
return $instance;
}
for ($attempt = 0; $attempt < $attempts; $attempt++) {
usleep($sleepMilliseconds * 1000);
$instance = $this->getInstance($instance['id']);
if ($this->getPublicIp($instance, $disablePublicIpv4, $enableIpv6)) {
return $instance;
}
}
return $instance;
}
public function getPublicIp(array $instance, bool $disablePublicIpv4 = false, bool $enableIpv6 = true): ?string
{
$ipv4 = $instance['main_ip'] ?? null;
if (! $disablePublicIpv4 && $this->isUsableIp($ipv4)) {
return $ipv4;
}
$ipv6 = $instance['v6_main_ip'] ?? null;
if ($enableIpv6 && $this->isUsableIp($ipv6)) {
return $ipv6;
}
return null;
}
private function isUsableIp(?string $ip): bool
{
return ! blank($ip) && ! in_array($ip, ['0.0.0.0', '::'], true);
}
public function getInstance(string $instanceId): array
{
$response = $this->request('get', $this->instanceEndpoint($instanceId));
return $response['instance'] ?? [];
}
public function startInstance(string $instanceId): array
{
return $this->request('post', $this->instanceEndpoint($instanceId).'/start');
}
public function deleteInstance(string $instanceId): void
{
$this->request('delete', $this->instanceEndpoint($instanceId));
}
public function getInstances(): array
{
return $this->requestPaginated('/instances', 'instances');
}
public function findInstanceByIp(string $ip): ?array
{
foreach ($this->getInstances() as $instance) {
if (($instance['main_ip'] ?? null) === $ip || ($instance['v6_main_ip'] ?? null) === $ip) {
return $instance;
}
}
return null;
}
private function instanceEndpoint(string $instanceId): string
{
return '/instances/'.rawurlencode($instanceId);
}
}

View file

@ -13,6 +13,11 @@ class CloudProviderTokenFactory extends Factory
{
protected $model = CloudProviderToken::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [

View file

@ -0,0 +1,44 @@
<?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
{
if (! Schema::hasColumn('servers', 'vultr_instance_id')) {
Schema::table('servers', function (Blueprint $table) {
$table->string('vultr_instance_id')->nullable()->after('hetzner_server_status');
});
}
if (! Schema::hasColumn('servers', 'vultr_instance_status')) {
Schema::table('servers', function (Blueprint $table) {
$table->string('vultr_instance_status')->nullable()->after('vultr_instance_id');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
if (Schema::hasColumn('servers', 'vultr_instance_status')) {
Schema::table('servers', function (Blueprint $table) {
$table->dropColumn('vultr_instance_status');
});
}
if (Schema::hasColumn('servers', 'vultr_instance_id')) {
Schema::table('servers', function (Blueprint $table) {
$table->dropColumn('vultr_instance_id');
});
}
}
};

View file

@ -4040,7 +4040,8 @@
"type": "string",
"enum": [
"hetzner",
"digitalocean"
"digitalocean",
"vultr"
]
},
"team_id": {
@ -4098,7 +4099,8 @@
"type": "string",
"enum": [
"hetzner",
"digitalocean"
"digitalocean",
"vultr"
],
"example": "hetzner",
"description": "The cloud provider."
@ -14156,6 +14158,139 @@
}
]
}
},
"\/vultr\/regions": {
"get": {
"tags": [
"Vultr"
],
"summary": "Get Vultr Regions",
"description": "Get all available Vultr regions.",
"operationId": "get-vultr-regions",
"responses": {
"200": {
"description": "List of Vultr regions."
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"404": {
"$ref": "#\/components\/responses\/404"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/vultr\/plans": {
"get": {
"tags": [
"Vultr"
],
"summary": "Get Vultr Plans",
"description": "Get all available Vultr plans.",
"operationId": "get-vultr-plans",
"responses": {
"200": {
"description": "List of Vultr plans."
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"404": {
"$ref": "#\/components\/responses\/404"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/vultr\/os": {
"get": {
"tags": [
"Vultr"
],
"summary": "Get Vultr Operating Systems",
"description": "Get all available Vultr operating systems.",
"operationId": "get-vultr-operating-systems",
"responses": {
"200": {
"description": "List of Vultr operating systems."
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"404": {
"$ref": "#\/components\/responses\/404"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/vultr\/ssh-keys": {
"get": {
"tags": [
"Vultr"
],
"summary": "Get Vultr SSH Keys",
"description": "Get all Vultr SSH keys available to the selected token.",
"operationId": "get-vultr-ssh-keys",
"responses": {
"200": {
"description": "List of Vultr SSH keys."
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"404": {
"$ref": "#\/components\/responses\/404"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/servers\/vultr": {
"post": {
"tags": [
"Vultr"
],
"summary": "Create Vultr Server",
"description": "Create a Vultr instance and link it as a Coolify server.",
"operationId": "create-vultr-server",
"responses": {
"201": {
"description": "Vultr server created."
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"422": {
"description": "Validation failed."
},
"429": {
"description": "Vultr API rate limit exceeded."
}
},
"security": [
{
"bearerAuth": []
}
]
}
}
},
"components": {
@ -15458,6 +15593,10 @@
{
"name": "Teams",
"description": "Teams"
},
{
"name": "Vultr",
"description": "Vultr"
}
]
}

View file

@ -2577,7 +2577,7 @@ paths:
schema:
type: array
items:
properties: { uuid: { type: string }, name: { type: string }, provider: { type: string, enum: [hetzner, digitalocean] }, team_id: { type: integer }, servers_count: { type: integer }, created_at: { type: string }, updated_at: { type: string } }
properties: { uuid: { type: string }, name: { type: string }, provider: { type: string, enum: [hetzner, digitalocean, vultr] }, team_id: { type: integer }, servers_count: { type: integer }, created_at: { type: string }, updated_at: { type: string } }
type: object
'401':
$ref: '#/components/responses/401'
@ -2605,7 +2605,7 @@ paths:
properties:
provider:
type: string
enum: [hetzner, digitalocean]
enum: [hetzner, digitalocean, vultr]
example: hetzner
description: 'The cloud provider.'
token:
@ -8984,6 +8984,93 @@ paths:
security:
-
bearerAuth: []
/vultr/regions:
get:
tags:
- Vultr
summary: 'Get Vultr Regions'
description: 'Get all available Vultr regions.'
operationId: get-vultr-regions
responses:
'200':
description: 'List of Vultr regions.'
'401':
$ref: '#/components/responses/401'
'404':
$ref: '#/components/responses/404'
security:
-
bearerAuth: []
/vultr/plans:
get:
tags:
- Vultr
summary: 'Get Vultr Plans'
description: 'Get all available Vultr plans.'
operationId: get-vultr-plans
responses:
'200':
description: 'List of Vultr plans.'
'401':
$ref: '#/components/responses/401'
'404':
$ref: '#/components/responses/404'
security:
-
bearerAuth: []
/vultr/os:
get:
tags:
- Vultr
summary: 'Get Vultr Operating Systems'
description: 'Get all available Vultr operating systems.'
operationId: get-vultr-operating-systems
responses:
'200':
description: 'List of Vultr operating systems.'
'401':
$ref: '#/components/responses/401'
'404':
$ref: '#/components/responses/404'
security:
-
bearerAuth: []
/vultr/ssh-keys:
get:
tags:
- Vultr
summary: 'Get Vultr SSH Keys'
description: 'Get all Vultr SSH keys available to the selected token.'
operationId: get-vultr-ssh-keys
responses:
'200':
description: 'List of Vultr SSH keys.'
'401':
$ref: '#/components/responses/401'
'404':
$ref: '#/components/responses/404'
security:
-
bearerAuth: []
/servers/vultr:
post:
tags:
- Vultr
summary: 'Create Vultr Server'
description: 'Create a Vultr instance and link it as a Coolify server.'
operationId: create-vultr-server
responses:
'201':
description: 'Vultr server created.'
'401':
$ref: '#/components/responses/401'
'422':
description: 'Validation failed.'
'429':
description: 'Vultr API rate limit exceeded.'
security:
-
bearerAuth: []
components:
schemas:
Application:
@ -9925,3 +10012,6 @@ tags:
-
name: Teams
description: Teams
-
name: Vultr
description: Vultr

View file

@ -70,7 +70,7 @@
@endphp
<div class="w-full md:w-auto">
<div class="mb-4 w-full border-b-2 border-solid border-neutral-200 pb-6 md:hidden dark:border-coolgray-200">
<div class="mb-4 w-full border-b-2 border-solid border-neutral-200 pb-4 md:hidden dark:border-coolgray-200">
<label id="server-mobile-section-label" for="server-mobile-section" class="mb-1 block text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">Section</label>
<select id="server-mobile-section" class="select w-full" aria-label="Proxy menu"
data-current-value="{{ $activeProxyMenuValue }}"

View file

@ -58,7 +58,7 @@
@endphp
<div class="w-full md:w-auto">
<div class="mb-4 w-full border-b-2 border-solid border-neutral-200 pb-6 md:hidden dark:border-coolgray-200">
<div class="mb-4 w-full border-b-2 border-solid border-neutral-200 pb-4 md:hidden dark:border-coolgray-200">
<label id="server-mobile-section-label" for="server-mobile-section" class="mb-1 block text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">Section</label>
<select id="server-mobile-section" class="select w-full" aria-label="Security menu"
data-current-value="{{ $activeSecurityMenuValue }}"

View file

@ -58,7 +58,7 @@
<div class="w-full md:w-auto">
@can('viewSentinel', $server)
<div class="mb-4 w-full border-b-2 border-solid border-neutral-200 pb-6 md:hidden dark:border-coolgray-200">
<div class="mb-4 w-full border-b-2 border-solid border-neutral-200 pb-4 md:hidden dark:border-coolgray-200">
<label id="server-mobile-section-label" for="server-mobile-section" class="mb-1 block text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">Section</label>
<select id="server-mobile-section" class="select w-full" aria-label="Sentinel menu"
data-current-value="{{ $activeSentinelMenuValue }}"

View file

@ -55,10 +55,10 @@
'active' => $activeMenu === 'private-key',
],
[
'label' => 'Hetzner Token',
'label' => 'Cloud Token',
'route' => 'server.cloud-provider-token',
'active' => $activeMenu === 'cloud-provider-token',
'visible' => (bool) $server->hetzner_server_id,
'visible' => (bool) ($server->hetzner_server_id || $server->vultr_instance_id),
],
[
'label' => 'CA Certificate',
@ -126,7 +126,7 @@
@endphp
<div class="w-full md:w-auto">
<div class="mb-4 w-full border-b-2 border-solid border-neutral-200 pb-6 md:hidden dark:border-coolgray-200">
<div class="mb-4 w-full border-b-2 border-solid border-neutral-200 pb-4 md:hidden dark:border-coolgray-200">
<label id="server-mobile-section-label" for="server-mobile-section" class="mb-1 block text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">Section</label>
<select id="server-mobile-section" class="select w-full" aria-label="Server menu"
data-current-value="{{ $activeServerMenuValue }}"

View file

@ -193,6 +193,30 @@ class="px-2 py-1 text-xs font-bold uppercase tracking-wide bg-coollabs/10 dark:b
</x-slot:content>
<livewire:server.new.by-hetzner :limit_reached="false" :from_onboarding="true" />
</x-modal-input>
<x-modal-input title="Connect a Vultr Server" isFullWidth>
<x-slot:content>
<div
class="group relative box-without-bg cursor-pointer hover:border-coollabs transition-all duration-200 p-6 h-full min-h-[210px]">
<div class="flex flex-col gap-4 text-left">
<div class="flex items-center justify-between">
<svg class="size-10" viewBox="0 0 200 200"
xmlns="http://www.w3.org/2000/svg">
<rect width="200" height="200" fill="#007BFC" rx="8" />
<path d="M42 46 H73 L100 127 L127 46 H158 L114 154 H86 Z"
fill="white" />
</svg>
</div>
<div>
<h3 class="text-xl font-bold mb-2">Vultr Cloud</h3>
<p class="text-sm dark:text-neutral-400">
Deploy servers directly from your Vultr account.
</p>
</div>
</div>
</div>
</x-slot:content>
<livewire:server.new.by-vultr :limit_reached="false" :from_onboarding="true" />
</x-modal-input>
@endif
@endcan
</div>
@ -745,4 +769,4 @@ class="text-sm dark:text-neutral-400 hover:text-coollabs dark:hover:text-warning
</x-modal-input>
</div>
@endif
</section>
</section>

View file

@ -3,27 +3,67 @@
@if ($modal_mode)
{{-- Modal layout: vertical, compact --}}
@if (!isset($provider) || empty($provider) || $provider === '')
<x-forms.select required id="provider" label="Provider">
<x-forms.select required id="provider" label="Provider" wire:model.live="provider">
<option value="hetzner">Hetzner</option>
<option value="digitalocean">DigitalOcean</option>
<option disabled value="digitalocean">DigitalOcean</option>
<option value="vultr">Vultr</option>
</x-forms.select>
@else
<input type="hidden" wire:model="provider" />
@endif
<x-forms.input required id="name" label="Token Name"
placeholder="e.g., Production Hetzner. tip: add Hetzner project name to identify easier" />
<x-forms.input required id="name" label="Token Name" placeholder="e.g., Production cloud token" />
<x-forms.input required type="password" id="token" label="API Token"
placeholder="Enter your API token" />
@if (auth()->user()->currentTeam()->cloudProviderTokens->where('provider', $provider)->isEmpty())
<div class="text-sm text-neutral-500 dark:text-neutral-400">
<div class="text-sm text-neutral-500 dark:text-neutral-400">
Create an API token in the <a
href='{{ $provider === 'hetzner' ? 'https://console.hetzner.com/projects' : ($provider === 'vultr' ? 'https://console.vultr.com/user/apiaccess/' : '#') }}'
target='_blank' class='underline dark:text-white'>{{ ucfirst($provider) }} Console</a>.
@if ($provider === 'hetzner')
Choose Project Security API Tokens.
<br><br>
Don't have a Hetzner account? <a href='https://coolify.io/hetzner' target='_blank'
class='underline dark:text-white'>Sign up here</a>
<br>
<span class="text-xs">(Coolify's affiliate link, only new accounts - supports us (€10)
and gives you €20)</span>
@endif
@if ($provider === 'vultr')
Open Account API Access.
<br><br>
Don't have a Vultr account? <a href='https://www.vultr.com/?ref=9911335' target='_blank'
class='underline dark:text-white'>Sign up here</a>
<br>
<span class="text-xs">Coolify's affiliate link, only new accounts - supports us</span>
@endif
</div>
<x-forms.button type="submit">Validate & Add Token</x-forms.button>
@else
{{-- Full page layout: horizontal, spacious --}}
<div class="flex gap-2 items-end flex-wrap">
<div class="w-64">
<x-forms.select required id="provider" label="Provider" wire:model.live="provider">
<option value="hetzner">Hetzner</option>
<option disabled value="digitalocean">DigitalOcean</option>
<option value="vultr">Vultr</option>
</x-forms.select>
</div>
<div class="flex-1 min-w-64">
<x-forms.input required id="name" label="Token Name" placeholder="e.g., Production cloud token" />
</div>
</div>
<div class="flex-1 min-w-64">
<x-forms.input required type="password" id="token" label="API Token"
placeholder="Enter your API token" />
<div class="text-sm text-neutral-500 dark:text-neutral-400 mt-2">
Create an API token in the <a
href='{{ $provider === 'hetzner' ? 'https://console.hetzner.com/projects' : '#' }}'
target='_blank' class='underline dark:text-white'>{{ ucfirst($provider) }} Console</a> choose
Project Security API Tokens.
href='{{ $provider === 'hetzner' ? 'https://console.hetzner.com/projects' : ($provider === 'vultr' ? 'https://console.vultr.com/user/apiaccess/' : '#') }}'
target='_blank' class='underline dark:text-white'>{{ ucfirst($provider) }} Console</a>.
@if ($provider === 'hetzner')
Choose Project Security API Tokens.
<br><br>
Don't have a Hetzner account? <a href='https://coolify.io/hetzner' target='_blank'
class='underline dark:text-white'>Sign up here</a>
@ -31,40 +71,15 @@ class='underline dark:text-white'>Sign up here</a>
<span class="text-xs">(Coolify's affiliate link, only new accounts - supports us (€10)
and gives you €20)</span>
@endif
</div>
@endif
<x-forms.button type="submit">Validate & Add Token</x-forms.button>
@else
{{-- Full page layout: horizontal, spacious --}}
<div class="flex gap-2 items-end flex-wrap">
<div class="w-64">
<x-forms.select required id="provider" label="Provider" disabled>
<option value="hetzner" selected>Hetzner</option>
<option value="digitalocean">DigitalOcean</option>
</x-forms.select>
</div>
<div class="flex-1 min-w-64">
<x-forms.input required id="name" label="Token Name"
placeholder="e.g., Production Hetzner. tip: add Hetzner project name to identify easier" />
</div>
</div>
<div class="flex-1 min-w-64">
<x-forms.input required type="password" id="token" label="API Token"
placeholder="Enter your API token" />
@if (auth()->user()->currentTeam()->cloudProviderTokens->where('provider', $provider)->isEmpty())
<div class="text-sm text-neutral-500 dark:text-neutral-400 mt-2">
Create an API token in the <a href='https://console.hetzner.com/projects' target='_blank'
class='underline dark:text-white'>Hetzner Console</a> choose Project Security API
Tokens.
@if ($provider === 'vultr')
Open Account API Access.
<br><br>
Don't have a Hetzner account? <a href='https://coolify.io/hetzner' target='_blank'
Don't have a Vultr account? <a href='https://www.vultr.com/?ref=9911335' target='_blank'
class='underline dark:text-white'>Sign up here</a>
<br>
<span class="text-xs">(Coolify's affiliate link, only new accounts - supports us (€10)
and gives you €20)</span>
</div>
@endif
<span class="text-xs">Coolify's affiliate link, only new accounts - supports us</span>
@endif
</div>
</div>
<x-forms.button type="submit">Validate & Add Token</x-forms.button>
@endif

View file

@ -1,6 +1,6 @@
<div>
<h2>Cloud Provider Tokens</h2>
<div class="pb-4">Manage API tokens for cloud providers (Hetzner, DigitalOcean, etc.).</div>
<div class="pb-4">Manage API tokens for cloud providers (Hetzner, Vultr, etc.).</div>
<h3>New Token</h3>
@can('create', App\Models\CloudProviderToken::class)

View file

@ -3,7 +3,7 @@
{{ data_get_str($server, 'name')->limit(10) }} > Advanced | Coolify
</x-slot>
<livewire:server.navbar :server="$server" />
<div x-data="{ activeTab: window.location.hash ? window.location.hash.substring(1) : 'general' }" class="flex flex-col h-full gap-2 md:gap-8 md:flex-row">
<div x-data="{ activeTab: window.location.hash ? window.location.hash.substring(1) : 'general' }" class="flex flex-col h-full gap-4 md:gap-8 md:flex-row">
<x-server.sidebar :server="$server" activeMenu="advanced" />
<form wire:submit='submit' class="w-full">
<div>

View file

@ -3,7 +3,7 @@
{{ data_get_str($server, 'name')->limit(10) }} > CA Certificate | Coolify
</x-slot>
<livewire:server.navbar :server="$server" />
<div class="flex flex-col h-full gap-2 md:gap-8 md:flex-row">
<div class="flex flex-col h-full gap-4 md:gap-8 md:flex-row">
<x-server.sidebar :server="$server" activeMenu="ca-certificate" />
<div class="flex flex-col gap-4">
<div class="flex items-center gap-2">

View file

@ -3,7 +3,7 @@
{{ data_get_str($server, 'name')->limit(10) }} > Metrics | Coolify
</x-slot>
<livewire:server.navbar :server="$server" />
<div class="flex flex-col h-full gap-2 md:gap-8 md:flex-row">
<div class="flex flex-col h-full gap-4 md:gap-8 md:flex-row">
<x-server.sidebar :server="$server" activeMenu="metrics" />
<div class="w-full">
<div class="flex items-center gap-2">

View file

@ -1,17 +1,17 @@
<div>
<x-slot:title>
{{ data_get_str($server, 'name')->limit(10) }} > Hetzner Token | Coolify
{{ data_get_str($server, 'name')->limit(10) }} > Cloud Token | Coolify
</x-slot>
<livewire:server.navbar :server="$server" />
<div class="flex flex-col h-full gap-2 md:gap-8 md:flex-row">
<div class="flex flex-col h-full gap-4 md:gap-8 md:flex-row">
<x-server.sidebar :server="$server" activeMenu="cloud-provider-token" />
<div class="w-full">
@if ($server->hetzner_server_id)
@if ($server->hetzner_server_id || $server->vultr_instance_id)
<div class="flex items-end gap-2">
<h2>Hetzner Token</h2>
<h2>{{ $providerName }} Token</h2>
@can('create', App\Models\CloudProviderToken::class)
<x-modal-input buttonTitle="+ Add" title="Add Hetzner Token">
<livewire:security.cloud-provider-token-form :modal_mode="true" provider="hetzner" />
<x-modal-input buttonTitle="+ Add" title="Add {{ $providerName }} Token">
<livewire:security.cloud-provider-token-form :modal_mode="true" :provider="$provider" />
</x-modal-input>
@endcan
<x-forms.button canGate="update" :canResource="$server" isHighlighted
@ -19,7 +19,7 @@
Validate token
</x-forms.button>
</div>
<div class="pb-4">Change your server's Hetzner token.</div>
<div class="pb-4">Change your server's {{ $providerName }} token.</div>
<div class="grid xl:grid-cols-2 grid-cols-1 gap-2">
@forelse ($cloudProviderTokens as $token)
<div
@ -42,17 +42,17 @@ class="box-without-bg justify-between dark:bg-coolgray-100 bg-white items-center
@endif
</div>
@empty
<div>No Hetzner tokens found. </div>
<div>No {{ $providerName }} tokens found. </div>
@endforelse
</div>
@else
<div class="flex items-end gap-2">
<h2>Hetzner Token</h2>
<h2>Cloud Token</h2>
</div>
<div class="pb-4">This server was not created through Hetzner Cloud integration.</div>
<div class="pb-4">This server was not created through a supported cloud provider integration.</div>
<div class="p-4 border rounded-md dark:border-coolgray-300 dark:bg-coolgray-100">
<p class="dark:text-neutral-400">
Only servers created through Hetzner Cloud can have their tokens managed here.
Only servers created through a supported cloud provider can have their tokens managed here.
</p>
</div>
@endif

View file

@ -3,7 +3,7 @@
{{ data_get_str($server, 'name')->limit(10) }} > Cloudflare Tunnel | Coolify
</x-slot>
<livewire:server.navbar :server="$server" />
<div class="flex flex-col h-full gap-2 md:gap-8 md:flex-row">
<div class="flex flex-col h-full gap-4 md:gap-8 md:flex-row">
<x-server.sidebar :server="$server" activeMenu="cloudflare-tunnel" />
<div class="w-full">
<div class="flex flex-col">

View file

@ -23,6 +23,29 @@
</x-modal-input>
</div>
<div>
<x-modal-input title="Connect a Vultr Server">
<x-slot:content>
<div class="relative gap-2 cursor-pointer coolbox group">
<div class="flex items-center gap-4 mx-6">
<svg class="w-10 h-10 flex-shrink-0" viewBox="0 0 200 200"
xmlns="http://www.w3.org/2000/svg">
<rect width="200" height="200" fill="#007BFC" rx="8" />
<path d="M42 46 H73 L100 127 L127 46 H158 L114 154 H86 Z" fill="white" />
</svg>
<div class="flex flex-col justify-center flex-1">
<div class="box-title">Connect a Vultr Server</div>
<div class="box-description">
Deploy servers directly from your Vultr account
</div>
</div>
</div>
</div>
</x-slot:content>
<livewire:server.new.by-vultr :private_keys="$private_keys" :limit_reached="$limit_reached" />
</x-modal-input>
</div>
<div class="border-t dark:border-coolgray-300 my-4"></div>
@endcan

View file

@ -3,7 +3,7 @@
{{ data_get_str($server, 'name')->limit(10) }} > Delete Server | Coolify
</x-slot>
<livewire:server.navbar :server="$server" />
<div class="flex flex-col h-full gap-2 md:gap-8 md:flex-row">
<div class="flex flex-col h-full gap-4 md:gap-8 md:flex-row">
<x-server.sidebar :server="$server" activeMenu="danger" />
<div class="w-full">
@if ($server->id !== 0)

View file

@ -3,7 +3,7 @@
{{ data_get_str($server, 'name')->limit(10) }} > Destinations | Coolify
</x-slot>
<livewire:server.navbar :server="$server" />
<div class="flex flex-col h-full gap-2 md:gap-8 md:flex-row">
<div class="flex flex-col h-full gap-4 md:gap-8 md:flex-row">
<x-server.sidebar :server="$server" activeMenu="destinations" />
<div class="w-full">
@if ($server->isFunctional())

View file

@ -3,7 +3,7 @@
{{ data_get_str($server, 'name')->limit(10) }} > Docker Cleanup | Coolify
</x-slot>
<livewire:server.navbar :server="$server" />
<div x-data="{ activeTab: window.location.hash ? window.location.hash.substring(1) : 'general' }" class="flex flex-col h-full gap-2 md:gap-8 md:flex-row">
<div x-data="{ activeTab: window.location.hash ? window.location.hash.substring(1) : 'general' }" class="flex flex-col h-full gap-4 md:gap-8 md:flex-row">
<x-server.sidebar :server="$server" activeMenu="docker-cleanup" />
<div class="w-full">
<form wire:submit='submit'>

View file

@ -3,7 +3,7 @@
{{ data_get_str($server, 'name')->limit(10) }} > Log Drains | Coolify
</x-slot>
<livewire:server.navbar :server="$server" />
<div class="flex flex-col h-full gap-2 md:gap-8 md:flex-row">
<div class="flex flex-col h-full gap-4 md:gap-8 md:flex-row">
<x-server.sidebar :server="$server" activeMenu="log-drains" />
<div class="w-full">
@if ($server->isFunctional())

View file

@ -0,0 +1,192 @@
<div class="w-full">
@if ($limit_reached)
<x-limit-reached name="servers" />
@else
@if ($current_step === 1)
<div class="flex flex-col w-full gap-4">
@if ($available_tokens->count() > 0)
<div class="flex gap-2">
<div class="flex-1">
<x-forms.select label="Select Vultr Token" id="selected_token_id"
wire:change="selectToken($event.target.value)" required>
<option value="">Select a saved token...</option>
@foreach ($available_tokens as $token)
<option value="{{ $token->id }}">
{{ $token->name ?? 'Vultr Token' }}
</option>
@endforeach
</x-forms.select>
</div>
<div class="flex items-end">
<x-forms.button canGate="create" :canResource="App\Models\Server::class" wire:click="nextStep"
:disabled="!$selected_token_id">
Continue
</x-forms.button>
</div>
</div>
<div class="text-center text-sm dark:text-neutral-500">OR</div>
@endif
<x-modal-input isFullWidth
buttonTitle="{{ $available_tokens->count() > 0 ? '+ Add New Token' : 'Add Vultr Token' }}"
title="Add Vultr Token">
<livewire:security.cloud-provider-token-form :modal_mode="true" provider="vultr" />
</x-modal-input>
</div>
@elseif ($current_step === 2)
@if ($loading_data)
<div class="flex items-center justify-center py-8">
<div class="text-center">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div>
<p class="mt-4 text-sm dark:text-neutral-400">Loading Vultr data...</p>
</div>
</div>
@else
<form class="flex flex-col w-full gap-2" wire:submit='submit'>
<div>
<x-forms.input id="server_name" label="Server Name" helper="A friendly name for your server." />
</div>
<div>
<x-forms.select label="Region" id="selected_region" wire:model.live="selected_region" required>
<option value="">Select a region...</option>
@foreach ($regions as $region)
<option value="{{ $region['id'] }}">
{{ $region['city'] ?? $region['id'] }} - {{ $region['country'] ?? $region['id'] }}
</option>
@endforeach
</x-forms.select>
</div>
<div>
<x-forms.select label="Plan" id="selected_plan" wire:model.live="selected_plan"
helper="Learn more about <a class='inline-block underline dark:text-white' href='https://www.vultr.com/products/cloud-compute/' target='_blank'>Vultr plans</a>"
required :disabled="!$selected_region">
<option value="">
{{ $selected_region ? 'Select a plan...' : 'Select a region first' }}
</option>
@foreach ($this->availablePlans as $plan)
<option value="{{ $plan['id'] }}">
{{ $plan['id'] }} -
{{ $plan['vcpu_count'] ?? '?' }} vCPU,
{{ isset($plan['ram']) ? number_format($plan['ram'] / 1024, 1) : '?' }}GB RAM,
{{ $plan['disk'] ?? '?' }}GB
@if (isset($plan['monthly_cost']))
- ${{ number_format((float) $plan['monthly_cost'], 2) }}/mo
@endif
</option>
@endforeach
</x-forms.select>
</div>
<div>
<x-forms.select label="Operating System" id="selected_os_id" required>
<option value="">Select an operating system...</option>
@foreach ($operatingSystems as $operatingSystem)
<option value="{{ $operatingSystem['id'] }}">
{{ $operatingSystem['name'] }}
</option>
@endforeach
</x-forms.select>
</div>
<div>
@if ($private_keys->count() === 0)
<div class="flex flex-col gap-2">
<label class="flex gap-1 items-center mb-1 text-sm font-medium">
Private Key
<x-highlighted text="*" />
</label>
<div
class="p-4 border border-warning-500 dark:border-warning-600 rounded bg-warning-50 dark:bg-warning-900/10">
<p class="text-sm mb-3 text-neutral-700 dark:text-neutral-300">
No private keys found. You need to create a private key to continue.
</p>
<x-modal-input buttonTitle="Create New Private Key" title="New Private Key"
isHighlightedButton>
<livewire:security.private-key.create :modal_mode="true" from="server" />
</x-modal-input>
</div>
</div>
@else
<x-forms.select label="Private Key" id="private_key_id" required>
<option value="">Select a private key...</option>
@foreach ($private_keys as $key)
<option value="{{ $key->id }}">
{{ $key->name }}
</option>
@endforeach
</x-forms.select>
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
This SSH key will be automatically added to your Vultr account and used to access the
server.
</p>
@endif
</div>
<div>
<x-forms.datalist label="Additional SSH Keys (from Vultr)" id="selectedVultrSshKeyIds"
helper="Select existing SSH keys from your Vultr account to add to this server. The Coolify SSH key will be automatically added."
:multiple="true" :disabled="count($vultrSshKeys) === 0" :placeholder="count($vultrSshKeys) > 0 ? 'Search and select SSH keys...' : 'No SSH keys found in Vultr account'">
@foreach ($vultrSshKeys as $sshKey)
<option value="{{ $sshKey['id'] }}">
{{ $sshKey['name'] }} - {{ substr($sshKey['id'], 0, 20) }}
</option>
@endforeach
</x-forms.datalist>
</div>
<div class="flex flex-col gap-2">
<label class="text-sm font-medium">Network Configuration</label>
<div class="flex gap-4">
<x-forms.checkbox id="enable_ipv6" label="Enable IPv6"
helper="Enable public IPv6 address for this server" />
<x-forms.checkbox id="disable_public_ipv4" label="Disable public IPv4"
helper="Disable the default public IPv4 address" />
</div>
</div>
<div class="flex flex-col gap-2">
<div class="flex justify-between items-center gap-2">
<label class="text-sm font-medium w-32">Cloud-Init Script</label>
@if ($saved_cloud_init_scripts->count() > 0)
<div class="flex items-center gap-2 flex-1">
<x-forms.select wire:model.live="selected_cloud_init_script_id" label="" helper="">
<option value="">Load saved script...</option>
@foreach ($saved_cloud_init_scripts as $script)
<option value="{{ $script->id }}">{{ $script->name }}</option>
@endforeach
</x-forms.select>
<x-forms.button type="button" wire:click="clearCloudInitScript">
Clear
</x-forms.button>
</div>
@endif
</div>
<x-forms.textarea id="cloud_init_script" label=""
helper="Add a cloud-init script to run when the server is created. Coolify sends it to Vultr as user data."
rows="8" />
<div class="flex items-center gap-2">
<x-forms.checkbox id="save_cloud_init_script" label="Save this script for later use" />
<div class="flex-1">
<x-forms.input id="cloud_init_script_name" label="" placeholder="Script name..." />
</div>
</div>
</div>
<div class="flex gap-2 justify-between">
<x-forms.button type="button" wire:click="previousStep">
Back
</x-forms.button>
<x-forms.button isHighlighted canGate="create" :canResource="App\Models\Server::class"
type="submit" :disabled="!$private_key_id">
Buy & Create Server{{ $this->selectedServerPrice ? ' (' . $this->selectedServerPrice . '/mo)' : '' }}
</x-forms.button>
</div>
</form>
@endif
@endif
@endif
</div>

View file

@ -3,7 +3,7 @@
{{ data_get_str($server, 'name')->limit(10) }} > Private Key | Coolify
</x-slot>
<livewire:server.navbar :server="$server" />
<div class="flex flex-col h-full gap-2 md:gap-8 md:flex-row">
<div class="flex flex-col h-full gap-4 md:gap-8 md:flex-row">
<x-server.sidebar :server="$server" activeMenu="private-key" />
<div class="w-full">
<div class="flex items-end gap-2">

View file

@ -3,7 +3,7 @@
Proxy Dynamic Configuration | Coolify
</x-slot>
<livewire:server.navbar :server="$server" />
<div class="flex flex-col h-full gap-2 md:gap-8 md:flex-row">
<div class="flex flex-col h-full gap-4 md:gap-8 md:flex-row">
<x-server.sidebar-proxy :server="$server" :parameters="$parameters" />
@if ($server->isFunctional())
<div class="w-full">

View file

@ -3,7 +3,7 @@
Proxy Logs | Coolify
</x-slot>
<livewire:server.navbar :server="$server" />
<div class="flex flex-col h-full gap-2 md:gap-8 md:flex-row">
<div class="flex flex-col h-full gap-4 md:gap-8 md:flex-row">
<x-server.sidebar-proxy :server="$server" :parameters="$parameters" />
<div class="w-full">
<h2 class="pb-4">Logs</h2>

View file

@ -4,7 +4,7 @@
</x-slot>
<livewire:server.navbar :server="$server" />
@if ($server->isFunctional())
<div class="flex flex-col h-full gap-2 md:gap-8 md:flex-row">
<div class="flex flex-col h-full gap-4 md:gap-8 md:flex-row">
<x-server.sidebar-proxy :server="$server" :parameters="$parameters" />
<div class="w-full">
<livewire:server.proxy :server="$server" />

View file

@ -3,7 +3,7 @@
{{ data_get_str($server, 'name')->limit(10) }} > Server Resources | Coolify
</x-slot>
<livewire:server.navbar :server="$server" />
<div x-data="{ activeTab: 'managed' }" class="flex flex-col h-full gap-2 md:gap-8 md:flex-row">
<div x-data="{ activeTab: 'managed' }" class="flex flex-col h-full gap-4 md:gap-8 md:flex-row">
<div class="w-full">
<div class="flex flex-col">
<div class="flex gap-2">

View file

@ -10,7 +10,7 @@
</x-slot:content>
</x-slide-over>
<div x-data="{ activeTab: window.location.hash ? window.location.hash.substring(1) : 'general' }" class="flex flex-col h-full gap-2 md:gap-8 md:flex-row">
<div x-data="{ activeTab: window.location.hash ? window.location.hash.substring(1) : 'general' }" class="flex flex-col h-full gap-4 md:gap-8 md:flex-row">
<x-server.sidebar-security :server="$server" :parameters="$parameters" />
<form wire:submit='submit' class="w-full">
<div>

View file

@ -3,7 +3,7 @@
{{ data_get_str($server, 'name')->limit(10) }} > Terminal Access | Coolify
</x-slot>
<livewire:server.navbar :server="$server" />
<div x-data="{ activeTab: window.location.hash ? window.location.hash.substring(1) : 'general' }" class="flex flex-col h-full gap-2 md:gap-8 md:flex-row">
<div x-data="{ activeTab: window.location.hash ? window.location.hash.substring(1) : 'general' }" class="flex flex-col h-full gap-4 md:gap-8 md:flex-row">
<x-server.sidebar-security :server="$server" :parameters="$parameters" />
<div class="w-full">
<div>

View file

@ -3,7 +3,7 @@
Sentinel Logs | Coolify
</x-slot>
<livewire:server.navbar :server="$server" />
<div class="flex flex-col h-full gap-2 md:gap-8 md:flex-row">
<div class="flex flex-col h-full gap-4 md:gap-8 md:flex-row">
<x-server.sidebar-sentinel :server="$server" :parameters="$parameters" />
<div class="w-full">
<h2 class="pb-4">Logs</h2>

View file

@ -4,7 +4,7 @@
</x-slot>
<livewire:server.navbar :server="$server" />
@if ($server->isFunctional())
<div class="flex flex-col h-full gap-2 md:gap-8 md:flex-row">
<div class="flex flex-col h-full gap-4 md:gap-8 md:flex-row">
<x-server.sidebar-sentinel :server="$server" :parameters="$parameters" />
<div class="w-full">
<livewire:server.sentinel :server="$server" />

View file

@ -1,9 +1,10 @@
<div x-data x-init="@if ($server->hetzner_server_id && $server->cloudProviderToken && !$hetznerServerStatus) $wire.checkHetznerServerStatus() @endif">
<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">
<x-slot:title>
{{ data_get_str($server, 'name')->limit(10) }} > General | Coolify
</x-slot>
<livewire:server.navbar :server="$server" />
<div class="flex flex-col h-full gap-2 md:gap-8 md:flex-row">
<div class="flex flex-col h-full gap-4 md:gap-8 md:flex-row">
<x-server.sidebar :server="$server" activeMenu="general" />
<div class="w-full">
<form wire:submit.prevent='submit' class="flex flex-col">
@ -78,6 +79,74 @@ class="mx-1 dark:hover:fill-white fill-black dark:fill-warning">
</x-forms.button>
@endif
@endif
@if ($server->vultr_instance_id)
<div class="flex items-center">
<div @class([
'flex items-center gap-1.5 px-2 py-1 text-xs font-semibold rounded transition-all',
'bg-white dark:bg-coolgray-100 dark:text-white',
])
@if (in_array($vultrInstanceStatus, ['pending', 'starting'])) wire:poll.5s="checkVultrInstanceStatus" @endif>
<svg class="w-4 h-4" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<rect width="200" height="200" fill="#007BFC" rx="8" />
<path d="M42 46 H73 L100 127 L127 46 H158 L114 154 H86 Z" fill="white" />
</svg>
@if ($vultrInstanceStatus)
<span class="pl-1.5">
@if (in_array($vultrInstanceStatus, ['pending', 'starting']))
<svg class="inline animate-spin h-3 w-3 mr-1 text-coollabs dark:text-warning-500"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10"
stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
</path>
</svg>
@endif
<span @class([
'text-green-500' => $vultrInstanceStatus === 'active',
'text-red-500' => in_array($vultrInstanceStatus, ['stopped', 'suspended', 'deleted']),
])>
{{ ucfirst($vultrInstanceStatus) }}
</span>
</span>
@else
<span class="pl-1.5">
<svg class="inline animate-spin h-3 w-3 mr-1 text-coollabs dark:text-warning-500"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10"
stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
</path>
</svg>
<span>Checking status...</span>
</span>
@endif
</div>
<button wire:loading.remove wire:target="checkVultrInstanceStatus" title="Refresh Status"
wire:click.prevent='checkVultrInstanceStatus(true)'
class="mx-1 dark:hover:fill-white fill-black dark:fill-warning">
<svg class="w-4 h-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path
d="M12 2a10.016 10.016 0 0 0-7 2.877V3a1 1 0 1 0-2 0v4.5a1 1 0 0 0 1 1h4.5a1 1 0 0 0 0-2H6.218A7.98 7.98 0 0 1 20 12a1 1 0 0 0 2 0A10.012 10.012 0 0 0 12 2zm7.989 13.5h-4.5a1 1 0 0 0 0 2h2.293A7.98 7.98 0 0 1 4 12a1 1 0 0 0-2 0a9.986 9.986 0 0 0 16.989 7.133V21a1 1 0 0 0 2 0v-4.5a1 1 0 0 0-1-1z" />
</svg>
</button>
<button wire:loading wire:target="checkVultrInstanceStatus" title="Refreshing Status"
class="mx-1 dark:hover:fill-white fill-black dark:fill-warning">
<svg class="w-4 h-4 animate-spin" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<path
d="M12 2a10.016 10.016 0 0 0-7 2.877V3a1 1 0 1 0-2 0v4.5a1 1 0 0 0 1 1h4.5a1 1 0 0 0 0-2H6.218A7.98 7.98 0 0 1 20 12a1 1 0 0 0 2 0A10.012 10.012 0 0 0 12 2zm7.989 13.5h-4.5a1 1 0 0 0 0 2h2.293A7.98 7.98 0 0 1 4 12a1 1 0 0 0-2 0a9.986 9.986 0 0 0 16.989 7.133V21a1 1 0 0 0 2 0v-4.5a1 1 0 0 0-1-1z" />
</svg>
</button>
</div>
@if ($server->cloudProviderToken && $vultrInstanceStatus === 'stopped')
<x-forms.button wire:click.prevent='startVultrInstance' isHighlighted canGate="update"
:canResource="$server">
Power On
</x-forms.button>
@endif
@endif
@if ($isValidating)
<div
class="flex items-center gap-1.5 px-2 py-1 text-xs font-semibold rounded bg-warning-100 dark:bg-warning-900/30 text-warning-700 dark:text-warning-400">
@ -147,7 +216,8 @@ class="mt-2 block overflow-x-auto rounded bg-black px-3 py-2 font-mono text-xs t
(!$isReachable || !$isUsable) &&
$server->id !== 0 &&
!$isValidating &&
!in_array($hetznerServerStatus, ['initializing', 'starting', 'stopping', 'off']))
!in_array($hetznerServerStatus, ['initializing', 'starting', 'stopping', 'off']) &&
!in_array($vultrInstanceStatus, ['pending', 'starting', 'stopped', 'suspended', 'deleted']))
<x-slide-over closeWithX fullScreen>
<x-slot:title>Validate & configure</x-slot:title>
<x-slot:content>
@ -436,6 +506,82 @@ class="dark:hover:fill-white fill-black dark:fill-warning">
@endif
</div>
@endif
@if (!$server->vultr_instance_id && $availableVultrTokens->isNotEmpty())
<div class="pt-6">
<h3>Link to Vultr</h3>
<p class="pb-4 text-sm dark:text-neutral-400">
Link this server to a Vultr instance to enable power controls and status monitoring.
</p>
<div class="flex flex-wrap gap-4 items-end">
<div class="w-72">
<x-forms.select wire:model="selectedVultrTokenId" label="Vultr Token"
canGate="update" :canResource="$server">
<option value="">Select a token...</option>
@foreach ($availableVultrTokens as $token)
<option value="{{ $token->id }}">{{ $token->name }}</option>
@endforeach
</x-forms.select>
</div>
<div class="w-72">
<x-forms.input wire:model="manualVultrInstanceId"
label="Instance ID"
placeholder="e.g., 6d4b..."
helper="Enter the Vultr Instance ID from your Vultr dashboard"
canGate="update" :canResource="$server" />
</div>
<x-forms.button wire:click="searchVultrInstanceById"
wire:loading.attr="disabled"
canGate="update" :canResource="$server">
<span wire:loading.remove wire:target="searchVultrInstanceById">Search by ID</span>
<span wire:loading wire:target="searchVultrInstanceById">Searching...</span>
</x-forms.button>
<div class="self-end pb-2 text-sm dark:text-neutral-500">OR</div>
<x-forms.button wire:click="searchVultrInstance"
wire:loading.attr="disabled"
canGate="update" :canResource="$server">
<span wire:loading.remove wire:target="searchVultrInstance">Search by IP</span>
<span wire:loading wire:target="searchVultrInstance">Searching...</span>
</x-forms.button>
</div>
@if ($vultrSearchError)
<div class="mt-4 p-4 border border-red-500 rounded-md bg-red-50 dark:bg-red-900/20">
<p class="text-red-600 dark:text-red-400">{{ $vultrSearchError }}</p>
</div>
@endif
@if ($vultrNoMatchFound)
<div class="mt-4 p-4 border border-yellow-500 rounded-md bg-yellow-50 dark:bg-yellow-900/20">
<p class="text-yellow-600 dark:text-yellow-400">
@if ($manualVultrInstanceId)
No Vultr instance found with ID: {{ $manualVultrInstanceId }}
@else
No Vultr instance found matching IP: {{ $server->ip }}
@endif
</p>
<p class="text-sm dark:text-neutral-400 mt-1">
Try a different token, enter the Instance ID manually, or verify the details are correct.
</p>
</div>
@endif
@if ($matchedVultrInstance)
<div class="mt-4 p-4 border border-green-500 rounded-md bg-green-50 dark:bg-green-900/20">
<h4 class="font-semibold text-green-700 dark:text-green-400 mb-2">Match Found!</h4>
<div class="grid grid-cols-2 gap-2 text-sm mb-4">
<div><span class="font-medium">Name:</span> {{ $matchedVultrInstance['label'] ?? $matchedVultrInstance['hostname'] ?? 'Unknown' }}</div>
<div><span class="font-medium">ID:</span> {{ $matchedVultrInstance['id'] }}</div>
<div><span class="font-medium">Status:</span> {{ ucfirst($matchedVultrInstance['status'] ?? 'unknown') }}</div>
<div><span class="font-medium">Plan:</span> {{ $matchedVultrInstance['plan'] ?? 'Unknown' }}</div>
</div>
<x-forms.button wire:click="linkToVultr" isHighlighted canGate="update" :canResource="$server">
Link This Server
</x-forms.button>
</div>
@endif
</div>
@endif
</div>
</div>
</div>

View file

@ -3,7 +3,7 @@
{{ data_get_str($server, 'name')->limit(10) }} > Swarm | Coolify
</x-slot>
<livewire:server.navbar :server="$server" />
<div class="flex flex-col h-full gap-2 md:gap-8 md:flex-row">
<div class="flex flex-col h-full gap-4 md:gap-8 md:flex-row">
<x-server.sidebar :server="$server" activeMenu="swarm" />
<div class="w-full">
<div>

View file

@ -18,6 +18,7 @@
use App\Http\Controllers\Api\ServicesController;
use App\Http\Controllers\Api\TagsController;
use App\Http\Controllers\Api\TeamController;
use App\Http\Controllers\Api\VultrController;
use App\Http\Middleware\ApiAllowed;
use Illuminate\Support\Facades\Route;
@ -110,6 +111,12 @@
Route::get('/hetzner/networks', [HetznerController::class, 'networks'])->middleware(['api.ability:read']);
Route::post('/servers/hetzner', [HetznerController::class, 'createServer'])->middleware(['api.ability:write']);
Route::get('/vultr/regions', [VultrController::class, 'regions'])->middleware(['api.ability:read']);
Route::get('/vultr/plans', [VultrController::class, 'plans'])->middleware(['api.ability:read']);
Route::get('/vultr/os', [VultrController::class, 'operatingSystems'])->middleware(['api.ability:read']);
Route::get('/vultr/ssh-keys', [VultrController::class, 'sshKeys'])->middleware(['api.ability:read']);
Route::post('/servers/vultr', [VultrController::class, 'createServer'])->middleware(['api.ability:write']);
Route::get('/resources', [ResourcesController::class, 'resources'])->middleware(['api.ability:read']);
Route::get('/tags', [TagsController::class, 'tags'])->middleware(['api.ability:read']);

View file

@ -12,6 +12,12 @@
uses(RefreshDatabase::class);
beforeEach(function () {
config([
'app.maintenance.driver' => 'file',
'cache.default' => 'array',
'session.driver' => 'array',
]);
InstanceSettings::query()->whereKey(0)->delete();
$settings = new InstanceSettings(['is_api_enabled' => true]);
$settings->id = 0;
@ -263,6 +269,30 @@
$response->assertJsonStructure(['uuid']);
});
test('creates a Vultr cloud provider token', function () {
Http::fake([
'https://api.vultr.com/v2/account' => Http::response([], 200),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/cloud-tokens', [
'provider' => 'vultr',
'token' => 'test-vultr-token',
'name' => 'My Vultr Token',
]);
$response->assertStatus(201);
$response->assertJsonStructure(['uuid']);
$this->assertDatabaseHas('cloud_provider_tokens', [
'team_id' => $this->team->id,
'provider' => 'vultr',
'name' => 'My Vultr Token',
]);
});
test('validates provider is required', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
@ -302,7 +332,7 @@
$response->assertJsonValidationErrors(['name']);
});
test('validates provider must be hetzner or digitalocean', function () {
test('validates provider must be hetzner digitalocean or vultr', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
@ -518,6 +548,25 @@
$response->assertJson(['valid' => true, 'message' => 'Token is valid.']);
});
test('validates a valid Vultr token', function () {
$token = CloudProviderToken::factory()->create([
'team_id' => $this->team->id,
'provider' => 'vultr',
]);
Http::fake([
'https://api.vultr.com/v2/account' => Http::response([], 200),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/cloud-tokens/{$token->uuid}/validate");
$response->assertStatus(200);
$response->assertJson(['valid' => true, 'message' => 'Token is valid.']);
});
test('writes an audit log entry when validating a stored token', function () {
$token = CloudProviderToken::factory()->create([
'team_id' => $this->team->id,

View file

@ -0,0 +1,393 @@
<?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;
uses(RefreshDatabase::class);
beforeEach(function () {
config([
'app.maintenance.driver' => 'file',
'cache.default' => 'array',
'session.driver' => 'array',
]);
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create([
'id' => 0,
'is_api_enabled' => true,
]));
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
$this->token = $this->user->createToken('test-token', ['*']);
$this->bearerToken = $this->token->plainTextToken;
$this->vultrToken = CloudProviderToken::create([
'team_id' => $this->team->id,
'provider' => 'vultr',
'token' => 'test-vultr-api-token',
'name' => 'Test Vultr Token',
]);
$this->privateKey = PrivateKey::create([
'team_id' => $this->team->id,
'name' => 'Test Private Key',
'description' => 'Test private key',
'private_key' => testPrivateKey(),
]);
});
function testPrivateKey(): string
{
return <<<'KEY'
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
-----END OPENSSH PRIVATE KEY-----
KEY;
}
function testPublicKey(): string
{
return 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFuGmoeGq/pojrsyP1pszcNVuZx9iFkCELtxrh31QJ68';
}
describe('GET /api/v1/vultr/regions', function () {
test('gets Vultr regions', function () {
Http::fake([
'https://api.vultr.com/v2/regions*' => Http::response([
'regions' => [
['id' => 'ewr', 'city' => 'New Jersey', 'country' => 'US'],
['id' => 'ams', 'city' => 'Amsterdam', 'country' => 'NL'],
],
'meta' => ['links' => ['next' => null]],
], 200),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->getJson('/api/v1/vultr/regions?cloud_provider_token_id='.$this->vultrToken->uuid);
$response->assertStatus(200);
$response->assertJsonCount(2);
$response->assertJsonFragment(['id' => 'ewr']);
});
test('requires cloud_provider_token_id parameter', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->getJson('/api/v1/vultr/regions');
$response->assertStatus(422);
$response->assertJsonValidationErrors(['cloud_provider_token_id']);
});
});
describe('GET /api/v1/vultr/plans', function () {
test('gets Vultr plans', function () {
Http::fake([
'https://api.vultr.com/v2/plans*' => Http::response([
'plans' => [
['id' => 'vc2-1c-1gb', 'vcpu_count' => 1, 'ram' => 1024, 'disk' => 25],
['id' => 'vc2-2c-2gb', 'vcpu_count' => 2, 'ram' => 2048, 'disk' => 55],
],
'meta' => ['links' => ['next' => null]],
], 200),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->getJson('/api/v1/vultr/plans?cloud_provider_token_uuid='.$this->vultrToken->uuid);
$response->assertStatus(200);
$response->assertJsonCount(2);
$response->assertJsonFragment(['id' => 'vc2-1c-1gb']);
});
});
describe('GET /api/v1/vultr/os', function () {
test('gets Vultr operating systems', function () {
Http::fake([
'https://api.vultr.com/v2/os*' => Http::response([
'os' => [
['id' => 2284, 'name' => 'Ubuntu 24.04 LTS x64'],
['id' => 2136, 'name' => 'Debian 12 x64'],
],
'meta' => ['links' => ['next' => null]],
], 200),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->getJson('/api/v1/vultr/os?cloud_provider_token_id='.$this->vultrToken->uuid);
$response->assertStatus(200);
$response->assertJsonCount(2);
$response->assertJsonFragment(['name' => 'Ubuntu 24.04 LTS x64']);
});
});
describe('GET /api/v1/vultr/ssh-keys', function () {
test('gets Vultr SSH keys', function () {
Http::fake([
'https://api.vultr.com/v2/ssh-keys*' => Http::response([
'ssh_keys' => [
['id' => 'key-1', 'name' => 'my-key', 'ssh_key' => testPublicKey()],
['id' => 'key-2', 'name' => 'another-key', 'ssh_key' => 'ssh-ed25519 AAAAother'],
],
'meta' => ['links' => ['next' => null]],
], 200),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->getJson('/api/v1/vultr/ssh-keys?cloud_provider_token_id='.$this->vultrToken->uuid);
$response->assertStatus(200);
$response->assertJsonCount(2);
$response->assertJsonFragment(['name' => 'my-key']);
});
});
describe('POST /api/v1/servers/vultr', function () {
test('creates a Vultr server', function () {
Http::fake([
'https://api.vultr.com/v2/ssh-keys' => Http::response([
'ssh_key' => ['id' => 'key-1', 'ssh_key' => testPublicKey()],
], 201),
'https://api.vultr.com/v2/ssh-keys*' => Http::response([
'ssh_keys' => [],
'meta' => ['links' => ['next' => null]],
], 200),
'https://api.vultr.com/v2/instances' => Http::response([
'instance' => [
'id' => 'instance-1',
'label' => 'test-server',
'main_ip' => '1.2.3.4',
'v6_main_ip' => '2001:db8::1',
'status' => 'pending',
],
], 202),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/servers/vultr', [
'cloud_provider_token_id' => $this->vultrToken->uuid,
'region' => 'ewr',
'plan' => 'vc2-1c-1gb',
'os_id' => 2284,
'name' => 'test-server',
'private_key_uuid' => $this->privateKey->uuid,
'enable_ipv6' => true,
'cloud_init_script' => "#cloud-config\npackages:\n - curl",
]);
$response->assertStatus(201);
$response->assertJsonStructure(['uuid', 'vultr_instance_id', 'ip']);
$response->assertJsonFragment(['vultr_instance_id' => 'instance-1', 'ip' => '1.2.3.4']);
$this->assertDatabaseHas('servers', [
'name' => 'test-server',
'ip' => '1.2.3.4',
'team_id' => $this->team->id,
'vultr_instance_id' => 'instance-1',
'vultr_instance_status' => 'pending',
]);
Http::assertSent(function ($request) {
return $request->url() === 'https://api.vultr.com/v2/instances'
&& $request['region'] === 'ewr'
&& $request['plan'] === 'vc2-1c-1gb'
&& $request['os_id'] === 2284
&& $request['sshkey_id'] === ['key-1']
&& $request['user_data'] === base64_encode("#cloud-config\npackages:\n - curl");
});
});
test('waits for Vultr to assign a real public IP when creation returns placeholder IP', function () {
Http::fake([
'https://api.vultr.com/v2/ssh-keys' => Http::response([
'ssh_key' => ['id' => 'key-1', 'ssh_key' => testPublicKey()],
], 201),
'https://api.vultr.com/v2/ssh-keys*' => Http::response([
'ssh_keys' => [],
'meta' => ['links' => ['next' => null]],
], 200),
'https://api.vultr.com/v2/instances' => Http::response([
'instance' => [
'id' => 'instance-1',
'main_ip' => '0.0.0.0',
'status' => 'pending',
],
], 202),
'https://api.vultr.com/v2/instances/instance-1' => Http::response([
'instance' => [
'id' => 'instance-1',
'main_ip' => '1.2.3.4',
'status' => 'active',
],
], 200),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/servers/vultr', [
'cloud_provider_token_id' => $this->vultrToken->uuid,
'region' => 'ewr',
'plan' => 'vc2-1c-1gb',
'os_id' => 2284,
'name' => 'test-server',
'private_key_uuid' => $this->privateKey->uuid,
]);
$response->assertStatus(201);
$response->assertJsonFragment(['ip' => '1.2.3.4']);
$this->assertDatabaseHas('servers', [
'name' => 'test-server',
'ip' => '1.2.3.4',
'vultr_instance_status' => 'active',
]);
});
test('reuses existing matching Vultr SSH key', function () {
Http::fake([
'https://api.vultr.com/v2/ssh-keys*' => Http::response([
'ssh_keys' => [
['id' => 'existing-key', 'name' => 'Existing', 'ssh_key' => testPublicKey().' comment'],
],
'meta' => ['links' => ['next' => null]],
], 200),
'https://api.vultr.com/v2/instances' => Http::response([
'instance' => [
'id' => 'instance-1',
'main_ip' => '1.2.3.4',
'status' => 'pending',
],
], 202),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/servers/vultr', [
'cloud_provider_token_id' => $this->vultrToken->uuid,
'region' => 'ewr',
'plan' => 'vc2-1c-1gb',
'os_id' => 2284,
'name' => 'test-server',
'private_key_uuid' => $this->privateKey->uuid,
]);
$response->assertStatus(201);
Http::assertNotSent(function ($request) {
return $request->url() === 'https://api.vultr.com/v2/ssh-keys'
&& $request->method() === 'POST';
});
Http::assertSent(function ($request) {
return $request->url() === 'https://api.vultr.com/v2/instances'
&& $request['sshkey_id'] === ['existing-key'];
});
});
test('validates required fields', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/servers/vultr', []);
$response->assertStatus(400);
});
test('returns 404 for non-existent Vultr token', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/servers/vultr', [
'cloud_provider_token_id' => 'non-existent-uuid',
'region' => 'ewr',
'plan' => 'vc2-1c-1gb',
'os_id' => 2284,
'private_key_uuid' => $this->privateKey->uuid,
]);
$response->assertStatus(404);
$response->assertJson(['message' => 'Vultr cloud provider token not found.']);
});
test('uses IPv6 when public IPv4 is disabled', function () {
Http::fake([
'https://api.vultr.com/v2/ssh-keys' => Http::response([
'ssh_key' => ['id' => 'key-1', 'ssh_key' => testPublicKey()],
], 201),
'https://api.vultr.com/v2/ssh-keys*' => Http::response([
'ssh_keys' => [],
'meta' => ['links' => ['next' => null]],
], 200),
'https://api.vultr.com/v2/instances' => Http::response([
'instance' => [
'id' => 'instance-1',
'main_ip' => '',
'v6_main_ip' => '2001:db8::1',
'status' => 'pending',
],
], 202),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/servers/vultr', [
'cloud_provider_token_id' => $this->vultrToken->uuid,
'region' => 'ewr',
'plan' => 'vc2-1c-1gb',
'os_id' => 2284,
'name' => 'test-server',
'private_key_uuid' => $this->privateKey->uuid,
'enable_ipv6' => true,
'disable_public_ipv4' => true,
]);
$response->assertStatus(201);
$response->assertJsonFragment(['ip' => '2001:db8::1']);
});
test('requires IPv6 when public IPv4 is disabled', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/servers/vultr', [
'cloud_provider_token_id' => $this->vultrToken->uuid,
'region' => 'ewr',
'plan' => 'vc2-1c-1gb',
'os_id' => 2284,
'name' => 'test-server',
'private_key_uuid' => $this->privateKey->uuid,
'enable_ipv6' => false,
'disable_public_ipv4' => true,
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['enable_ipv6']);
});
});

View file

@ -0,0 +1,150 @@
<?php
use App\Livewire\Server\New\ByVultr;
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 Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
config([
'app.maintenance.driver' => 'file',
'cache.default' => 'array',
'session.driver' => 'array',
]);
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create([
'id' => 0,
'is_api_enabled' => true,
]));
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
$this->vultrToken = CloudProviderToken::create([
'team_id' => $this->team->id,
'provider' => 'vultr',
'token' => 'test-vultr-token',
'name' => 'Test Vultr Token',
]);
$this->privateKey = PrivateKey::create([
'team_id' => $this->team->id,
'name' => 'Test Private Key',
'description' => 'Test private key',
'private_key' => vultrLivewireTestPrivateKey(),
]);
});
function vultrLivewireTestPrivateKey(): string
{
return <<<'KEY'
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
-----END OPENSSH PRIVATE KEY-----
KEY;
}
function vultrLivewireTestPublicKey(): string
{
return 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFuGmoeGq/pojrsyP1pszcNVuZx9iFkCELtxrh31QJ68';
}
it('creates a Vultr server through the Livewire flow', function () {
Http::fake([
'https://api.vultr.com/v2/regions*' => Http::response([
'regions' => [
['id' => 'ewr', 'city' => 'New Jersey', 'country' => 'US'],
],
'meta' => ['links' => ['next' => null]],
], 200),
'https://api.vultr.com/v2/plans*' => Http::response([
'plans' => [
['id' => 'vc2-1c-1gb', 'vcpu_count' => 1, 'ram' => 1024, 'disk' => 25, 'monthly_cost' => 6, 'locations' => ['ewr']],
],
'meta' => ['links' => ['next' => null]],
], 200),
'https://api.vultr.com/v2/os*' => Http::response([
'os' => [
['id' => 2284, 'name' => 'Ubuntu 24.04 LTS x64'],
],
'meta' => ['links' => ['next' => null]],
], 200),
'https://api.vultr.com/v2/ssh-keys' => Http::response([
'ssh_key' => ['id' => 'key-1', 'ssh_key' => vultrLivewireTestPublicKey()],
], 201),
'https://api.vultr.com/v2/ssh-keys*' => Http::response([
'ssh_keys' => [],
'meta' => ['links' => ['next' => null]],
], 200),
'https://api.vultr.com/v2/instances' => Http::response([
'instance' => [
'id' => 'instance-1',
'label' => 'test-vultr-server',
'main_ip' => '1.2.3.4',
'v6_main_ip' => '2001:db8::1',
'status' => 'pending',
],
], 202),
]);
Livewire::test(ByVultr::class)
->set('selected_token_id', $this->vultrToken->id)
->call('nextStep')
->assertSet('current_step', 2)
->set('server_name', 'test-vultr-server')
->set('selected_region', 'ewr')
->set('selected_plan', 'vc2-1c-1gb')
->set('selected_os_id', 2284)
->set('private_key_id', $this->privateKey->id)
->set('enable_ipv6', true)
->set('cloud_init_script', "#cloud-config\npackages:\n - curl")
->call('submit');
$this->assertDatabaseHas('servers', [
'name' => 'test-vultr-server',
'ip' => '1.2.3.4',
'team_id' => $this->team->id,
'cloud_provider_token_id' => $this->vultrToken->id,
'vultr_instance_id' => 'instance-1',
'vultr_instance_status' => 'pending',
]);
Http::assertSent(function ($request) {
return $request->url() === 'https://api.vultr.com/v2/instances'
&& $request['region'] === 'ewr'
&& $request['plan'] === 'vc2-1c-1gb'
&& $request['os_id'] === 2284
&& $request['sshkey_id'] === ['key-1']
&& $request['user_data'] === base64_encode("#cloud-config\npackages:\n - curl");
});
});
it('requires IPv6 when public IPv4 is disabled', function () {
Livewire::test(ByVultr::class)
->set('selected_token_id', $this->vultrToken->id)
->set('current_step', 2)
->set('server_name', 'test-vultr-server')
->set('selected_region', 'ewr')
->set('selected_plan', 'vc2-1c-1gb')
->set('selected_os_id', 2284)
->set('private_key_id', $this->privateKey->id)
->set('enable_ipv6', false)
->set('disable_public_ipv4', true)
->call('submit')
->assertHasErrors(['enable_ipv6']);
});

View file

@ -0,0 +1,313 @@
<?php
use App\Actions\Server\ValidateServer as ValidateServerAction;
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 Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
config([
'app.maintenance.driver' => 'file',
'cache.default' => 'array',
'session.driver' => 'array',
]);
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create([
'id' => 0,
'is_api_enabled' => true,
]));
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
$this->privateKey = PrivateKey::create([
'team_id' => $this->team->id,
'name' => 'Test Private Key',
'description' => 'Test private key',
'private_key' => vultrLifecycleTestPrivateKey(),
]);
$this->vultrToken = CloudProviderToken::create([
'team_id' => $this->team->id,
'provider' => 'vultr',
'token' => 'test-vultr-token',
'name' => 'Test Vultr Token',
]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $this->privateKey->id,
'cloud_provider_token_id' => $this->vultrToken->id,
'vultr_instance_id' => 'instance-1',
'vultr_instance_status' => 'pending',
'ip' => '1.2.3.4',
]);
});
function vultrLifecycleTestPrivateKey(): string
{
return <<<'KEY'
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
-----END OPENSSH PRIVATE KEY-----
KEY;
}
it('refreshes Vultr instance status', function () {
Http::fake([
'https://api.vultr.com/v2/instances/instance-1' => Http::response([
'instance' => [
'id' => 'instance-1',
'status' => 'active',
],
], 200),
]);
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
->call('checkVultrInstanceStatus', true)
->assertSet('vultrInstanceStatus', 'active');
$this->assertDatabaseHas('servers', [
'id' => $this->server->id,
'vultr_instance_status' => 'active',
]);
});
it('refreshes Vultr status on server page load even when cached status is active', function () {
$this->server->update(['vultr_instance_status' => 'active']);
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
->assertSee('$wire.checkVultrInstanceStatus();', false);
});
it('updates placeholder server IP when Vultr status refresh returns assigned public IP', function () {
$this->server->update(['ip' => '0.0.0.0']);
Http::fake([
'https://api.vultr.com/v2/instances/instance-1' => Http::response([
'instance' => [
'id' => 'instance-1',
'status' => 'active',
'main_ip' => '9.8.7.6',
],
], 200),
]);
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
->call('checkVultrInstanceStatus', true)
->assertSet('ip', '9.8.7.6');
$this->assertDatabaseHas('servers', [
'id' => $this->server->id,
'ip' => '9.8.7.6',
'vultr_instance_status' => 'active',
]);
});
it('does not overwrite an established server IP during Vultr status refresh', function () {
Http::fake([
'https://api.vultr.com/v2/instances/instance-1' => Http::response([
'instance' => [
'id' => 'instance-1',
'status' => 'active',
'main_ip' => '9.8.7.6',
],
], 200),
]);
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
->call('checkVultrInstanceStatus', true)
->assertSet('ip', '1.2.3.4')
->assertSet('vultrInstanceStatus', 'active');
$this->assertDatabaseHas('servers', [
'id' => $this->server->id,
'ip' => '1.2.3.4',
'vultr_instance_status' => 'active',
]);
});
it('blocks server page validation when Vultr instance is stopped', function () {
Http::fake([
'https://api.vultr.com/v2/instances/instance-1' => Http::response([
'instance' => [
'id' => 'instance-1',
'status' => 'active',
'power_status' => 'stopped',
'main_ip' => '1.2.3.4',
],
], 200),
]);
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
->call('validateServer')
->assertSet('vultrInstanceStatus', 'stopped')
->assertDispatched('error', 'Vultr instance is stopped. Power it on before validating.')
->assertNotDispatched('init');
$this->assertDatabaseHas('servers', [
'id' => $this->server->id,
'vultr_instance_status' => 'stopped',
]);
expect($this->server->fresh()->validation_logs)->toBeNull();
});
it('marks missing Vultr instances as deleted before validation', function () {
Http::fake([
'https://api.vultr.com/v2/instances/instance-1' => Http::response([
'error' => 'instance not found',
], 404),
]);
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
->call('validateServer')
->assertSet('vultrInstanceStatus', 'deleted')
->assertDispatched('error', 'Vultr instance is deleted or no longer accessible. Relink this server before validating.')
->assertNotDispatched('init');
$this->assertDatabaseHas('servers', [
'id' => $this->server->id,
'vultr_instance_status' => 'deleted',
]);
});
it('blocks validation modal connection checks when Vultr instance is stopped', function () {
Http::fake([
'https://api.vultr.com/v2/instances/instance-1' => Http::response([
'instance' => [
'id' => 'instance-1',
'status' => 'active',
'power_status' => 'stopped',
'main_ip' => '1.2.3.4',
],
], 200),
]);
Livewire::test(ValidateAndInstall::class, ['server' => $this->server])
->call('validateConnection')
->assertSet('error', 'Vultr instance is stopped. Power it on before validating.')
->assertNotDispatched('validateOS');
$this->assertDatabaseHas('servers', [
'id' => $this->server->id,
'vultr_instance_status' => 'stopped',
'validation_logs' => 'Vultr instance is stopped. Power it on before validating.',
]);
});
it('blocks action validation when Vultr instance is stopped', function () {
Http::fake([
'https://api.vultr.com/v2/instances/instance-1' => Http::response([
'instance' => [
'id' => 'instance-1',
'status' => 'active',
'power_status' => 'stopped',
'main_ip' => '1.2.3.4',
],
], 200),
]);
expect(fn () => ValidateServerAction::run($this->server))
->toThrow(Exception::class, 'Vultr instance is stopped. Power it on before validating.');
$this->assertDatabaseHas('servers', [
'id' => $this->server->id,
'vultr_instance_status' => 'stopped',
'validation_logs' => 'Vultr instance is stopped. Power it on before validating.',
]);
});
it('shows Vultr power on button when stopped even if server was previously functional', function () {
$this->server->update([
'ip' => '1.2.3.5',
'vultr_instance_status' => 'stopped',
]);
$this->server->settings->update([
'is_reachable' => true,
'is_usable' => true,
'force_disabled' => false,
]);
expect($this->server->fresh()->isFunctional())->toBeTrue();
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
->assertSee('Power On');
});
it('starts a Vultr instance', function () {
Http::fake([
'https://api.vultr.com/v2/instances/instance-1/start' => Http::response([], 204),
]);
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
->call('startVultrInstance')
->assertSet('vultrInstanceStatus', 'starting');
$this->assertDatabaseHas('servers', [
'id' => $this->server->id,
'vultr_instance_status' => 'starting',
]);
Http::assertSent(fn ($request) => $request->url() === 'https://api.vultr.com/v2/instances/instance-1/start');
});
it('links a server to Vultr by matching IP', function () {
$unlinkedServer = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $this->privateKey->id,
'ip' => '5.6.7.8',
]);
Http::fake([
'https://api.vultr.com/v2/instances?per_page=100' => Http::response([
'instances' => [
[
'id' => 'instance-2',
'label' => 'matched-server',
'main_ip' => '5.6.7.8',
'status' => 'active',
'plan' => 'vc2-1c-1gb',
],
],
'meta' => ['links' => ['next' => null]],
], 200),
'https://api.vultr.com/v2/instances/instance-2' => Http::response([
'instance' => [
'id' => 'instance-2',
'status' => 'active',
],
], 200),
]);
Livewire::test(Show::class, ['server_uuid' => $unlinkedServer->uuid])
->set('selectedVultrTokenId', $this->vultrToken->id)
->call('searchVultrInstance')
->assertSet('matchedVultrInstance.id', 'instance-2')
->call('linkToVultr');
$this->assertDatabaseHas('servers', [
'id' => $unlinkedServer->id,
'cloud_provider_token_id' => $this->vultrToken->id,
'vultr_instance_id' => 'instance-2',
'vultr_instance_status' => 'active',
]);
});

View file

@ -0,0 +1,16 @@
<?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.select required id="provider" label="Provider" wire:model.live="provider">')
->and($view)->toContain('<option value="vultr">Vultr</option>')
->and($view)->toContain('<option disabled value="digitalocean">DigitalOcean</option>')
->and(substr_count($view, 'Open Account → API Access.'))->toBe(2)
->and(substr_count($view, 'https://console.vultr.com/user/apiaccess/'))->toBe(2)
->and($view)->not->toContain('<option value="digitalocean">DigitalOcean</option>')
->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'");
});

View file

@ -127,6 +127,37 @@
});
describe('ServerConnectionCheckJob unreachable_count', function () {
it('marks Vultr servers unreachable when provider status is unavailable', function () {
Event::fake([ServerReachabilityChanged::class]);
$settings = Mockery::mock();
$settings->is_reachable = true;
$settings->force_disabled = false;
$settings->shouldReceive('update')
->with(['is_reachable' => false, 'is_usable' => false])
->once();
$server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods();
$server->shouldReceive('getAttribute')->andReturnUsing(fn (string $key) => match ($key) {
'settings' => $settings,
'unreachable_notification_sent' => false,
'vultr_instance_id' => 'instance-1',
'cloudProviderToken' => (object) ['token' => 'test-token'],
'id' => 1,
'name' => 'test-server',
'unreachable_count' => 1,
default => null,
});
$server->shouldReceive('refreshVultrState')->once()->andReturn('stopped');
$server->shouldReceive('increment')->with('unreachable_count')->once();
$server->id = 1;
$server->name = 'test-server';
$server->uuid = 'server-uuid';
$job = new ServerConnectionCheckJob($server);
$job->handle();
});
it('increments unreachable_count on timeout', function () {
Event::fake([ServerReachabilityChanged::class]);

View file

@ -0,0 +1,118 @@
<?php
use App\Actions\Server\DeleteServer;
use App\Models\CloudProviderToken;
use App\Models\InstanceSettings;
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 () {
config([
'cache.default' => 'array',
'session.driver' => 'array',
]);
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create([
'id' => 0,
'is_api_enabled' => true,
]));
$this->team = Team::factory()->create();
session(['currentTeam' => $this->team]);
$this->privateKey = PrivateKey::create([
'team_id' => $this->team->id,
'name' => 'Test Private Key',
'description' => 'Test private key',
'private_key' => vultrDeleteTestPrivateKey(),
]);
$this->vultrToken = CloudProviderToken::create([
'team_id' => $this->team->id,
'provider' => 'vultr',
'token' => 'test-vultr-token',
'name' => 'Test Vultr Token',
]);
});
function vultrDeleteTestPrivateKey(): string
{
return <<<'KEY'
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
-----END OPENSSH PRIVATE KEY-----
KEY;
}
it('deletes a Vultr instance when requested', function () {
Http::fake([
'https://api.vultr.com/v2/instances/instance-1' => Http::response([], 204),
]);
$server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $this->privateKey->id,
'cloud_provider_token_id' => $this->vultrToken->id,
'vultr_instance_id' => 'instance-1',
]);
$server->delete();
DeleteServer::run(
serverId: $server->id,
cloudProviderTokenId: $this->vultrToken->id,
teamId: $this->team->id,
deleteFromVultr: true,
vultrInstanceId: 'instance-1'
);
Http::assertSent(fn ($request) => $request->method() === 'DELETE'
&& $request->url() === 'https://api.vultr.com/v2/instances/instance-1');
expect(Server::withTrashed()->find($server->id))->toBeNull();
});
it('does not use another team Vultr token when deleting an instance', function () {
Http::fake([
'https://api.vultr.com/v2/instances/instance-1' => Http::response([], 204),
]);
$otherTeam = Team::factory()->create();
$otherToken = CloudProviderToken::create([
'team_id' => $otherTeam->id,
'provider' => 'vultr',
'token' => 'other-team-vultr-token',
'name' => 'Other Team Vultr Token',
]);
$server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $this->privateKey->id,
'cloud_provider_token_id' => $this->vultrToken->id,
'vultr_instance_id' => 'instance-1',
]);
$server->delete();
DeleteServer::run(
serverId: $server->id,
cloudProviderTokenId: $otherToken->id,
teamId: $this->team->id,
deleteFromVultr: true,
vultrInstanceId: 'instance-1'
);
$request = Http::recorded()->first()[0];
expect($request->header('Authorization'))->toBe(['Bearer test-vultr-token']);
});

View file

@ -0,0 +1,166 @@
<?php
use App\Services\VultrService;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
uses(TestCase::class);
beforeEach(function () {
Http::preventStrayRequests();
});
it('gets instances from Vultr API', function () {
Http::fake([
'https://api.vultr.com/v2/instances*' => Http::response([
'instances' => [
[
'id' => 'instance-1',
'label' => 'test-server-1',
'status' => 'active',
'main_ip' => '123.45.67.89',
'v6_main_ip' => '2001:db8::1',
],
[
'id' => 'instance-2',
'label' => 'test-server-2',
'status' => 'stopped',
'main_ip' => '98.76.54.32',
'v6_main_ip' => '2001:db8::2',
],
],
'meta' => ['links' => ['next' => null]],
], 200),
]);
$service = new VultrService('fake-token');
$instances = $service->getInstances();
expect($instances)->toBeArray()
->and($instances)->toHaveCount(2)
->and($instances[0]['id'])->toBe('instance-1')
->and($instances[1]['id'])->toBe('instance-2');
});
it('follows cursor pagination', function () {
Http::fake([
'https://api.vultr.com/v2/regions?per_page=100' => Http::response([
'regions' => [
['id' => 'ewr', 'city' => 'New Jersey'],
],
'meta' => [
'links' => [
'next' => 'https://api.vultr.com/v2/regions?cursor=next-cursor',
],
],
], 200),
'https://api.vultr.com/v2/regions?per_page=100&cursor=next-cursor' => Http::response([
'regions' => [
['id' => 'ams', 'city' => 'Amsterdam'],
],
'meta' => ['links' => ['next' => null]],
], 200),
]);
$service = new VultrService('fake-token');
$regions = $service->getRegions();
expect($regions)->toHaveCount(2)
->and($regions[0]['id'])->toBe('ewr')
->and($regions[1]['id'])->toBe('ams');
});
it('base64 encodes user data when creating an instance', function () {
Http::fake([
'https://api.vultr.com/v2/instances' => Http::response([
'instance' => [
'id' => 'instance-1',
'main_ip' => '123.45.67.89',
'status' => 'pending',
],
], 202),
]);
$service = new VultrService('fake-token');
$service->createInstance([
'region' => 'ewr',
'plan' => 'vc2-1c-1gb',
'os_id' => 2284,
'user_data' => "#cloud-config\npackages:\n - curl",
]);
Http::assertSent(function ($request) {
return $request->url() === 'https://api.vultr.com/v2/instances'
&& $request['user_data'] === base64_encode("#cloud-config\npackages:\n - curl");
});
});
it('waits for Vultr to replace placeholder public IP', function () {
Http::fake([
'https://api.vultr.com/v2/instances/instance-1' => Http::response([
'instance' => [
'id' => 'instance-1',
'main_ip' => '123.45.67.89',
'status' => 'active',
],
], 200),
]);
$service = new VultrService('fake-token');
$instance = $service->waitForPublicIp([
'id' => 'instance-1',
'main_ip' => '0.0.0.0',
'status' => 'pending',
], sleepMilliseconds: 0);
expect($service->getPublicIp($instance))->toBe('123.45.67.89');
});
it('finds an instance by IPv4 or IPv6', function () {
Http::fake([
'https://api.vultr.com/v2/instances*' => Http::response([
'instances' => [
[
'id' => 'instance-1',
'main_ip' => '123.45.67.89',
'v6_main_ip' => '2001:db8::1',
],
],
'meta' => ['links' => ['next' => null]],
], 200),
]);
$service = new VultrService('fake-token');
expect($service->findInstanceByIp('123.45.67.89')['id'])->toBe('instance-1')
->and($service->findInstanceByIp('2001:db8::1')['id'])->toBe('instance-1')
->and($service->findInstanceByIp('1.2.3.4'))->toBeNull();
});
it('handles empty successful responses from Vultr API', function () {
Http::fake([
'https://api.vultr.com/v2/instances/instance-1/start' => Http::response(null, 204),
'https://api.vultr.com/v2/instances/instance-1' => Http::response(null, 204),
]);
$service = new VultrService('fake-token');
expect($service->startInstance('instance-1'))->toBe([]);
$service->deleteInstance('instance-1');
Http::assertSentCount(2);
});
it('encodes instance IDs in request paths', function () {
Http::fake([
'https://api.vultr.com/v2/instances/instance%2F1' => Http::response([
'instance' => ['id' => 'instance/1'],
], 200),
]);
$service = new VultrService('fake-token');
expect($service->getInstance('instance/1')['id'])->toBe('instance/1');
Http::assertSent(fn ($request) => $request->url() === 'https://api.vultr.com/v2/instances/instance%2F1');
});