Add Vultr cloud provider integration

This commit is contained in:
Cornelis Terblanche committed 2026-06-03 21:32:18 +02:00
1 parent 2833c68af9
commit 94abbe1590
33 files changed
+2978 -44

No files matched your search

+39 -12
View File
@@ -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);
+14 -1
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 @@ 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,
]);
+417
View File
@@ -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));
}
}
+210 -1
View File
@@ -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');