coolify/app/Services/VultrService.php
Andras Bacsai 7d699818e8 fix: harden Vultr create, Gmail identity, and provider retries
Wrap Vultr server creation in DB transactions and delete the remote
instance when local persistence fails (API and Livewire). Scope
plus/dot email normalization to gmail.com/googlemail.com only.
Use throw:false on DigitalOcean/Vultr HTTP retries, and normalize
service log line counts via normalizeLogLines.
2026-07-16 13:42:53 +02:00

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, throw: false)
->{$method}($this->baseUrl.$endpoint, $data);
if (! $response->successful()) {
if ($response->status() === 429) {
throw new RateLimitException(
'Rate limit exceeded. Please try again later.',
$response->header('Retry-After') !== null ? (int) $response->header('Retry-After') : null
);
}
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);
}
}