coolify/app/Services/DigitalOceanService.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

221 lines
6.6 KiB
PHP

<?php
namespace App\Services;
use App\Exceptions\RateLimitException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
class DigitalOceanService
{
private string $baseUrl = 'https://api.digitalocean.com/v2';
public function __construct(private string $token) {}
private function request(string $method, string $endpoint, array $data = []): array
{
$response = Http::withToken($this->token)
->acceptJson()
->timeout(30)
->connectTimeout(10)
->retry(3, function (int $attempt, \Exception $exception) {
if ($exception instanceof RequestException && $exception->response?->status() === 429) {
$resetTime = $exception->response->header('RateLimit-Reset');
if ($resetTime) {
return min(max(0, (int) $resetTime - time()), 60) * 1000;
}
}
return $attempt * 100;
}, throw: false)
->{$method}($this->baseUrl.$endpoint, $data);
if (! $response->successful()) {
if ($response->status() === 429) {
$retryAfter = $response->header('Retry-After');
if ($retryAfter === null) {
$resetTime = $response->header('RateLimit-Reset');
$retryAfter = $resetTime ? max(0, (int) $resetTime - time()) : null;
}
throw new RateLimitException(
'Rate limit exceeded. Please try again later.',
$retryAfter !== null ? (int) $retryAfter : null
);
}
throw new \Exception('DigitalOcean API error: '.$response->json('message', 'Unknown error'), $response->status());
}
return $response->json() ?? [];
}
private function requestPaginated(string $endpoint, string $resourceKey, array $data = []): array
{
$allResults = [];
$page = 1;
do {
$response = $this->request('get', $endpoint, array_merge($data, [
'page' => $page,
'per_page' => 50,
]));
if (isset($response[$resourceKey])) {
$allResults = array_merge($allResults, $response[$resourceKey]);
}
$hasNextPage = filled(data_get($response, 'links.pages.next'));
$page++;
} while ($hasNextPage);
return $allResults;
}
public function getAccount(): array
{
return $this->request('get', '/account')['account'] ?? [];
}
public function getRegions(): array
{
return array_values(array_filter(
$this->requestPaginated('/regions', 'regions'),
fn (array $region) => ($region['available'] ?? true) === true
));
}
public function getSizes(): array
{
return array_values(array_filter(
$this->requestPaginated('/sizes', 'sizes'),
fn (array $size) => ($size['available'] ?? true) === true
));
}
public function getImages(): array
{
return array_values(array_filter(
$this->requestPaginated('/images', 'images', ['type' => 'distribution']),
fn (array $image) => ($image['public'] ?? true) === true
));
}
public function getSshKeys(): array
{
return $this->requestPaginated('/account/keys', 'ssh_keys');
}
public function uploadSshKey(string $name, string $publicKey): array
{
$response = $this->request('post', '/account/keys', [
'name' => $name,
'public_key' => $publicKey,
]);
return $response['ssh_key'] ?? [];
}
public function createDroplet(array $params): array
{
$response = $this->request('post', '/droplets', $params);
return $response['droplet'] ?? [];
}
public function getDroplet(int $dropletId): array
{
$response = $this->request('get', $this->dropletEndpoint($dropletId));
return $response['droplet'] ?? [];
}
public function waitForPublicIp(array $droplet, bool $enableIpv4 = true, bool $enableIpv6 = true, int $attempts = 30, int $sleepMilliseconds = 1000): array
{
if ($this->getPublicIpAddress($droplet, $enableIpv4, $enableIpv6) || empty($droplet['id'])) {
return $droplet;
}
for ($attempt = 0; $attempt < $attempts; $attempt++) {
usleep($sleepMilliseconds * 1000);
$droplet = $this->getDroplet((int) $droplet['id']);
if ($this->getPublicIpAddress($droplet, $enableIpv4, $enableIpv6)) {
return $droplet;
}
}
return $droplet;
}
public function powerOnDroplet(int $dropletId): array
{
$response = $this->request('post', $this->dropletEndpoint($dropletId).'/actions', [
'type' => 'power_on',
]);
return $response['action'] ?? [];
}
public function deleteDroplet(int $dropletId): void
{
$this->request('delete', $this->dropletEndpoint($dropletId));
}
public function getDroplets(): array
{
return $this->requestPaginated('/droplets', 'droplets');
}
public function findDropletByIp(string $ip): ?array
{
foreach ($this->getDroplets() as $droplet) {
if ($this->dropletHasIp($droplet, $ip)) {
return $droplet;
}
}
return null;
}
public function getPublicIpAddress(array $droplet, bool $enableIpv4 = true, bool $enableIpv6 = true): ?string
{
if ($enableIpv4) {
foreach (data_get($droplet, 'networks.v4', []) as $network) {
if (($network['type'] ?? null) === 'public' && filled($network['ip_address'] ?? null)) {
return $network['ip_address'];
}
}
}
if ($enableIpv6) {
foreach (data_get($droplet, 'networks.v6', []) as $network) {
if (($network['type'] ?? null) === 'public' && filled($network['ip_address'] ?? null)) {
return $network['ip_address'];
}
}
}
return null;
}
private function dropletEndpoint(int $dropletId): string
{
return '/droplets/'.$dropletId;
}
private function dropletHasIp(array $droplet, string $ip): bool
{
foreach (['v4', 'v6'] as $version) {
foreach (data_get($droplet, "networks.{$version}", []) as $network) {
if (($network['ip_address'] ?? null) === $ip) {
return true;
}
}
}
return false;
}
}