Require IPv6 when public IPv4 is disabled, constrain cloud token lookup by team and provider during deletion, and encode Vultr instance IDs in API paths.
191 lines
5.3 KiB
PHP
191 lines
5.3 KiB
PHP
<?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);
|
|
}
|
|
}
|