mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-16 20:14:08 +00:00
Add Vultr cloud provider integration
This commit is contained in:
1 parent
2833c68af9
commit
94abbe1590
33 files changed
+2978
-44
No files matched your search
@@ -7,13 +7,14 @@ use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Notifications\Server\HetznerDeletionFailed;
|
||||
use App\Services\HetznerService;
|
||||
use App\Services\VultrService;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class DeleteServer
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
public function handle(int $serverId, bool $deleteFromHetzner = false, ?int $hetznerServerId = null, ?int $cloudProviderTokenId = null, ?int $teamId = null)
|
||||
public function handle(int $serverId, bool $deleteFromHetzner = false, ?int $hetznerServerId = null, ?int $cloudProviderTokenId = null, ?int $teamId = null, bool $deleteFromVultr = false, ?string $vultrInstanceId = null)
|
||||
{
|
||||
$server = Server::withTrashed()->find($serverId);
|
||||
|
||||
@@ -26,6 +27,14 @@ class DeleteServer
|
||||
);
|
||||
}
|
||||
|
||||
if ($deleteFromVultr && ($vultrInstanceId || ($server && $server->vultr_instance_id))) {
|
||||
$this->deleteFromVultrById(
|
||||
$vultrInstanceId ?? $server->vultr_instance_id,
|
||||
$cloudProviderTokenId ?? $server->cloud_provider_token_id,
|
||||
$teamId ?? $server->team_id
|
||||
);
|
||||
}
|
||||
|
||||
ray($server ? 'Deleting server from Coolify' : 'Server already deleted from Coolify, skipping Coolify deletion');
|
||||
|
||||
// If server is already deleted from Coolify, skip this part
|
||||
@@ -100,4 +109,50 @@ class DeleteServer
|
||||
$team?->notify(new HetznerDeletionFailed($hetznerServerId, $teamId, $e->getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
private function deleteFromVultrById(string $vultrInstanceId, ?int $cloudProviderTokenId, int $teamId): void
|
||||
{
|
||||
try {
|
||||
$token = null;
|
||||
|
||||
if ($cloudProviderTokenId) {
|
||||
$token = CloudProviderToken::find($cloudProviderTokenId);
|
||||
}
|
||||
|
||||
if (! $token) {
|
||||
$token = CloudProviderToken::where('team_id', $teamId)
|
||||
->where('provider', 'vultr')
|
||||
->first();
|
||||
}
|
||||
|
||||
if (! $token) {
|
||||
ray('No Vultr token found for team, skipping Vultr deletion', [
|
||||
'team_id' => $teamId,
|
||||
'vultr_instance_id' => $vultrInstanceId,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$vultrService = new VultrService($token->token);
|
||||
$vultrService->deleteInstance($vultrInstanceId);
|
||||
|
||||
ray('Deleted server from Vultr', [
|
||||
'vultr_instance_id' => $vultrInstanceId,
|
||||
'team_id' => $teamId,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
ray('Failed to delete server from Vultr', [
|
||||
'error' => $e->getMessage(),
|
||||
'vultr_instance_id' => $vultrInstanceId,
|
||||
'team_id' => $teamId,
|
||||
]);
|
||||
|
||||
logger()->error('Failed to delete server from Vultr', [
|
||||
'error' => $e->getMessage(),
|
||||
'vultr_instance_id' => $vultrInstanceId,
|
||||
'team_id' => $teamId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,19 @@ class ValidateServer
|
||||
$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');
|
||||
|
||||
@@ -37,6 +37,9 @@ class CloudProviderTokensController extends Controller
|
||||
'digitalocean' => Http::withHeaders([
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
])->timeout(10)->get('https://api.digitalocean.com/v2/account'),
|
||||
'vultr' => Http::withHeaders([
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
])->timeout(10)->get('https://api.vultr.com/v2/account'),
|
||||
default => null,
|
||||
};
|
||||
|
||||
@@ -82,7 +85,7 @@ class CloudProviderTokensController extends Controller
|
||||
properties: [
|
||||
'uuid' => ['type' => 'string'],
|
||||
'name' => ['type' => 'string'],
|
||||
'provider' => ['type' => 'string', 'enum' => ['hetzner', 'digitalocean']],
|
||||
'provider' => ['type' => 'string', 'enum' => ['hetzner', 'digitalocean', 'vultr']],
|
||||
'team_id' => ['type' => 'integer'],
|
||||
'servers_count' => ['type' => 'integer'],
|
||||
'created_at' => ['type' => 'string'],
|
||||
@@ -199,7 +202,7 @@ class CloudProviderTokensController extends Controller
|
||||
type: 'object',
|
||||
required: ['provider', 'token', 'name'],
|
||||
properties: [
|
||||
'provider' => ['type' => 'string', 'enum' => ['hetzner', 'digitalocean'], 'example' => 'hetzner', 'description' => 'The cloud provider.'],
|
||||
'provider' => ['type' => 'string', 'enum' => ['hetzner', 'digitalocean', 'vultr'], 'example' => 'hetzner', 'description' => 'The cloud provider.'],
|
||||
'token' => ['type' => 'string', 'example' => 'your-api-token-here', 'description' => 'The API token for the cloud provider.'],
|
||||
'name' => ['type' => 'string', 'example' => 'My Hetzner Token', 'description' => 'A friendly name for the token.'],
|
||||
],
|
||||
@@ -253,7 +256,7 @@ class CloudProviderTokensController extends Controller
|
||||
$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',
|
||||
]);
|
||||
|
||||
@@ -850,7 +850,9 @@ class ServersController extends Controller
|
||||
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', [
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
<?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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
public function regions(Request $request)
|
||||
{
|
||||
$token = $this->getVultrToken($request);
|
||||
if ($token instanceof JsonResponse) {
|
||||
return $token;
|
||||
}
|
||||
|
||||
try {
|
||||
return response()->json((new VultrService($token->token))->getRegions());
|
||||
} catch (\Throwable) {
|
||||
return response()->json(['message' => 'Failed to fetch Vultr regions.'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function plans(Request $request)
|
||||
{
|
||||
$token = $this->getVultrToken($request);
|
||||
if ($token instanceof JsonResponse) {
|
||||
return $token;
|
||||
}
|
||||
|
||||
try {
|
||||
return response()->json((new VultrService($token->token))->getPlans());
|
||||
} catch (\Throwable) {
|
||||
return response()->json(['message' => 'Failed to fetch Vultr plans.'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function operatingSystems(Request $request)
|
||||
{
|
||||
$token = $this->getVultrToken($request);
|
||||
if ($token instanceof JsonResponse) {
|
||||
return $token;
|
||||
}
|
||||
|
||||
try {
|
||||
return response()->json((new VultrService($token->token))->getOperatingSystems());
|
||||
} catch (\Throwable) {
|
||||
return response()->json(['message' => 'Failed to fetch Vultr operating systems.'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function sshKeys(Request $request)
|
||||
{
|
||||
$token = $this->getVultrToken($request);
|
||||
if ($token instanceof JsonResponse) {
|
||||
return $token;
|
||||
}
|
||||
|
||||
try {
|
||||
return response()->json((new VultrService($token->token))->getSshKeys());
|
||||
} catch (\Throwable) {
|
||||
return response()->json(['message' => 'Failed to fetch Vultr SSH keys.'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function createServer(Request $request)
|
||||
{
|
||||
$allowedFields = [
|
||||
'cloud_provider_token_uuid',
|
||||
'cloud_provider_token_id',
|
||||
'region',
|
||||
'plan',
|
||||
'os_id',
|
||||
'name',
|
||||
'private_key_uuid',
|
||||
'enable_ipv6',
|
||||
'disable_public_ipv4',
|
||||
'vultr_ssh_key_ids',
|
||||
'cloud_init_script',
|
||||
'instant_validate',
|
||||
];
|
||||
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string',
|
||||
'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string',
|
||||
'region' => 'required|string',
|
||||
'plan' => 'required|string',
|
||||
'os_id' => 'required|integer',
|
||||
'name' => ['nullable', 'string', 'max:253', new ValidHostname],
|
||||
'private_key_uuid' => 'required|string',
|
||||
'enable_ipv6' => 'nullable|boolean',
|
||||
'disable_public_ipv4' => 'nullable|boolean',
|
||||
'vultr_ssh_key_ids' => 'nullable|array',
|
||||
'vultr_ssh_key_ids.*' => 'string',
|
||||
'cloud_init_script' => ['nullable', 'string', new ValidCloudInitYaml],
|
||||
'instant_validate' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
if ($validator->fails() || ! empty($extraFields)) {
|
||||
$errors = $validator->errors();
|
||||
foreach ($extraFields as $field) {
|
||||
$errors->add($field, 'This field is not allowed.');
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
}
|
||||
|
||||
$team = Team::find($teamId);
|
||||
if (Team::serverLimitReached($team)) {
|
||||
return response()->json(['message' => 'Server limit reached for your subscription.'], 400);
|
||||
}
|
||||
|
||||
if (! $request->name) {
|
||||
$request->offsetSet('name', generate_random_name());
|
||||
}
|
||||
if (is_null($request->enable_ipv6)) {
|
||||
$request->offsetSet('enable_ipv6', true);
|
||||
}
|
||||
if (is_null($request->disable_public_ipv4)) {
|
||||
$request->offsetSet('disable_public_ipv4', false);
|
||||
}
|
||||
if (is_null($request->vultr_ssh_key_ids)) {
|
||||
$request->offsetSet('vultr_ssh_key_ids', []);
|
||||
}
|
||||
if (is_null($request->instant_validate)) {
|
||||
$request->offsetSet('instant_validate', false);
|
||||
}
|
||||
|
||||
$token = CloudProviderToken::whereTeamId($teamId)
|
||||
->whereUuid($this->getCloudProviderTokenUuid($request))
|
||||
->where('provider', 'vultr')
|
||||
->first();
|
||||
|
||||
if (! $token) {
|
||||
return response()->json(['message' => 'Vultr cloud provider token not found.'], 404);
|
||||
}
|
||||
|
||||
$privateKey = PrivateKey::whereTeamId($teamId)->whereUuid($request->private_key_uuid)->first();
|
||||
if (! $privateKey) {
|
||||
return response()->json(['message' => 'Private key not found.'], 404);
|
||||
}
|
||||
|
||||
try {
|
||||
$vultrService = new VultrService($token->token);
|
||||
$publicKey = $privateKey->getPublicKey();
|
||||
$existingKey = $this->findMatchingSshKey($vultrService->getSshKeys(), $publicKey);
|
||||
|
||||
if ($existingKey) {
|
||||
$sshKeyId = $existingKey['id'];
|
||||
} else {
|
||||
$uploadedKey = $vultrService->uploadSshKey($privateKey->name, $publicKey);
|
||||
$sshKeyId = $uploadedKey['id'];
|
||||
}
|
||||
|
||||
$normalizedServerName = strtolower(trim($request->name));
|
||||
$sshKeys = array_values(array_unique(array_merge([$sshKeyId], $request->vultr_ssh_key_ids)));
|
||||
|
||||
$params = [
|
||||
'region' => $request->region,
|
||||
'plan' => $request->plan,
|
||||
'os_id' => $request->os_id,
|
||||
'label' => $normalizedServerName,
|
||||
'hostname' => $normalizedServerName,
|
||||
'sshkey_id' => $sshKeys,
|
||||
'enable_ipv6' => $request->enable_ipv6,
|
||||
'disable_public_ipv4' => $request->disable_public_ipv4,
|
||||
];
|
||||
|
||||
if (! empty($request->cloud_init_script)) {
|
||||
$params['user_data'] = $request->cloud_init_script;
|
||||
}
|
||||
|
||||
$vultrInstance = $vultrService->createInstance($params);
|
||||
$ipAddress = $vultrService->getPublicIp($vultrInstance, $request->disable_public_ipv4, $request->enable_ipv6) ?? '0.0.0.0';
|
||||
|
||||
$server = Server::create([
|
||||
'name' => $normalizedServerName,
|
||||
'ip' => $ipAddress,
|
||||
'user' => 'root',
|
||||
'port' => 22,
|
||||
'team_id' => $teamId,
|
||||
'private_key_id' => $privateKey->id,
|
||||
'cloud_provider_token_id' => $token->id,
|
||||
'vultr_instance_id' => $vultrInstance['id'],
|
||||
'vultr_instance_status' => $vultrInstance['status'] ?? null,
|
||||
]);
|
||||
|
||||
$vultrInstance = $vultrService->waitForPublicIp($vultrInstance, $request->disable_public_ipv4, $request->enable_ipv6);
|
||||
$assignedIpAddress = $vultrService->getPublicIp($vultrInstance, $request->disable_public_ipv4, $request->enable_ipv6);
|
||||
if ($assignedIpAddress && $assignedIpAddress !== $server->ip) {
|
||||
$ipAddress = $assignedIpAddress;
|
||||
$server->update([
|
||||
'ip' => $assignedIpAddress,
|
||||
'vultr_instance_status' => $vultrInstance['status'] ?? $server->vultr_instance_status,
|
||||
]);
|
||||
}
|
||||
|
||||
$server->proxy->set('status', 'exited');
|
||||
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
|
||||
$server->save();
|
||||
|
||||
if ($request->instant_validate) {
|
||||
ValidateServer::dispatch($server);
|
||||
}
|
||||
|
||||
auditLog('api.vultr_server.created', [
|
||||
'team_id' => $teamId,
|
||||
'server_uuid' => $server->uuid,
|
||||
'server_name' => $server->name,
|
||||
'vultr_instance_id' => $vultrInstance['id'],
|
||||
'ip' => $ipAddress,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'uuid' => $server->uuid,
|
||||
'vultr_instance_id' => $vultrInstance['id'],
|
||||
'ip' => $ipAddress,
|
||||
])->setStatusCode(201);
|
||||
} catch (RateLimitException $e) {
|
||||
$response = response()->json(['message' => $e->getMessage()], 429);
|
||||
if ($e->retryAfter !== null) {
|
||||
$response->header('Retry-After', $e->retryAfter);
|
||||
}
|
||||
|
||||
return $response;
|
||||
} catch (\Throwable) {
|
||||
return response()->json(['message' => 'Failed to create Vultr server.'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
private function findMatchingSshKey(array $sshKeys, string $publicKey): ?array
|
||||
{
|
||||
$normalizedPublicKey = $this->normalizePublicKey($publicKey);
|
||||
|
||||
foreach ($sshKeys as $sshKey) {
|
||||
if ($this->normalizePublicKey($sshKey['ssh_key'] ?? '') === $normalizedPublicKey) {
|
||||
return $sshKey;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function normalizePublicKey(string $publicKey): string
|
||||
{
|
||||
$parts = preg_split('/\s+/', trim($publicKey));
|
||||
|
||||
return implode(' ', array_slice($parts ?: [], 0, 2));
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,10 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$this->checkHetznerStatus();
|
||||
}
|
||||
|
||||
if ($this->server->vultr_instance_id && $this->server->cloudProviderToken) {
|
||||
$this->checkVultrStatus();
|
||||
}
|
||||
|
||||
// Temporarily disable mux if requested
|
||||
if ($this->disableMux) {
|
||||
$this->disableSshMux();
|
||||
@@ -186,6 +190,21 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue
|
||||
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -27,7 +27,7 @@ class CloudProviderTokenForm extends Component
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'provider' => 'required|string|in:hetzner,digitalocean',
|
||||
'provider' => 'required|string|in:hetzner,digitalocean,vultr',
|
||||
'token' => 'required|string',
|
||||
'name' => 'required|string|max:255',
|
||||
];
|
||||
@@ -50,13 +50,25 @@ class CloudProviderTokenForm extends Component
|
||||
$response = Http::withHeaders([
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
])->timeout(10)->get('https://api.hetzner.cloud/v1/servers');
|
||||
ray($response);
|
||||
|
||||
return $response->successful();
|
||||
}
|
||||
|
||||
// Add other providers here in the future
|
||||
// if ($provider === 'digitalocean') { ... }
|
||||
if ($provider === 'digitalocean') {
|
||||
$response = Http::withHeaders([
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
])->timeout(10)->get('https://api.digitalocean.com/v2/account');
|
||||
|
||||
return $response->successful();
|
||||
}
|
||||
|
||||
if ($provider === 'vultr') {
|
||||
$response = Http::withHeaders([
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
])->timeout(10)->get('https://api.vultr.com/v2/account');
|
||||
|
||||
return $response->successful();
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (\Throwable $e) {
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Livewire\Security;
|
||||
|
||||
use App\Models\CloudProviderToken;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Component;
|
||||
|
||||
class CloudProviderTokens extends Component
|
||||
@@ -50,6 +51,13 @@ class CloudProviderTokens extends Component
|
||||
} else {
|
||||
$this->dispatch('error', 'DigitalOcean token validation failed. Please check the token.');
|
||||
}
|
||||
} elseif ($token->provider === 'vultr') {
|
||||
$isValid = $this->validateVultrToken($token->token);
|
||||
if ($isValid) {
|
||||
$this->dispatch('success', 'Vultr token is valid.');
|
||||
} else {
|
||||
$this->dispatch('error', 'Vultr token validation failed. Please check the token.');
|
||||
}
|
||||
} else {
|
||||
$this->dispatch('error', 'Unknown provider.');
|
||||
}
|
||||
@@ -61,7 +69,7 @@ class CloudProviderTokens extends Component
|
||||
private function validateHetznerToken(string $token): bool
|
||||
{
|
||||
try {
|
||||
$response = \Illuminate\Support\Facades\Http::withToken($token)
|
||||
$response = Http::withToken($token)
|
||||
->timeout(10)
|
||||
->get('https://api.hetzner.cloud/v1/servers?per_page=1');
|
||||
|
||||
@@ -74,7 +82,7 @@ class CloudProviderTokens extends Component
|
||||
private function validateDigitalOceanToken(string $token): bool
|
||||
{
|
||||
try {
|
||||
$response = \Illuminate\Support\Facades\Http::withToken($token)
|
||||
$response = Http::withToken($token)
|
||||
->timeout(10)
|
||||
->get('https://api.digitalocean.com/v2/account');
|
||||
|
||||
@@ -84,6 +92,19 @@ class CloudProviderTokens extends Component
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Livewire\Server\CloudProviderToken;
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Component;
|
||||
|
||||
class Show extends Component
|
||||
@@ -17,6 +18,10 @@ class Show extends Component
|
||||
|
||||
public $parameters = [];
|
||||
|
||||
public string $provider = 'hetzner';
|
||||
|
||||
public string $providerName = 'Hetzner';
|
||||
|
||||
public function mount(string $server_uuid)
|
||||
{
|
||||
try {
|
||||
@@ -36,8 +41,11 @@ class Show extends Component
|
||||
|
||||
public function loadTokens()
|
||||
{
|
||||
$this->provider = $this->server->vultr_instance_id ? 'vultr' : 'hetzner';
|
||||
$this->providerName = $this->provider === 'vultr' ? 'Vultr' : 'Hetzner';
|
||||
|
||||
$this->cloudProviderTokens = CloudProviderToken::ownedByCurrentTeam()
|
||||
->where('provider', 'hetzner')
|
||||
->where('provider', $this->provider)
|
||||
->get();
|
||||
}
|
||||
|
||||
@@ -67,7 +75,7 @@ class Show extends Component
|
||||
|
||||
$this->server->cloudProviderToken()->associate($ownedToken);
|
||||
$this->server->save();
|
||||
$this->dispatch('success', 'Hetzner token updated successfully.');
|
||||
$this->dispatch('success', "{$this->providerName} token updated successfully.");
|
||||
$this->dispatch('refreshServerShow');
|
||||
} catch (\Exception $e) {
|
||||
$this->server->refresh();
|
||||
@@ -78,10 +86,13 @@ class Show extends Component
|
||||
private function validateTokenForServer(CloudProviderToken $token): array
|
||||
{
|
||||
try {
|
||||
// First, validate the token itself
|
||||
$response = \Illuminate\Support\Facades\Http::withHeaders([
|
||||
$endpoint = $token->provider === 'vultr'
|
||||
? 'https://api.vultr.com/v2/account'
|
||||
: 'https://api.hetzner.cloud/v1/servers';
|
||||
|
||||
$response = Http::withHeaders([
|
||||
'Authorization' => 'Bearer '.$token->token,
|
||||
])->timeout(10)->get('https://api.hetzner.cloud/v1/servers');
|
||||
])->timeout(10)->get($endpoint);
|
||||
|
||||
if (! $response->successful()) {
|
||||
return [
|
||||
@@ -90,9 +101,8 @@ class Show extends Component
|
||||
];
|
||||
}
|
||||
|
||||
// Check if this token can access the specific Hetzner server
|
||||
if ($this->server->hetzner_server_id) {
|
||||
$serverResponse = \Illuminate\Support\Facades\Http::withHeaders([
|
||||
$serverResponse = Http::withHeaders([
|
||||
'Authorization' => 'Bearer '.$token->token,
|
||||
])->timeout(10)->get("https://api.hetzner.cloud/v1/servers/{$this->server->hetzner_server_id}");
|
||||
|
||||
@@ -104,6 +114,19 @@ class Show extends Component
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->server->vultr_instance_id) {
|
||||
$serverResponse = Http::withHeaders([
|
||||
'Authorization' => 'Bearer '.$token->token,
|
||||
])->timeout(10)->get("https://api.vultr.com/v2/instances/{$this->server->vultr_instance_id}");
|
||||
|
||||
if (! $serverResponse->successful()) {
|
||||
return [
|
||||
'valid' => false,
|
||||
'error' => 'This token cannot access this instance. It may belong to a different Vultr account.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return ['valid' => true];
|
||||
} catch (\Throwable $e) {
|
||||
return [
|
||||
@@ -118,19 +141,23 @@ class Show extends Component
|
||||
try {
|
||||
$token = $this->server->cloudProviderToken;
|
||||
if (! $token) {
|
||||
$this->dispatch('error', 'No Hetzner token is associated with this server.');
|
||||
$this->dispatch('error', "No {$this->providerName} token is associated with this server.");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$response = \Illuminate\Support\Facades\Http::withHeaders([
|
||||
$endpoint = $token->provider === 'vultr'
|
||||
? 'https://api.vultr.com/v2/account'
|
||||
: 'https://api.hetzner.cloud/v1/servers';
|
||||
|
||||
$response = Http::withHeaders([
|
||||
'Authorization' => 'Bearer '.$token->token,
|
||||
])->timeout(10)->get('https://api.hetzner.cloud/v1/servers');
|
||||
])->timeout(10)->get($endpoint);
|
||||
|
||||
if ($response->successful()) {
|
||||
$this->dispatch('success', 'Hetzner token is valid and working.');
|
||||
$this->dispatch('success', "{$this->providerName} token is valid and working.");
|
||||
} else {
|
||||
$this->dispatch('error', 'Hetzner token is invalid or has insufficient permissions.');
|
||||
$this->dispatch('error', "{$this->providerName} token is invalid or has insufficient permissions.");
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
|
||||
@@ -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 @@ class Delete extends Component
|
||||
|
||||
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 @@ class Delete extends Component
|
||||
$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 @@ class Delete extends Component
|
||||
];
|
||||
}
|
||||
|
||||
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,
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
<?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();
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use App\Models\CloudProviderToken;
|
||||
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 @@ class Show extends Component
|
||||
}
|
||||
// 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 @@ class Show extends Component
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->server);
|
||||
if ($this->server->vultr_instance_id) {
|
||||
$status = $this->server->refreshVultrState();
|
||||
$this->server->refresh();
|
||||
$this->vultrInstanceStatus = $this->server->vultr_instance_status;
|
||||
$this->ip = $this->server->ip;
|
||||
|
||||
if (in_array($status, ['stopped', 'suspended', 'deleted'], true)) {
|
||||
$message = $status === 'deleted'
|
||||
? 'Vultr instance is deleted or no longer accessible. Relink this server before validating.'
|
||||
: 'Vultr instance is '.($status ?? 'not running').'. Power it on before validating.';
|
||||
$this->dispatch('error', $message);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$this->validationLogs = $this->server->validation_logs = null;
|
||||
$this->server->save();
|
||||
$this->dispatch('init', $install);
|
||||
@@ -453,6 +488,27 @@ class Show extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function checkVultrInstanceStatus(bool $manual = false)
|
||||
{
|
||||
try {
|
||||
if (! $this->server->vultr_instance_id || ! $this->server->cloudProviderToken) {
|
||||
$this->dispatch('error', 'This server is not associated with a Vultr instance or token.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->vultrInstanceStatus = $this->server->refreshVultrState();
|
||||
$this->server->refresh();
|
||||
$this->ip = $this->server->ip;
|
||||
|
||||
if ($manual) {
|
||||
$this->dispatch('success', 'Instance status refreshed: '.ucfirst($this->vultrInstanceStatus ?? 'unknown'));
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function handleServerValidated($event = null)
|
||||
{
|
||||
// Check if event is for this server
|
||||
@@ -472,6 +528,7 @@ class Show extends Component
|
||||
|
||||
// Reload Hetzner tokens in case the linking section should now be shown
|
||||
$this->loadHetznerTokens();
|
||||
$this->loadVultrTokens();
|
||||
|
||||
$this->dispatch('refreshServerShow');
|
||||
$this->dispatch('refreshServer');
|
||||
@@ -498,6 +555,27 @@ class Show extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function startVultrInstance()
|
||||
{
|
||||
try {
|
||||
if (! $this->server->vultr_instance_id || ! $this->server->cloudProviderToken) {
|
||||
$this->dispatch('error', 'This server is not associated with a Vultr instance or token.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$vultrService = new VultrService($this->server->cloudProviderToken->token);
|
||||
$vultrService->startInstance($this->server->vultr_instance_id);
|
||||
|
||||
$this->vultrInstanceStatus = 'starting';
|
||||
$this->server->update(['vultr_instance_status' => 'starting']);
|
||||
$this->vultrInstanceManuallyStarted = true;
|
||||
$this->dispatch('success', 'Vultr instance is starting...');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function refreshServerMetadata(): void
|
||||
{
|
||||
try {
|
||||
@@ -531,6 +609,13 @@ class Show extends Component
|
||||
->get();
|
||||
}
|
||||
|
||||
public function loadVultrTokens(): void
|
||||
{
|
||||
$this->availableVultrTokens = CloudProviderToken::ownedByCurrentTeam()
|
||||
->where('provider', 'vultr')
|
||||
->get();
|
||||
}
|
||||
|
||||
public function searchHetznerServer(): void
|
||||
{
|
||||
$this->hetznerSearchError = null;
|
||||
@@ -658,6 +743,130 @@ class Show extends Component
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
@@ -87,6 +87,22 @@ class ValidateAndInstall extends Component
|
||||
public function validateConnection()
|
||||
{
|
||||
$this->authorize('update', $this->server);
|
||||
if ($this->server->vultr_instance_id) {
|
||||
$status = $this->server->refreshVultrState();
|
||||
$this->server->refresh();
|
||||
|
||||
if (in_array($status, ['stopped', 'suspended', 'deleted'], true)) {
|
||||
$this->error = $status === 'deleted'
|
||||
? 'Vultr instance is deleted or no longer accessible. Relink this server before validating.'
|
||||
: 'Vultr instance is '.($status ?? 'not running').'. Power it on before validating.';
|
||||
$this->server->update([
|
||||
'validation_logs' => $this->error,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
['uptime' => $this->uptime, 'error' => $error] = $this->server->validateConnection();
|
||||
if (! $this->uptime) {
|
||||
$sanitizedError = htmlspecialchars($error ?? '', ENT_QUOTES, 'UTF-8');
|
||||
|
||||
@@ -2,8 +2,12 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
|
||||
class CloudProviderToken extends BaseModel
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'team_id',
|
||||
'provider',
|
||||
|
||||
@@ -17,6 +17,7 @@ use App\Livewire\Server\Proxy;
|
||||
use App\Notifications\Server\Reachable;
|
||||
use App\Notifications\Server\Unreachable;
|
||||
use App\Services\ConfigurationRepository;
|
||||
use App\Services\VultrService;
|
||||
use App\Support\ValidationPatterns;
|
||||
use App\Traits\ClearsGlobalSearchCache;
|
||||
use App\Traits\HasMetrics;
|
||||
@@ -270,7 +271,10 @@ class Server extends BaseModel
|
||||
'team_id',
|
||||
'hetzner_server_id',
|
||||
'hetzner_server_status',
|
||||
'vultr_instance_id',
|
||||
'vultr_instance_status',
|
||||
'is_validating',
|
||||
'validation_logs',
|
||||
'detected_traefik_version',
|
||||
'traefik_outdated_info',
|
||||
'server_metadata',
|
||||
@@ -291,6 +295,51 @@ class Server extends BaseModel
|
||||
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(
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
<?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', "/instances/{$instanceId}");
|
||||
|
||||
return $response['instance'] ?? [];
|
||||
}
|
||||
|
||||
public function startInstance(string $instanceId): array
|
||||
{
|
||||
return $this->request('post', "/instances/{$instanceId}/start");
|
||||
}
|
||||
|
||||
public function deleteInstance(string $instanceId): void
|
||||
{
|
||||
$this->request('delete', "/instances/{$instanceId}");
|
||||
}
|
||||
|
||||
public function getInstances(): array
|
||||
{
|
||||
return $this->requestPaginated('/instances', 'instances');
|
||||
}
|
||||
|
||||
public function findInstanceByIp(string $ip): ?array
|
||||
{
|
||||
foreach ($this->getInstances() as $instance) {
|
||||
if (($instance['main_ip'] ?? null) === $ip || ($instance['v6_main_ip'] ?? null) === $ip) {
|
||||
return $instance;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user