mirror of
https://github.com/tiennm99/coolify.git
synced 2026-08-21 06:26:22 +00:00
fix(servers): isolate cloud status checks from SSH checks
Track provider state independently, skip SSH work for placeholder IPs, and clean up failed cloud server provisioning.
This commit is contained in:
@@ -122,12 +122,7 @@ class DeleteServer
|
||||
}
|
||||
|
||||
if (! $token) {
|
||||
logger()->debug('No Vultr token found for team, skipping Vultr deletion', [
|
||||
'team_id' => $teamId,
|
||||
'vultr_instance_id' => $vultrInstanceId,
|
||||
]);
|
||||
|
||||
return;
|
||||
throw new \RuntimeException('No Vultr token found for the server team.');
|
||||
}
|
||||
|
||||
$vultrService = new VultrService($token->token);
|
||||
@@ -143,6 +138,8 @@ class DeleteServer
|
||||
'vultr_instance_id' => $vultrInstanceId,
|
||||
'team_id' => $teamId,
|
||||
]);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,12 +162,7 @@ class DeleteServer
|
||||
}
|
||||
|
||||
if (! $token) {
|
||||
logger()->debug('No DigitalOcean token found for team, skipping droplet deletion', [
|
||||
'team_id' => $teamId,
|
||||
'digitalocean_droplet_id' => $digitalOceanDropletId,
|
||||
]);
|
||||
|
||||
return;
|
||||
throw new \RuntimeException('No DigitalOcean token found for the server team.');
|
||||
}
|
||||
|
||||
$digitalOceanService = new DigitalOceanService($token->token);
|
||||
@@ -186,6 +178,8 @@ class DeleteServer
|
||||
'digitalocean_droplet_id' => $digitalOceanDropletId,
|
||||
'team_id' => $teamId,
|
||||
]);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ use App\Rules\ValidHostname;
|
||||
use App\Services\DigitalOceanService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class DigitalOceanController extends Controller
|
||||
@@ -283,6 +284,10 @@ class DigitalOceanController extends Controller
|
||||
return response()->json(['message' => 'Private key not found.'], 404);
|
||||
}
|
||||
|
||||
$digitalOceanService = null;
|
||||
$dropletId = null;
|
||||
$server = null;
|
||||
|
||||
try {
|
||||
$digitalOceanService = new DigitalOceanService($token->token);
|
||||
$sshKeyId = $this->getOrCreateSshKey($digitalOceanService, $privateKey);
|
||||
@@ -309,29 +314,41 @@ class DigitalOceanController extends Controller
|
||||
|
||||
$droplet = $digitalOceanService->createDroplet($params);
|
||||
$dropletId = (int) $droplet['id'];
|
||||
$droplet = $digitalOceanService->waitForPublicIp($droplet, true, $request->enable_ipv6);
|
||||
$ipAddress = $digitalOceanService->getPublicIpAddress($droplet, true, $request->enable_ipv6);
|
||||
|
||||
if (! $ipAddress) {
|
||||
throw new \Exception('No public IP address available for the new droplet.');
|
||||
$server = DB::transaction(function () use ($normalizedServerName, $teamId, $privateKey, $token, $dropletId, $droplet): Server {
|
||||
$server = Server::create([
|
||||
'name' => $normalizedServerName,
|
||||
'ip' => Server::PLACEHOLDER_IP,
|
||||
'user' => 'root',
|
||||
'port' => 22,
|
||||
'team_id' => $teamId,
|
||||
'private_key_id' => $privateKey->id,
|
||||
'cloud_provider_token_id' => $token->id,
|
||||
'digitalocean_droplet_id' => $dropletId,
|
||||
'digitalocean_droplet_status' => $droplet['status'] ?? null,
|
||||
]);
|
||||
|
||||
$server->proxy->set('status', 'exited');
|
||||
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
|
||||
$server->save();
|
||||
|
||||
return $server;
|
||||
});
|
||||
|
||||
try {
|
||||
$droplet = $digitalOceanService->waitForPublicIp($droplet, true, $request->enable_ipv6);
|
||||
$ipAddress = $digitalOceanService->getPublicIpAddress($droplet, true, $request->enable_ipv6);
|
||||
|
||||
if ($ipAddress) {
|
||||
$server->update([
|
||||
'ip' => $ipAddress,
|
||||
'digitalocean_droplet_status' => $droplet['status'] ?? $server->digitalocean_droplet_status,
|
||||
]);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
}
|
||||
|
||||
$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,
|
||||
'digitalocean_droplet_id' => $dropletId,
|
||||
'digitalocean_droplet_status' => $droplet['status'] ?? null,
|
||||
]);
|
||||
|
||||
$server->proxy->set('status', 'exited');
|
||||
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
|
||||
$server->save();
|
||||
|
||||
if ($request->instant_validate) {
|
||||
ValidateServer::dispatch($server);
|
||||
}
|
||||
@@ -341,15 +358,17 @@ class DigitalOceanController extends Controller
|
||||
'server_uuid' => $server->uuid,
|
||||
'server_name' => $server->name,
|
||||
'digitalocean_droplet_id' => $dropletId,
|
||||
'ip' => $ipAddress,
|
||||
'ip' => $server->ip,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'uuid' => $server->uuid,
|
||||
'digitalocean_droplet_id' => $dropletId,
|
||||
'ip' => $ipAddress,
|
||||
'ip' => $server->ip,
|
||||
])->setStatusCode(201);
|
||||
} catch (RateLimitException $e) {
|
||||
$this->deleteUntrackedDroplet($digitalOceanService, $dropletId, $server);
|
||||
|
||||
$response = response()->json(['message' => $e->getMessage()], 429);
|
||||
if ($e->retryAfter !== null) {
|
||||
$response->header('Retry-After', $e->retryAfter);
|
||||
@@ -357,6 +376,8 @@ class DigitalOceanController extends Controller
|
||||
|
||||
return $response;
|
||||
} catch (\Throwable $e) {
|
||||
$this->deleteUntrackedDroplet($digitalOceanService, $dropletId, $server);
|
||||
|
||||
logger()->error('Failed to create DigitalOcean server', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
@@ -365,6 +386,19 @@ class DigitalOceanController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
private function deleteUntrackedDroplet(?DigitalOceanService $digitalOceanService, ?int $dropletId, ?Server $server): void
|
||||
{
|
||||
if (! $digitalOceanService || ! $dropletId || $server) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$digitalOceanService->deleteDroplet($dropletId);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
}
|
||||
}
|
||||
|
||||
private function getOrCreateSshKey(DigitalOceanService $digitalOceanService, PrivateKey $privateKey): int
|
||||
{
|
||||
$md5Fingerprint = PrivateKey::generateMd5Fingerprint($privateKey->private_key);
|
||||
|
||||
@@ -52,6 +52,8 @@ class VultrController extends Controller
|
||||
return response()->json(['message' => 'Vultr cloud provider token not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('view', $token);
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
@@ -277,6 +279,8 @@ class VultrController extends Controller
|
||||
return response()->json(['message' => 'Vultr cloud provider token not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('view', $token);
|
||||
|
||||
$privateKey = PrivateKey::whereTeamId($teamId)->whereUuid($request->private_key_uuid)->first();
|
||||
if (! $privateKey) {
|
||||
return response()->json(['message' => 'Private key not found.'], 404);
|
||||
@@ -313,7 +317,7 @@ class VultrController extends Controller
|
||||
}
|
||||
|
||||
$vultrInstance = $vultrService->createInstance($params);
|
||||
$ipAddress = $vultrService->getPublicIp($vultrInstance, $request->disable_public_ipv4, $request->enable_ipv6) ?? '0.0.0.0';
|
||||
$ipAddress = $vultrService->getPublicIp($vultrInstance, $request->disable_public_ipv4, $request->enable_ipv6) ?? Server::PLACEHOLDER_IP;
|
||||
|
||||
$server = Server::create([
|
||||
'name' => $normalizedServerName,
|
||||
|
||||
@@ -12,6 +12,7 @@ use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\Middleware\WithoutOverlapping;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
@@ -53,11 +54,13 @@ class CleanupOrphanedPreviewContainersJob implements ShouldBeEncrypted, ShouldBe
|
||||
/**
|
||||
* Get all functional servers to check for orphaned containers.
|
||||
*/
|
||||
private function getServersToCheck(): \Illuminate\Support\Collection
|
||||
private function getServersToCheck(): Collection
|
||||
{
|
||||
$query = Server::whereRelation('settings', 'is_usable', true)
|
||||
->whereRelation('settings', 'is_reachable', true)
|
||||
->where('ip', '!=', '1.2.3.4');
|
||||
->whereNotNull('ip')
|
||||
->where('ip', '!=', '')
|
||||
->whereNotIn('ip', Server::PLACEHOLDER_IPS);
|
||||
|
||||
if (isCloud()) {
|
||||
$query = $query->whereRelation('team.subscription', 'stripe_invoice_paid', true);
|
||||
@@ -99,7 +102,7 @@ class CleanupOrphanedPreviewContainersJob implements ShouldBeEncrypted, ShouldBe
|
||||
/**
|
||||
* Get all PR containers on a server (containers with pullRequestId > 0).
|
||||
*/
|
||||
private function getPRContainersOnServer(Server $server): \Illuminate\Support\Collection
|
||||
private function getPRContainersOnServer(Server $server): Collection
|
||||
{
|
||||
try {
|
||||
$output = instant_remote_process([
|
||||
|
||||
@@ -457,7 +457,9 @@ class ScheduledJobManager implements ShouldQueue
|
||||
private function getServersForCleanupQuery(): Builder
|
||||
{
|
||||
$query = Server::with('settings')
|
||||
->where('ip', '!=', '1.2.3.4');
|
||||
->whereNotNull('ip')
|
||||
->where('ip', '!=', '')
|
||||
->whereNotIn('ip', Server::PLACEHOLDER_IPS);
|
||||
|
||||
if (isCloud()) {
|
||||
$query
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Server;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\Middleware\WithoutOverlapping;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ServerCloudProviderStatusCheckJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public $tries = 1;
|
||||
|
||||
public $timeout = 120;
|
||||
|
||||
public function __construct(public Server $server)
|
||||
{
|
||||
$this->onQueue('high');
|
||||
}
|
||||
|
||||
public function middleware(): array
|
||||
{
|
||||
return [(new WithoutOverlapping('server-cloud-provider-status-'.$this->server->uuid))->expireAfter(130)->dontRelease()];
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
try {
|
||||
if (! $this->server->cloudProviderToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
match ($this->server->cloudProviderToken->provider) {
|
||||
'hetzner' => $this->server->hetzner_server_id
|
||||
? $this->server->refreshHetznerState()
|
||||
: null,
|
||||
'vultr' => $this->server->vultr_instance_id
|
||||
? $this->server->refreshVultrState()
|
||||
: null,
|
||||
'digitalocean' => $this->server->digitalocean_droplet_id
|
||||
? $this->server->refreshDigitalOceanState()
|
||||
: null,
|
||||
default => null,
|
||||
};
|
||||
} catch (\Throwable $e) {
|
||||
Log::debug('Cloud provider status check failed', [
|
||||
'server_id' => $this->server->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ use App\Events\ServerReachabilityChanged;
|
||||
use App\Helpers\SshMultiplexingHelper;
|
||||
use App\Models\Server;
|
||||
use App\Services\ConfigurationRepository;
|
||||
use App\Services\HetznerService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
@@ -42,8 +41,12 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$configRepository->disableSshMux();
|
||||
}
|
||||
|
||||
public function handle()
|
||||
public function handle(): void
|
||||
{
|
||||
if ($this->server->hasPlaceholderIp()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$wasReachable = (bool) $this->server->settings->is_reachable;
|
||||
$wasNotified = (bool) $this->server->unreachable_notification_sent;
|
||||
|
||||
@@ -62,19 +65,6 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue
|
||||
return;
|
||||
}
|
||||
|
||||
// Check Hetzner server status if applicable
|
||||
if ($this->server->hetzner_server_id && $this->server->cloudProviderToken) {
|
||||
$this->checkHetznerStatus();
|
||||
}
|
||||
|
||||
if ($this->server->vultr_instance_id && $this->server->cloudProviderToken) {
|
||||
$this->checkVultrStatus();
|
||||
}
|
||||
|
||||
if ($this->server->digitalocean_droplet_id && $this->server->cloudProviderToken) {
|
||||
$this->checkDigitalOceanStatus();
|
||||
}
|
||||
|
||||
// Temporarily disable mux if requested
|
||||
if ($this->disableMux) {
|
||||
$this->disableSshMux();
|
||||
@@ -136,17 +126,6 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue
|
||||
public function failed(?\Throwable $exception): void
|
||||
{
|
||||
if ($exception instanceof TimeoutExceededException) {
|
||||
$wasReachable = (bool) $this->server->settings->is_reachable;
|
||||
$wasNotified = (bool) $this->server->unreachable_notification_sent;
|
||||
|
||||
$this->server->settings->update([
|
||||
'is_reachable' => false,
|
||||
'is_usable' => false,
|
||||
]);
|
||||
$this->server->increment('unreachable_count');
|
||||
|
||||
$this->dispatchReachabilityChangedIfNeeded($wasReachable, $wasNotified, false);
|
||||
|
||||
// Delete the queue job so it doesn't appear in Horizon's failed list.
|
||||
$this->job?->delete();
|
||||
}
|
||||
@@ -171,63 +150,6 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue
|
||||
}
|
||||
}
|
||||
|
||||
private function checkHetznerStatus(): void
|
||||
{
|
||||
$status = null;
|
||||
|
||||
try {
|
||||
$hetznerService = new HetznerService($this->server->cloudProviderToken->token);
|
||||
$serverData = $hetznerService->getServer($this->server->hetzner_server_id);
|
||||
$status = $serverData['status'] ?? null;
|
||||
|
||||
} catch (\Throwable) {
|
||||
// Silently ignore — server may have been deleted from Hetzner.
|
||||
}
|
||||
if ($this->server->hetzner_server_status !== $status) {
|
||||
$this->server->update(['hetzner_server_status' => $status]);
|
||||
$this->server->hetzner_server_status = $status;
|
||||
if ($status === 'off') {
|
||||
throw new \Exception('Server is powered off');
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
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 checkDigitalOceanStatus(): void
|
||||
{
|
||||
try {
|
||||
$status = $this->server->refreshDigitalOceanState();
|
||||
} catch (\Throwable $e) {
|
||||
Log::debug('ServerConnectionCheck: DigitalOcean status check failed', [
|
||||
'server_id' => $this->server->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->server->digitalocean_droplet_status = $status;
|
||||
|
||||
if (in_array($status, ['off', 'archive', 'deleted'], true)) {
|
||||
throw new \Exception('DigitalOcean droplet is not running');
|
||||
}
|
||||
}
|
||||
|
||||
private function checkConnection(): bool
|
||||
{
|
||||
try {
|
||||
|
||||
@@ -55,6 +55,13 @@ class ServerManagerJob implements ShouldBeEncrypted, ShouldQueue
|
||||
// Get all servers to process
|
||||
$servers = $this->getServers();
|
||||
|
||||
// Provider state checks run independently so slow APIs cannot block SSH checks.
|
||||
$this->dispatchCloudProviderStatusChecks($servers);
|
||||
|
||||
$servers = $servers
|
||||
->reject(fn (Server $server) => $server->hasPlaceholderIp())
|
||||
->values();
|
||||
|
||||
// Dispatch ServerConnectionCheck for all servers efficiently
|
||||
$this->dispatchConnectionChecks($servers);
|
||||
|
||||
@@ -64,24 +71,45 @@ class ServerManagerJob implements ShouldBeEncrypted, ShouldQueue
|
||||
|
||||
private function getServers(): Collection
|
||||
{
|
||||
$allServers = Server::with('settings')->where('ip', '!=', '1.2.3.4');
|
||||
$allServers = Server::with(['settings', 'cloudProviderToken']);
|
||||
|
||||
if (isCloud()) {
|
||||
$servers = $allServers->whereRelation('team.subscription', 'stripe_invoice_paid', true)->get();
|
||||
$own = Team::find(0)->servers()->with('settings')->get();
|
||||
$own = Team::find(0)->servers()->with(['settings', 'cloudProviderToken'])->get();
|
||||
|
||||
return $servers->merge($own);
|
||||
return $servers->merge($own)->unique('id')->values();
|
||||
} else {
|
||||
return $allServers->get();
|
||||
}
|
||||
}
|
||||
|
||||
private function dispatchCloudProviderStatusChecks(Collection $servers): void
|
||||
{
|
||||
if (! shouldRunCronNow($this->checkFrequency, $this->instanceTimezone, 'server-cloud-provider-status-checks', $this->executionTime)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$servers->each(function (Server $server) {
|
||||
$hasCloudResource = $server->hetzner_server_id
|
||||
|| $server->vultr_instance_id
|
||||
|| $server->digitalocean_droplet_id;
|
||||
|
||||
if ($hasCloudResource && $server->cloudProviderToken) {
|
||||
ServerCloudProviderStatusCheckJob::dispatch($server);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function dispatchConnectionChecks(Collection $servers): void
|
||||
{
|
||||
|
||||
if (shouldRunCronNow($this->checkFrequency, $this->instanceTimezone, 'server-connection-checks', $this->executionTime)) {
|
||||
$servers->each(function (Server $server) {
|
||||
try {
|
||||
if ($server->hasPlaceholderIp()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip SSH connection check if Sentinel is healthy — its heartbeat already proves connectivity
|
||||
if ($server->isSentinelEnabled() && $server->isSentinelLive()) {
|
||||
return;
|
||||
|
||||
@@ -14,6 +14,7 @@ use App\Services\DigitalOceanService;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Component;
|
||||
|
||||
@@ -449,6 +450,10 @@ class ByDigitalOcean extends Component
|
||||
{
|
||||
$this->validate();
|
||||
|
||||
$digitalOceanService = null;
|
||||
$dropletId = null;
|
||||
$server = null;
|
||||
|
||||
try {
|
||||
$this->authorize('create', Server::class);
|
||||
|
||||
@@ -468,24 +473,29 @@ class ByDigitalOcean extends Component
|
||||
|
||||
$digitalOceanService = new DigitalOceanService($this->getDigitalOceanToken());
|
||||
$droplet = $this->createDigitalOceanDroplet($digitalOceanService);
|
||||
$dropletId = (int) $droplet['id'];
|
||||
|
||||
// Persist the server immediately so the droplet is always tracked
|
||||
// in Coolify, even if waiting for the public IP fails below.
|
||||
$server = Server::create([
|
||||
'name' => strtolower(trim($this->server_name)),
|
||||
'ip' => Server::PLACEHOLDER_IP,
|
||||
'user' => 'root',
|
||||
'port' => 22,
|
||||
'team_id' => currentTeam()->id,
|
||||
'private_key_id' => $this->private_key_id,
|
||||
'cloud_provider_token_id' => $this->selected_token_id,
|
||||
'digitalocean_droplet_id' => $droplet['id'],
|
||||
'digitalocean_droplet_status' => $droplet['status'] ?? null,
|
||||
]);
|
||||
$server = DB::transaction(function () use ($dropletId, $droplet): Server {
|
||||
$server = Server::create([
|
||||
'name' => strtolower(trim($this->server_name)),
|
||||
'ip' => Server::PLACEHOLDER_IP,
|
||||
'user' => 'root',
|
||||
'port' => 22,
|
||||
'team_id' => currentTeam()->id,
|
||||
'private_key_id' => $this->private_key_id,
|
||||
'cloud_provider_token_id' => $this->selected_token_id,
|
||||
'digitalocean_droplet_id' => $dropletId,
|
||||
'digitalocean_droplet_status' => $droplet['status'] ?? null,
|
||||
]);
|
||||
|
||||
$server->proxy->set('status', 'exited');
|
||||
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
|
||||
$server->save();
|
||||
$server->proxy->set('status', 'exited');
|
||||
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
|
||||
$server->save();
|
||||
|
||||
return $server;
|
||||
});
|
||||
|
||||
try {
|
||||
$droplet = $digitalOceanService->waitForPublicIp($droplet, true, $this->enable_ipv6);
|
||||
@@ -510,10 +520,25 @@ class ByDigitalOcean extends Component
|
||||
|
||||
return redirectRoute($this, 'server.show', [$server->uuid]);
|
||||
} catch (\Throwable $e) {
|
||||
$this->deleteUntrackedDroplet($digitalOceanService, $dropletId, $server);
|
||||
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
private function deleteUntrackedDroplet(?DigitalOceanService $digitalOceanService, ?int $dropletId, ?Server $server): void
|
||||
{
|
||||
if (! $digitalOceanService || ! $dropletId || $server) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$digitalOceanService->deleteDroplet($dropletId);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.server.new.by-digital-ocean');
|
||||
|
||||
@@ -611,6 +611,7 @@ class Show extends Component
|
||||
public function startVultrInstance()
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->server);
|
||||
if (! $this->server->vultr_instance_id || ! $this->server->cloudProviderToken) {
|
||||
$this->dispatch('error', 'This server is not associated with a Vultr instance or token.');
|
||||
|
||||
|
||||
+73
-26
@@ -18,6 +18,7 @@ use App\Notifications\Server\Reachable;
|
||||
use App\Notifications\Server\Unreachable;
|
||||
use App\Services\ConfigurationRepository;
|
||||
use App\Services\DigitalOceanService;
|
||||
use App\Services\HetznerService;
|
||||
use App\Services\VultrService;
|
||||
use App\Support\ValidationPatterns;
|
||||
use App\Traits\ClearsGlobalSearchCache;
|
||||
@@ -119,6 +120,8 @@ class Server extends BaseModel
|
||||
*/
|
||||
public const PLACEHOLDER_IP = '1.2.3.4';
|
||||
|
||||
public const PLACEHOLDER_IPS = [self::PLACEHOLDER_IP, '0.0.0.0', '::'];
|
||||
|
||||
public static $batch_counter = 0;
|
||||
|
||||
/**
|
||||
@@ -317,9 +320,12 @@ class Server extends BaseModel
|
||||
public function hasPlaceholderIp(): bool
|
||||
{
|
||||
// Cast: the saving hook stores the ip as a Stringable in memory.
|
||||
$ip = (string) $this->ip;
|
||||
return self::isPlaceholderIp((string) $this->ip);
|
||||
}
|
||||
|
||||
return blank($ip) || in_array($ip, [self::PLACEHOLDER_IP, '0.0.0.0', '::'], true);
|
||||
public static function isPlaceholderIp(?string $ip): bool
|
||||
{
|
||||
return blank($ip) || in_array($ip, self::PLACEHOLDER_IPS, true);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -328,13 +334,66 @@ class Server extends BaseModel
|
||||
*/
|
||||
public function backfillPlaceholderIp(?string $ip): bool
|
||||
{
|
||||
if ($ip && $this->hasPlaceholderIp()) {
|
||||
$this->update(['ip' => $ip]);
|
||||
|
||||
return true;
|
||||
if (self::isPlaceholderIp($ip)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
$updated = static::query()
|
||||
->whereKey($this->getKey())
|
||||
->where(function (Builder $query): void {
|
||||
$query->whereNull('ip')
|
||||
->orWhere('ip', '')
|
||||
->orWhereIn('ip', self::PLACEHOLDER_IPS);
|
||||
})
|
||||
->update(['ip' => $ip]);
|
||||
|
||||
if ($updated === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->forceFill(['ip' => $ip]);
|
||||
$this->syncOriginalAttribute('ip');
|
||||
static::flushIdentityMap();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist provider status without saving a stale in-memory IP value.
|
||||
*
|
||||
* @param array<string, mixed> $updates
|
||||
*/
|
||||
private function persistProviderState(array $updates): void
|
||||
{
|
||||
if (empty($updates)) {
|
||||
return;
|
||||
}
|
||||
|
||||
static::query()->whereKey($this->getKey())->update($updates);
|
||||
$this->forceFill($updates);
|
||||
$this->syncOriginalAttributes(array_keys($updates));
|
||||
static::flushIdentityMap();
|
||||
}
|
||||
|
||||
public function refreshHetznerState(): ?string
|
||||
{
|
||||
if (! $this->hetzner_server_id || ! $this->cloudProviderToken || $this->cloudProviderToken->provider !== 'hetzner') {
|
||||
return $this->hetzner_server_status;
|
||||
}
|
||||
|
||||
$hetznerService = new HetznerService($this->cloudProviderToken->token);
|
||||
$server = $hetznerService->getServer($this->hetzner_server_id);
|
||||
$status = $server['status'] ?? null;
|
||||
$assignedIp = data_get($server, 'public_net.ipv4.ip') ?? data_get($server, 'public_net.ipv6.ip');
|
||||
|
||||
$updates = [];
|
||||
if ($this->hetzner_server_status !== $status) {
|
||||
$updates['hetzner_server_status'] = $status;
|
||||
}
|
||||
$this->persistProviderState($updates);
|
||||
$this->backfillPlaceholderIp($assignedIp);
|
||||
|
||||
return $status;
|
||||
}
|
||||
|
||||
public function refreshVultrState(): ?string
|
||||
@@ -352,8 +411,7 @@ class Server extends BaseModel
|
||||
}
|
||||
|
||||
if ($this->vultr_instance_status !== 'deleted') {
|
||||
$this->update(['vultr_instance_status' => 'deleted']);
|
||||
$this->forceFill(['vultr_instance_status' => 'deleted']);
|
||||
$this->persistProviderState(['vultr_instance_status' => 'deleted']);
|
||||
}
|
||||
|
||||
return 'deleted';
|
||||
@@ -368,15 +426,8 @@ class Server extends BaseModel
|
||||
if ($this->vultr_instance_status !== $status) {
|
||||
$updates['vultr_instance_status'] = $status;
|
||||
}
|
||||
|
||||
if ($this->hasPlaceholderIp() && $publicIp) {
|
||||
$updates['ip'] = $publicIp;
|
||||
}
|
||||
|
||||
if (! empty($updates)) {
|
||||
$this->update($updates);
|
||||
$this->forceFill($updates);
|
||||
}
|
||||
$this->persistProviderState($updates);
|
||||
$this->backfillPlaceholderIp($publicIp);
|
||||
|
||||
return $status;
|
||||
}
|
||||
@@ -393,7 +444,7 @@ class Server extends BaseModel
|
||||
$droplet = $digitalOceanService->getDroplet((int) $this->digitalocean_droplet_id);
|
||||
} catch (RequestException $e) {
|
||||
if ($e->response?->status() === 404) {
|
||||
$this->update(['digitalocean_droplet_status' => 'deleted']);
|
||||
$this->persistProviderState(['digitalocean_droplet_status' => 'deleted']);
|
||||
|
||||
return 'deleted';
|
||||
}
|
||||
@@ -401,7 +452,7 @@ class Server extends BaseModel
|
||||
throw $e;
|
||||
} catch (\Throwable $e) {
|
||||
if ((int) $e->getCode() === 404) {
|
||||
$this->update(['digitalocean_droplet_status' => 'deleted']);
|
||||
$this->persistProviderState(['digitalocean_droplet_status' => 'deleted']);
|
||||
|
||||
return 'deleted';
|
||||
}
|
||||
@@ -416,12 +467,8 @@ class Server extends BaseModel
|
||||
$status = $droplet['status'] ?? null;
|
||||
$ip = $digitalOceanService->getPublicIpAddress($droplet);
|
||||
|
||||
$updates = ['digitalocean_droplet_status' => $status];
|
||||
if ($ip && $this->hasPlaceholderIp()) {
|
||||
$updates['ip'] = $ip;
|
||||
}
|
||||
|
||||
$this->update($updates);
|
||||
$this->persistProviderState(['digitalocean_droplet_status' => $status]);
|
||||
$this->backfillPlaceholderIp($ip);
|
||||
|
||||
return $status;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user