mirror of
https://github.com/tiennm99/coolify.git
synced 2026-09-05 12:16:52 +00:00
Merge remote-tracking branch 'origin/next' into s3-backup-validation
This commit is contained in:
@@ -1,295 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Server;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class CleanupStaleMultiplexedConnections implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function handle()
|
||||
{
|
||||
$this->cleanupStaleConnections();
|
||||
$this->cleanupNonExistentServerConnections();
|
||||
$this->cleanupDuplicateSshProcesses();
|
||||
$this->cleanupOrphanedSshProcesses();
|
||||
$this->cleanupOrphanedCloudflaredProcesses();
|
||||
}
|
||||
|
||||
/**
|
||||
* Once two background ssh masters share the same ControlPath, OpenSSH's
|
||||
* control socket state is no longer trustworthy: `ssh -O check` may report
|
||||
* one PID while the socket lifecycle is tied to another. Reset the whole
|
||||
* duplicate group rather than trying to choose an owner.
|
||||
*/
|
||||
private function cleanupDuplicateSshProcesses(): void
|
||||
{
|
||||
$muxDir = storage_path('app/ssh/mux');
|
||||
$groups = [];
|
||||
|
||||
foreach ($this->listProcesses() as $process) {
|
||||
$controlPath = $this->extractControlPath($process['args']);
|
||||
if (! is_string($controlPath) || ! str_starts_with($controlPath, $muxDir.'/')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$groups[$controlPath][] = $process;
|
||||
}
|
||||
|
||||
foreach ($groups as $controlPath => $processes) {
|
||||
if (count($processes) < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->resetDuplicateGroup($controlPath, $processes);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill backgrounded ssh master processes that lost the ControlPath socket
|
||||
* race. Such processes are not masters, so ControlPersist never reaps them
|
||||
* and they leak memory until the container restarts. A legitimate master
|
||||
* always owns its socket file; an orphan has none.
|
||||
*
|
||||
* Processes younger than the minimum age are skipped: a freshly forked
|
||||
* master creates its socket a few milliseconds after starting, so a young
|
||||
* process with no socket may simply be mid-establish rather than orphaned.
|
||||
*/
|
||||
private function cleanupOrphanedSshProcesses(): void
|
||||
{
|
||||
$muxDir = storage_path('app/ssh/mux');
|
||||
$minAge = (int) config('constants.ssh.mux_orphan_min_age');
|
||||
|
||||
foreach ($this->listProcesses() as $process) {
|
||||
// Only ever touch ssh processes pointing at Coolify's mux directory.
|
||||
$controlPath = $this->extractControlPath($process['args']);
|
||||
if (! is_string($controlPath) || ! str_starts_with($controlPath, $muxDir.'/')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($process['etimes'] >= $minAge && ! file_exists($controlPath)) {
|
||||
$this->reapOrphan('ssh', $process);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill orphaned `cloudflared access ssh` proxy processes. Each is spawned
|
||||
* as the SSH ProxyCommand transport for a Cloudflare Tunnel server and must
|
||||
* die with its parent ssh. When that ssh is killed or orphaned (e.g. a lost
|
||||
* mux master), the cloudflared process can leak and accumulate. A legitimate
|
||||
* proxy always has a live ssh parent; one without is safe to reap.
|
||||
*
|
||||
* Processes younger than the minimum age are skipped so a proxy whose parent
|
||||
* ssh is still starting up, or a transient `ssh -O check` proxy mid-exit, is
|
||||
* never mistaken for an orphan.
|
||||
*/
|
||||
private function cleanupOrphanedCloudflaredProcesses(): void
|
||||
{
|
||||
$minAge = (int) config('constants.ssh.mux_orphan_min_age');
|
||||
$processes = $this->listProcesses();
|
||||
|
||||
$sshPids = [];
|
||||
foreach ($processes as $process) {
|
||||
// The ssh binary itself, not `cloudflared access ssh` (space before ssh).
|
||||
if (preg_match('#(^|/)ssh\s#', $process['args'])) {
|
||||
$sshPids[$process['pid']] = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($processes as $process) {
|
||||
// `cloudflared access ssh`, never the `cloudflared tunnel` daemon.
|
||||
if (! str_contains($process['args'], 'cloudflared access ssh')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Orphaned when no live ssh process is its parent.
|
||||
if ($process['etimes'] >= $minAge && ! isset($sshPids[$process['ppid']])) {
|
||||
$this->reapOrphan('cloudflared', $process);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reap a detected orphan process. When orphan reaping is disabled (the
|
||||
* default), the orphan is only logged — a dry-run mode that lets operators
|
||||
* verify what would be killed before enabling it for real.
|
||||
*
|
||||
* @param array{pid: string, ppid: string, etimes: int, args: string} $process
|
||||
*/
|
||||
private function reapOrphan(string $kind, array $process): void
|
||||
{
|
||||
if (! config('constants.ssh.mux_orphan_reap_enabled')) {
|
||||
Log::info("Orphaned {$kind} process detected (dry-run, not killed)", [
|
||||
'pid' => $process['pid'],
|
||||
'etimes' => $process['etimes'],
|
||||
'command' => $process['args'],
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Process::run('kill '.escapeshellarg($process['pid']));
|
||||
Log::info("Killed orphaned {$kind} process", [
|
||||
'pid' => $process['pid'],
|
||||
'etimes' => $process['etimes'],
|
||||
'command' => $process['args'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot of running processes.
|
||||
*
|
||||
* @return list<array{pid: string, ppid: string, etimes: int, args: string}>
|
||||
*/
|
||||
private function listProcesses(): array
|
||||
{
|
||||
$ps = Process::run('ps -ww -eo pid=,ppid=,etimes=,args=');
|
||||
if ($ps->exitCode() !== 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$processes = [];
|
||||
foreach (explode("\n", trim($ps->output())) as $line) {
|
||||
if (! preg_match('/^\s*(\d+)\s+(\d+)\s+(\d+)\s+(.*)$/', $line, $matches)) {
|
||||
continue;
|
||||
}
|
||||
$processes[] = [
|
||||
'pid' => $matches[1],
|
||||
'ppid' => $matches[2],
|
||||
'etimes' => (int) $matches[3],
|
||||
'args' => $matches[4],
|
||||
];
|
||||
}
|
||||
|
||||
return $processes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{pid: string, ppid: string, etimes: int, args: string}> $processes
|
||||
*/
|
||||
private function resetDuplicateGroup(string $controlPath, array $processes): void
|
||||
{
|
||||
if (! config('constants.ssh.mux_orphan_reap_enabled')) {
|
||||
Log::info('Duplicate ssh mux processes detected (dry-run, not killed)', [
|
||||
'control_path' => $controlPath,
|
||||
'pids' => array_column($processes, 'pid'),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($processes as $process) {
|
||||
Process::run('kill '.escapeshellarg($process['pid']));
|
||||
}
|
||||
|
||||
if (file_exists($controlPath)) {
|
||||
@unlink($controlPath);
|
||||
}
|
||||
|
||||
Log::info('Reset duplicate ssh mux processes', [
|
||||
'control_path' => $controlPath,
|
||||
'pids' => array_column($processes, 'pid'),
|
||||
]);
|
||||
}
|
||||
|
||||
private function extractControlPath(string $args): ?string
|
||||
{
|
||||
if (! preg_match('/(?:^|\s)-o\s+ControlPath=(?:"([^"]+)"|\'([^\']+)\'|(\S+))/', $args, $matches)) {
|
||||
if (preg_match('/^ssh:\s+(\S+)\s+\[mux\]$/', $args, $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $matches[1] ?: ($matches[2] ?: $matches[3]);
|
||||
}
|
||||
|
||||
private function cleanupStaleConnections()
|
||||
{
|
||||
$muxFiles = Storage::disk('ssh-mux')->files();
|
||||
|
||||
foreach ($muxFiles as $muxFile) {
|
||||
$serverUuid = $this->extractServerUuidFromMuxFile($muxFile);
|
||||
$server = Server::where('uuid', $serverUuid)->first();
|
||||
|
||||
if (! $server) {
|
||||
$this->removeMultiplexFile($muxFile, 'server_not_found');
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$muxSocket = "/var/www/html/storage/app/ssh/mux/{$muxFile}";
|
||||
$checkCommand = "ssh -O check -o ControlPath={$muxSocket} {$server->user}@{$server->ip} 2>/dev/null";
|
||||
$checkProcess = Process::run($checkCommand);
|
||||
|
||||
if ($checkProcess->exitCode() !== 0) {
|
||||
$this->removeMultiplexFile($muxFile, 'connection_check_failed');
|
||||
} else {
|
||||
$muxContent = Storage::disk('ssh-mux')->get($muxFile);
|
||||
$establishedAt = Carbon::parse(substr($muxContent, 37));
|
||||
$expirationTime = $establishedAt->addSeconds(config('constants.ssh.mux_persist_time'));
|
||||
|
||||
if (Carbon::now()->isAfter($expirationTime)) {
|
||||
$this->removeMultiplexFile($muxFile, 'expired');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function cleanupNonExistentServerConnections()
|
||||
{
|
||||
$muxFiles = Storage::disk('ssh-mux')->files();
|
||||
$existingServerUuids = Server::pluck('uuid')->toArray();
|
||||
|
||||
foreach ($muxFiles as $muxFile) {
|
||||
$serverUuid = $this->extractServerUuidFromMuxFile($muxFile);
|
||||
if (! in_array($serverUuid, $existingServerUuids)) {
|
||||
$this->removeMultiplexFile($muxFile, 'server_does_not_exist');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function extractServerUuidFromMuxFile($muxFile)
|
||||
{
|
||||
return substr($muxFile, 4);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close and delete a stale mux socket file. When orphan reaping is disabled
|
||||
* (the default), the file is only logged — a dry-run mode that lets operators
|
||||
* verify what would be removed before enabling it for real.
|
||||
*/
|
||||
private function removeMultiplexFile(string $muxFile, string $reason): void
|
||||
{
|
||||
if (! config('constants.ssh.mux_orphan_reap_enabled')) {
|
||||
Log::info('Stale mux file detected (dry-run, not removed)', [
|
||||
'file' => $muxFile,
|
||||
'reason' => $reason,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$muxSocket = "/var/www/html/storage/app/ssh/mux/{$muxFile}";
|
||||
$closeCommand = "ssh -O exit -o ControlPath={$muxSocket} localhost 2>/dev/null";
|
||||
Process::run($closeCommand);
|
||||
Storage::disk('ssh-mux')->delete($muxFile);
|
||||
|
||||
Log::info('Removed stale mux file', [
|
||||
'file' => $muxFile,
|
||||
'reason' => $reason,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ class ProcessGithubPullRequestWebhook implements ShouldBeEncrypted, ShouldQueue
|
||||
public string $commitSha,
|
||||
public ?string $authorAssociation,
|
||||
public string $fullName,
|
||||
public bool $isForkPullRequest = false,
|
||||
) {
|
||||
$this->onQueue('high');
|
||||
}
|
||||
@@ -92,7 +93,17 @@ class ProcessGithubPullRequestWebhook implements ShouldBeEncrypted, ShouldQueue
|
||||
|
||||
// Check if PR deployments from public contributors are restricted
|
||||
if (! $application->settings->is_pr_deployments_public_enabled) {
|
||||
$trustedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR', 'CONTRIBUTOR'];
|
||||
// Fork PRs carry untrusted code from a repository outside our control.
|
||||
// GitHub's author_association cannot be trusted to gate these (it grants
|
||||
// CONTRIBUTOR to anyone who has merely opened an issue/PR before), so fork
|
||||
// PRs are never deployed automatically when public previews are off.
|
||||
if ($this->isForkPullRequest) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Same-repo (non-fork) branch PRs require push access to the base repo,
|
||||
// so only trusted associations are allowed to trigger a deployment.
|
||||
$trustedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR'];
|
||||
if (! in_array($this->authorAssociation, $trustedAssociations)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,16 @@ use App\Models\ApplicationPreview;
|
||||
use App\Models\Server;
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Models\ServiceDatabase;
|
||||
use App\Models\StandaloneClickhouse;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandaloneDragonfly;
|
||||
use App\Models\StandaloneKeydb;
|
||||
use App\Models\StandaloneMariadb;
|
||||
use App\Models\StandaloneMongodb;
|
||||
use App\Models\StandaloneMysql;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\StandaloneRedis;
|
||||
use App\Models\SwarmDocker;
|
||||
use App\Notifications\Container\ContainerRestarted;
|
||||
use App\Services\ContainerStatusAggregator;
|
||||
use App\Traits\CalculatesExcludedStatus;
|
||||
@@ -25,6 +35,7 @@ use Illuminate\Queue\Middleware\WithoutOverlapping;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Laravel\Horizon\Contracts\Silenced;
|
||||
|
||||
class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
||||
@@ -46,6 +57,18 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
||||
|
||||
public Collection $services;
|
||||
|
||||
public Collection $applicationsById;
|
||||
|
||||
public Collection $previewsByKey;
|
||||
|
||||
public Collection $databasesByUuid;
|
||||
|
||||
public Collection $servicesById;
|
||||
|
||||
public Collection $serviceApplicationsById;
|
||||
|
||||
public Collection $serviceDatabasesById;
|
||||
|
||||
public Collection $allApplicationIds;
|
||||
|
||||
public Collection $allDatabaseUuids;
|
||||
@@ -78,6 +101,8 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
||||
|
||||
public bool $foundLogDrainContainer = false;
|
||||
|
||||
private ?array $cachedDestinationIds = null;
|
||||
|
||||
public function middleware(): array
|
||||
{
|
||||
return [(new WithoutOverlapping('push-server-update-'.$this->server->uuid))->expireAfter(30)->dontRelease()];
|
||||
@@ -103,6 +128,12 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
||||
$this->allTcpProxyUuids = collect();
|
||||
$this->allServiceApplicationIds = collect();
|
||||
$this->allServiceDatabaseIds = collect();
|
||||
$this->applicationsById = collect();
|
||||
$this->previewsByKey = collect();
|
||||
$this->databasesByUuid = collect();
|
||||
$this->servicesById = collect();
|
||||
$this->serviceApplicationsById = collect();
|
||||
$this->serviceDatabasesById = collect();
|
||||
}
|
||||
|
||||
public function handle()
|
||||
@@ -120,6 +151,16 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
||||
$this->allTcpProxyUuids ??= collect();
|
||||
$this->allServiceApplicationIds ??= collect();
|
||||
$this->allServiceDatabaseIds ??= collect();
|
||||
$this->applicationsById ??= collect();
|
||||
$this->previewsByKey ??= collect();
|
||||
$this->databasesByUuid ??= collect();
|
||||
$this->servicesById ??= collect();
|
||||
$this->serviceApplicationsById ??= collect();
|
||||
$this->serviceDatabasesById ??= collect();
|
||||
|
||||
// Eager-load relations the job touches repeatedly to avoid lazy-load queries
|
||||
// (settings: disk threshold, isProxyShouldRun, isLogDrainEnabled; team: notifications).
|
||||
$this->server->loadMissing(['settings', 'team']);
|
||||
|
||||
// TODO: Swarm is not supported yet
|
||||
if (! $this->data) {
|
||||
@@ -143,19 +184,24 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
||||
&& (string) $lastPercentage !== (string) $filesystemUsageRoot) {
|
||||
Cache::put($storageCacheKey, $filesystemUsageRoot, 600);
|
||||
ServerStorageCheckJob::dispatch($this->server, $filesystemUsageRoot);
|
||||
} elseif ($filesystemUsageRoot !== null && $filesystemUsageRoot < $diskThreshold) {
|
||||
Cache::forget($storageCacheKey);
|
||||
}
|
||||
|
||||
if ($this->containers->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->applications = $this->server->applications();
|
||||
$this->databases = $this->server->databases();
|
||||
$this->previews = $this->server->previews();
|
||||
// Eager load service applications and databases to avoid N+1 queries
|
||||
$this->services = $this->server->services()
|
||||
->with(['applications:id,service_id', 'databases:id,service_id'])
|
||||
->get();
|
||||
$this->applications = $this->loadApplications();
|
||||
$this->databases = $this->loadDatabases();
|
||||
$this->previews = $this->loadPreviews();
|
||||
$this->services = $this->loadServices();
|
||||
$this->applicationsById = $this->applications->keyBy(fn ($application) => (string) $application->id);
|
||||
$this->previewsByKey = $this->previews->keyBy(fn ($preview) => $preview->application_id.':'.$preview->pull_request_id);
|
||||
$this->databasesByUuid = $this->databases->keyBy('uuid');
|
||||
$this->servicesById = $this->services->keyBy(fn ($service) => (string) $service->id);
|
||||
$this->serviceApplicationsById = $this->services->flatMap(fn ($service) => $service->applications)->keyBy(fn ($application) => (string) $application->id);
|
||||
$this->serviceDatabasesById = $this->services->flatMap(fn ($service) => $service->databases)->keyBy(fn ($database) => (string) $database->id);
|
||||
|
||||
$this->allApplicationIds = $this->applications->filter(function ($application) {
|
||||
return $application->additional_servers_count === 0;
|
||||
@@ -168,9 +214,8 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
||||
});
|
||||
$this->allDatabaseUuids = $this->databases->pluck('uuid');
|
||||
$this->allTcpProxyUuids = $this->databases->where('is_public', true)->pluck('uuid');
|
||||
// Use eager-loaded relationships instead of querying in loop
|
||||
$this->allServiceApplicationIds = $this->services->flatMap(fn ($service) => $service->applications->pluck('id'));
|
||||
$this->allServiceDatabaseIds = $this->services->flatMap(fn ($service) => $service->databases->pluck('id'));
|
||||
$this->allServiceApplicationIds = $this->serviceApplicationsById->keys();
|
||||
$this->allServiceDatabaseIds = $this->serviceDatabasesById->keys();
|
||||
|
||||
foreach ($this->containers as $container) {
|
||||
$containerStatus = data_get($container, 'state', 'exited');
|
||||
@@ -284,6 +329,151 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
||||
$this->checkLogDrainContainer();
|
||||
}
|
||||
|
||||
private function loadApplications(): Collection
|
||||
{
|
||||
[$standaloneDockerIds, $swarmDockerIds] = $this->serverDestinationIds();
|
||||
|
||||
$applications = ($standaloneDockerIds->isNotEmpty() || $swarmDockerIds->isNotEmpty())
|
||||
? Application::withoutGlobalScope('withRelations')
|
||||
->select([
|
||||
'id',
|
||||
'uuid',
|
||||
'name',
|
||||
'status',
|
||||
'build_pack',
|
||||
'docker_compose_raw',
|
||||
'destination_id',
|
||||
'destination_type',
|
||||
'last_online_at',
|
||||
])
|
||||
->withCount('additional_servers')
|
||||
->where(fn ($query) => $this->scopeDestination($query, $standaloneDockerIds, $swarmDockerIds))
|
||||
->get()
|
||||
: collect();
|
||||
|
||||
$additionalApplicationIds = DB::table('additional_destinations')
|
||||
->where('server_id', $this->server->id)
|
||||
->pluck('application_id');
|
||||
|
||||
if ($additionalApplicationIds->isNotEmpty()) {
|
||||
$applications = $applications->concat(
|
||||
Application::withoutGlobalScope('withRelations')
|
||||
->select([
|
||||
'id',
|
||||
'uuid',
|
||||
'name',
|
||||
'status',
|
||||
'build_pack',
|
||||
'docker_compose_raw',
|
||||
'destination_id',
|
||||
'destination_type',
|
||||
'last_online_at',
|
||||
])
|
||||
->withCount('additional_servers')
|
||||
->whereIn('id', $additionalApplicationIds)
|
||||
->get()
|
||||
);
|
||||
}
|
||||
|
||||
return $applications->unique('id')->values();
|
||||
}
|
||||
|
||||
private function loadPreviews(): Collection
|
||||
{
|
||||
$applicationIds = $this->applications->pluck('id');
|
||||
|
||||
if ($applicationIds->isEmpty()) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return ApplicationPreview::query()
|
||||
->select([
|
||||
'id',
|
||||
'application_id',
|
||||
'pull_request_id',
|
||||
'status',
|
||||
'last_online_at',
|
||||
])
|
||||
->whereIn('application_id', $applicationIds)
|
||||
->get();
|
||||
}
|
||||
|
||||
private function loadServices(): Collection
|
||||
{
|
||||
return $this->server->services()
|
||||
->select([
|
||||
'id',
|
||||
'server_id',
|
||||
'uuid',
|
||||
'docker_compose_raw',
|
||||
])
|
||||
->with([
|
||||
'applications:id,service_id,status,last_online_at',
|
||||
'databases:id,service_id,status,last_online_at,is_public,name',
|
||||
])
|
||||
->get();
|
||||
}
|
||||
|
||||
private function loadDatabases(): Collection
|
||||
{
|
||||
[$standaloneDockerIds, $swarmDockerIds] = $this->serverDestinationIds();
|
||||
if ($standaloneDockerIds->isEmpty() && $swarmDockerIds->isEmpty()) {
|
||||
return collect();
|
||||
}
|
||||
$databaseColumns = [
|
||||
'id',
|
||||
'uuid',
|
||||
'name',
|
||||
'status',
|
||||
'is_public',
|
||||
'destination_id',
|
||||
'destination_type',
|
||||
'last_online_at',
|
||||
'restart_count',
|
||||
'last_restart_at',
|
||||
'last_restart_type',
|
||||
];
|
||||
|
||||
return collect([
|
||||
StandalonePostgresql::class,
|
||||
StandaloneRedis::class,
|
||||
StandaloneMongodb::class,
|
||||
StandaloneMysql::class,
|
||||
StandaloneMariadb::class,
|
||||
StandaloneKeydb::class,
|
||||
StandaloneDragonfly::class,
|
||||
StandaloneClickhouse::class,
|
||||
])->flatMap(function (string $databaseClass) use ($databaseColumns, $standaloneDockerIds, $swarmDockerIds) {
|
||||
return $databaseClass::query()
|
||||
->select($databaseColumns)
|
||||
->where(fn ($query) => $this->scopeDestination($query, $standaloneDockerIds, $swarmDockerIds))
|
||||
->get();
|
||||
})->filter(fn ($database) => data_get($database, 'name') !== 'coolify-db')->values();
|
||||
}
|
||||
|
||||
private function serverDestinationIds(): array
|
||||
{
|
||||
if ($this->cachedDestinationIds !== null) {
|
||||
return $this->cachedDestinationIds;
|
||||
}
|
||||
|
||||
return $this->cachedDestinationIds = [
|
||||
StandaloneDocker::where('server_id', $this->server->id)->pluck('id'),
|
||||
SwarmDocker::where('server_id', $this->server->id)->pluck('id'),
|
||||
];
|
||||
}
|
||||
|
||||
private function scopeDestination($query, Collection $standaloneDockerIds, Collection $swarmDockerIds): void
|
||||
{
|
||||
$query->where(function ($query) use ($standaloneDockerIds) {
|
||||
$query->where('destination_type', StandaloneDocker::class)
|
||||
->whereIn('destination_id', $standaloneDockerIds);
|
||||
})->orWhere(function ($query) use ($swarmDockerIds) {
|
||||
$query->where('destination_type', SwarmDocker::class)
|
||||
->whereIn('destination_id', $swarmDockerIds);
|
||||
});
|
||||
}
|
||||
|
||||
private function aggregateMultiContainerStatuses()
|
||||
{
|
||||
if ($this->applicationContainerStatuses->isEmpty()) {
|
||||
@@ -291,7 +481,7 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
||||
}
|
||||
|
||||
foreach ($this->applicationContainerStatuses as $applicationId => $containerStatuses) {
|
||||
$application = $this->applications->where('id', $applicationId)->first();
|
||||
$application = $this->applicationsById->get((string) $applicationId);
|
||||
if (! $application) {
|
||||
continue;
|
||||
}
|
||||
@@ -312,8 +502,6 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
||||
if ($aggregatedStatus && $application->status !== $aggregatedStatus) {
|
||||
$application->status = $aggregatedStatus;
|
||||
$application->save();
|
||||
} elseif ($aggregatedStatus) {
|
||||
$application->update(['last_online_at' => now()]);
|
||||
}
|
||||
|
||||
continue;
|
||||
@@ -328,8 +516,6 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
||||
if ($aggregatedStatus && $application->status !== $aggregatedStatus) {
|
||||
$application->status = $aggregatedStatus;
|
||||
$application->save();
|
||||
} elseif ($aggregatedStatus) {
|
||||
$application->update(['last_online_at' => now()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -348,7 +534,7 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
||||
continue;
|
||||
}
|
||||
|
||||
$service = $this->services->where('id', $serviceId)->first();
|
||||
$service = $this->servicesById->get((string) $serviceId);
|
||||
if (! $service) {
|
||||
continue;
|
||||
}
|
||||
@@ -356,9 +542,9 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
||||
// Get the service sub-resource (ServiceApplication or ServiceDatabase)
|
||||
$subResource = null;
|
||||
if ($subType === 'application') {
|
||||
$subResource = $service->applications->where('id', $subId)->first();
|
||||
$subResource = $this->serviceApplicationsById->get((string) $subId);
|
||||
} elseif ($subType === 'database') {
|
||||
$subResource = $service->databases->where('id', $subId)->first();
|
||||
$subResource = $this->serviceDatabasesById->get((string) $subId);
|
||||
}
|
||||
|
||||
if (! $subResource) {
|
||||
@@ -380,8 +566,6 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
||||
if ($aggregatedStatus && $subResource->status !== $aggregatedStatus) {
|
||||
$subResource->status = $aggregatedStatus;
|
||||
$subResource->save();
|
||||
} elseif ($aggregatedStatus) {
|
||||
$subResource->update(['last_online_at' => now()]);
|
||||
}
|
||||
|
||||
continue;
|
||||
@@ -397,39 +581,31 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
||||
if ($aggregatedStatus && $subResource->status !== $aggregatedStatus) {
|
||||
$subResource->status = $aggregatedStatus;
|
||||
$subResource->save();
|
||||
} elseif ($aggregatedStatus) {
|
||||
$subResource->update(['last_online_at' => now()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function updateApplicationStatus(string $applicationId, string $containerStatus)
|
||||
{
|
||||
$application = $this->applications->where('id', $applicationId)->first();
|
||||
$application = $this->applicationsById->get((string) $applicationId);
|
||||
if (! $application) {
|
||||
return;
|
||||
}
|
||||
if ($application->status !== $containerStatus) {
|
||||
$application->status = $containerStatus;
|
||||
$application->save();
|
||||
} else {
|
||||
$application->update(['last_online_at' => now()]);
|
||||
}
|
||||
}
|
||||
|
||||
private function updateApplicationPreviewStatus(string $applicationId, string $pullRequestId, string $containerStatus)
|
||||
{
|
||||
$application = $this->previews->where('application_id', $applicationId)
|
||||
->where('pull_request_id', $pullRequestId)
|
||||
->first();
|
||||
$application = $this->previewsByKey->get($applicationId.':'.$pullRequestId);
|
||||
if (! $application) {
|
||||
return;
|
||||
}
|
||||
if ($application->status !== $containerStatus) {
|
||||
$application->status = $containerStatus;
|
||||
$application->save();
|
||||
} else {
|
||||
$application->update(['last_online_at' => now()]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -477,9 +653,7 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
||||
$applicationId = $parts[0];
|
||||
$pullRequestId = $parts[1];
|
||||
|
||||
$applicationPreview = $this->previews->where('application_id', $applicationId)
|
||||
->where('pull_request_id', $pullRequestId)
|
||||
->first();
|
||||
$applicationPreview = $this->previewsByKey->get($applicationId.':'.$pullRequestId);
|
||||
|
||||
if ($applicationPreview && ! str($applicationPreview->status)->startsWith('exited')) {
|
||||
$previewIdsToUpdate->push($applicationPreview->id);
|
||||
@@ -518,15 +692,13 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
||||
|
||||
private function updateDatabaseStatus(string $databaseUuid, string $containerStatus, bool $tcpProxy = false)
|
||||
{
|
||||
$database = $this->databases->where('uuid', $databaseUuid)->first();
|
||||
$database = $this->databasesByUuid->get($databaseUuid);
|
||||
if (! $database) {
|
||||
return;
|
||||
}
|
||||
if ($database->status !== $containerStatus) {
|
||||
$database->status = $containerStatus;
|
||||
$database->save();
|
||||
} else {
|
||||
$database->update(['last_online_at' => now()]);
|
||||
}
|
||||
if ($this->isRunning($containerStatus) && $tcpProxy) {
|
||||
$tcpProxyContainerFound = $this->containers->filter(function ($value, $key) use ($databaseUuid) {
|
||||
@@ -561,7 +733,7 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
||||
}
|
||||
|
||||
$notFoundDatabaseUuids->each(function ($databaseUuid) {
|
||||
$database = $this->databases->where('uuid', $databaseUuid)->first();
|
||||
$database = $this->databasesByUuid->get($databaseUuid);
|
||||
if ($database) {
|
||||
if (! str($database->status)->startsWith('exited')) {
|
||||
$database->update([
|
||||
|
||||
+291
-146
@@ -6,14 +6,15 @@ use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\ScheduledTask;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use Cron\CronExpression;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\Middleware\WithoutOverlapping;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
@@ -22,6 +23,8 @@ class ScheduledJobManager implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
private const CHUNK_SIZE = 100;
|
||||
|
||||
/**
|
||||
* The time when this job execution started.
|
||||
* Used to ensure all scheduled items are evaluated against the same point in time.
|
||||
@@ -96,21 +99,11 @@ class ScheduledJobManager implements ShouldQueue
|
||||
'execution_time' => $this->executionTime->toIso8601String(),
|
||||
]);
|
||||
|
||||
// Process backups - don't let failures stop task processing
|
||||
// Process scheduled backups and tasks together so neither type starves the other.
|
||||
try {
|
||||
$this->processScheduledBackups();
|
||||
$this->processScheduledBackupsAndTasks();
|
||||
} catch (\Exception $e) {
|
||||
Log::channel('scheduled-errors')->error('Failed to process scheduled backups', [
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
}
|
||||
|
||||
// Process tasks - don't let failures stop the job manager
|
||||
try {
|
||||
$this->processScheduledTasks();
|
||||
} catch (\Exception $e) {
|
||||
Log::channel('scheduled-errors')->error('Failed to process scheduled tasks', [
|
||||
Log::channel('scheduled-errors')->error('Failed to process scheduled backups and tasks', [
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
@@ -141,125 +134,211 @@ class ScheduledJobManager implements ShouldQueue
|
||||
}
|
||||
}
|
||||
|
||||
private function processScheduledBackups(): void
|
||||
private function processScheduledBackupsAndTasks(): void
|
||||
{
|
||||
$backups = ScheduledDatabaseBackup::with(['database'])
|
||||
$lastBackupId = 0;
|
||||
$lastTaskId = 0;
|
||||
|
||||
do {
|
||||
$backups = $this->scheduledBackupQuery($lastBackupId)->get();
|
||||
$tasks = $this->scheduledTaskQuery($lastTaskId)->get();
|
||||
|
||||
if ($backups->isNotEmpty()) {
|
||||
$lastBackupId = $backups->last()->id;
|
||||
}
|
||||
|
||||
if ($tasks->isNotEmpty()) {
|
||||
$lastTaskId = $tasks->last()->id;
|
||||
}
|
||||
|
||||
$this->processInterleavedDueSchedules(
|
||||
$this->dueScheduledBackups($backups),
|
||||
$this->dueScheduledTasks($tasks),
|
||||
);
|
||||
} while ($backups->isNotEmpty() || $tasks->isNotEmpty());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{backup: ScheduledDatabaseBackup, server: Server}> $dueBackups
|
||||
* @param array<int, array{task: ScheduledTask, server: Server}> $dueTasks
|
||||
*/
|
||||
private function processInterleavedDueSchedules(array $dueBackups, array $dueTasks): void
|
||||
{
|
||||
$maxCount = max(count($dueBackups), count($dueTasks));
|
||||
|
||||
for ($index = 0; $index < $maxCount; $index++) {
|
||||
if (isset($dueBackups[$index])) {
|
||||
$this->processScheduledBackup($dueBackups[$index]['backup'], $dueBackups[$index]['server']);
|
||||
}
|
||||
|
||||
if (isset($dueTasks[$index])) {
|
||||
$this->processScheduledTask($dueTasks[$index]['task'], $dueTasks[$index]['server']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function scheduledBackupQuery(int $lastBackupId): Builder
|
||||
{
|
||||
return ScheduledDatabaseBackup::with(['database', 'team.subscription'])
|
||||
->where('enabled', true)
|
||||
->get();
|
||||
->where('id', '>', $lastBackupId)
|
||||
->orderBy('id')
|
||||
->limit(self::CHUNK_SIZE);
|
||||
}
|
||||
|
||||
private function scheduledTaskQuery(int $lastTaskId): Builder
|
||||
{
|
||||
return ScheduledTask::with([
|
||||
'service.destination.server.settings',
|
||||
'service.destination.server.team.subscription',
|
||||
'application.destination.server.settings',
|
||||
'application.destination.server.team.subscription',
|
||||
])
|
||||
->where('enabled', true)
|
||||
->where('id', '>', $lastTaskId)
|
||||
->orderBy('id')
|
||||
->limit(self::CHUNK_SIZE);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param iterable<ScheduledDatabaseBackup> $backups
|
||||
* @return array<int, array{backup: ScheduledDatabaseBackup, server: Server}>
|
||||
*/
|
||||
private function dueScheduledBackups(iterable $backups): array
|
||||
{
|
||||
$dueBackups = [];
|
||||
|
||||
foreach ($backups as $backup) {
|
||||
try {
|
||||
$server = $backup->server();
|
||||
$skipReason = $this->getBackupSkipReason($backup, $server);
|
||||
if ($skipReason !== null) {
|
||||
$this->skippedCount++;
|
||||
$this->logSkip('backup', $skipReason, [
|
||||
'backup_id' => $backup->id,
|
||||
'database_id' => $backup->database_id,
|
||||
'database_type' => $backup->database_type,
|
||||
'team_id' => $backup->team_id ?? null,
|
||||
]);
|
||||
|
||||
if (blank(data_get($backup, 'database')) || blank($server)) {
|
||||
$this->processScheduledBackup($backup, $server);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$serverTimezone = data_get($server->settings, 'server_timezone', config('app.timezone'));
|
||||
|
||||
if (validate_timezone($serverTimezone) === false) {
|
||||
$serverTimezone = config('app.timezone');
|
||||
}
|
||||
|
||||
$frequency = $backup->frequency;
|
||||
if (isset(VALID_CRON_STRINGS[$frequency])) {
|
||||
$frequency = VALID_CRON_STRINGS[$frequency];
|
||||
}
|
||||
|
||||
if (shouldRunCronNow($frequency, $serverTimezone, "scheduled-backup:{$backup->id}", $this->executionTime)) {
|
||||
DatabaseBackupJob::dispatch($backup);
|
||||
$this->dispatchedCount++;
|
||||
Log::channel('scheduled')->info('Backup dispatched', [
|
||||
'backup_id' => $backup->id,
|
||||
'database_id' => $backup->database_id,
|
||||
'database_type' => $backup->database_type,
|
||||
'team_id' => $backup->team_id ?? null,
|
||||
'server_id' => $server->id,
|
||||
]);
|
||||
if ($this->isDueCandidateBeforeExpensiveChecks($backup->frequency, $server, "scheduled-backup:{$backup->id}")) {
|
||||
$dueBackups[] = [
|
||||
'backup' => $backup,
|
||||
'server' => $server,
|
||||
];
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::channel('scheduled-errors')->error('Error processing backup', [
|
||||
Log::channel('scheduled-errors')->error('Error prechecking backup', [
|
||||
'backup_id' => $backup->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $dueBackups;
|
||||
}
|
||||
|
||||
private function processScheduledTasks(): void
|
||||
/**
|
||||
* @param iterable<ScheduledTask> $tasks
|
||||
* @return array<int, array{task: ScheduledTask, server: Server}>
|
||||
*/
|
||||
private function dueScheduledTasks(iterable $tasks): array
|
||||
{
|
||||
$tasks = ScheduledTask::with(['service', 'application'])
|
||||
->where('enabled', true)
|
||||
->get();
|
||||
$dueTasks = [];
|
||||
|
||||
foreach ($tasks as $task) {
|
||||
try {
|
||||
$server = $task->server();
|
||||
|
||||
// Phase 1: Critical checks (always — cheap, handles orphans and infra issues)
|
||||
$criticalSkip = $this->getTaskCriticalSkipReason($task, $server);
|
||||
if ($criticalSkip !== null) {
|
||||
$this->skippedCount++;
|
||||
$this->logSkip('task', $criticalSkip, [
|
||||
'task_id' => $task->id,
|
||||
'task_name' => $task->name,
|
||||
'team_id' => $server?->team_id,
|
||||
]);
|
||||
if (blank($server) || (! $task->service && ! $task->application)) {
|
||||
$this->processScheduledTask($task, $server);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$serverTimezone = data_get($server->settings, 'server_timezone', config('app.timezone'));
|
||||
|
||||
if (validate_timezone($serverTimezone) === false) {
|
||||
$serverTimezone = config('app.timezone');
|
||||
if ($this->isDueCandidateBeforeExpensiveChecks($task->frequency, $server, "scheduled-task:{$task->id}")) {
|
||||
$dueTasks[] = [
|
||||
'task' => $task,
|
||||
'server' => $server,
|
||||
];
|
||||
}
|
||||
|
||||
$frequency = $task->frequency;
|
||||
if (isset(VALID_CRON_STRINGS[$frequency])) {
|
||||
$frequency = VALID_CRON_STRINGS[$frequency];
|
||||
}
|
||||
|
||||
if (! shouldRunCronNow($frequency, $serverTimezone, "scheduled-task:{$task->id}", $this->executionTime)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Phase 2: Runtime checks (only when cron is due — avoids noise for stopped resources)
|
||||
$runtimeSkip = $this->getTaskRuntimeSkipReason($task);
|
||||
if ($runtimeSkip !== null) {
|
||||
$this->skippedCount++;
|
||||
$this->logSkip('task', $runtimeSkip, [
|
||||
'task_id' => $task->id,
|
||||
'task_name' => $task->name,
|
||||
'team_id' => $server->team_id,
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
ScheduledTaskJob::dispatch($task);
|
||||
$this->dispatchedCount++;
|
||||
Log::channel('scheduled')->info('Task dispatched', [
|
||||
'task_id' => $task->id,
|
||||
'task_name' => $task->name,
|
||||
'team_id' => $server->team_id,
|
||||
'server_id' => $server->id,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
Log::channel('scheduled-errors')->error('Error processing task', [
|
||||
Log::channel('scheduled-errors')->error('Error prechecking task', [
|
||||
'task_id' => $task->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $dueTasks;
|
||||
}
|
||||
|
||||
private function processScheduledBackup(ScheduledDatabaseBackup $backup, ?Server $precheckedServer = null): void
|
||||
{
|
||||
try {
|
||||
$server = $precheckedServer ?? $backup->server();
|
||||
$skipReason = $this->getBackupSkipReason($backup, $server);
|
||||
if ($skipReason !== null) {
|
||||
$this->skippedCount++;
|
||||
$this->logBackupSkip($backup, $skipReason);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->shouldDispatch($backup->frequency, $server, "scheduled-backup:{$backup->id}")) {
|
||||
DatabaseBackupJob::dispatch($backup);
|
||||
$this->dispatchedCount++;
|
||||
Log::channel('scheduled')->info('Backup dispatched', [
|
||||
'backup_id' => $backup->id,
|
||||
'database_id' => $backup->database_id,
|
||||
'database_type' => $backup->database_type,
|
||||
'team_id' => $backup->team_id ?? null,
|
||||
'server_id' => $server->id,
|
||||
]);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::channel('scheduled-errors')->error('Error processing backup', [
|
||||
'backup_id' => $backup->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function processScheduledTask(ScheduledTask $task, ?Server $precheckedServer = null): void
|
||||
{
|
||||
try {
|
||||
$server = $precheckedServer ?? $task->server();
|
||||
$criticalSkip = $this->getTaskCriticalSkipReason($task, $server);
|
||||
if ($criticalSkip !== null) {
|
||||
$this->skippedCount++;
|
||||
$this->logTaskSkip($task, $criticalSkip, $server);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->shouldDispatch($task->frequency, $server, "scheduled-task:{$task->id}")) {
|
||||
return;
|
||||
}
|
||||
|
||||
$runtimeSkip = $this->getTaskRuntimeSkipReason($task);
|
||||
if ($runtimeSkip !== null) {
|
||||
$this->skippedCount++;
|
||||
$this->logTaskSkip($task, $runtimeSkip, $server);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
ScheduledTaskJob::dispatch($task);
|
||||
$this->dispatchedCount++;
|
||||
Log::channel('scheduled')->info('Task dispatched', [
|
||||
'task_id' => $task->id,
|
||||
'task_name' => $task->name,
|
||||
'team_id' => $server->team_id,
|
||||
'server_id' => $server->id,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
Log::channel('scheduled-errors')->error('Error processing task', [
|
||||
'task_id' => $task->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function getBackupSkipReason(ScheduledDatabaseBackup $backup, ?Server $server): ?string
|
||||
@@ -327,71 +406,70 @@ class ScheduledJobManager implements ShouldQueue
|
||||
|
||||
private function processDockerCleanups(): void
|
||||
{
|
||||
// Get all servers that need cleanup checks
|
||||
$servers = $this->getServersForCleanup();
|
||||
|
||||
foreach ($servers as $server) {
|
||||
try {
|
||||
$skipReason = $this->getDockerCleanupSkipReason($server);
|
||||
if ($skipReason !== null) {
|
||||
$this->skippedCount++;
|
||||
$this->logSkip('docker_cleanup', $skipReason, [
|
||||
'server_id' => $server->id,
|
||||
'server_name' => $server->name,
|
||||
'team_id' => $server->team_id,
|
||||
]);
|
||||
|
||||
continue;
|
||||
$this->getServersForCleanupQuery()
|
||||
->chunkById(self::CHUNK_SIZE, function ($servers): void {
|
||||
foreach ($servers as $server) {
|
||||
$this->processDockerCleanup($server);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$serverTimezone = data_get($server->settings, 'server_timezone', config('app.timezone'));
|
||||
if (validate_timezone($serverTimezone) === false) {
|
||||
$serverTimezone = config('app.timezone');
|
||||
}
|
||||
|
||||
$frequency = data_get($server->settings, 'docker_cleanup_frequency', '0 * * * *');
|
||||
if (isset(VALID_CRON_STRINGS[$frequency])) {
|
||||
$frequency = VALID_CRON_STRINGS[$frequency];
|
||||
}
|
||||
|
||||
// Use the frozen execution time for consistent evaluation
|
||||
if (shouldRunCronNow($frequency, $serverTimezone, "docker-cleanup:{$server->id}", $this->executionTime)) {
|
||||
DockerCleanupJob::dispatch(
|
||||
$server,
|
||||
false,
|
||||
$server->settings->delete_unused_volumes,
|
||||
$server->settings->delete_unused_networks
|
||||
);
|
||||
$this->dispatchedCount++;
|
||||
Log::channel('scheduled')->info('Docker cleanup dispatched', [
|
||||
'server_id' => $server->id,
|
||||
'server_name' => $server->name,
|
||||
'team_id' => $server->team_id,
|
||||
]);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::channel('scheduled-errors')->error('Error processing docker cleanup', [
|
||||
private function processDockerCleanup(Server $server): void
|
||||
{
|
||||
try {
|
||||
$skipReason = $this->getDockerCleanupSkipReason($server);
|
||||
if ($skipReason !== null) {
|
||||
$this->skippedCount++;
|
||||
$this->logSkip('docker_cleanup', $skipReason, [
|
||||
'server_id' => $server->id,
|
||||
'server_name' => $server->name,
|
||||
'error' => $e->getMessage(),
|
||||
'team_id' => $server->team_id,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$frequency = data_get($server->settings, 'docker_cleanup_frequency', '0 * * * *');
|
||||
|
||||
if ($this->shouldDispatch($frequency, $server, "docker-cleanup:{$server->id}")) {
|
||||
DockerCleanupJob::dispatch(
|
||||
$server,
|
||||
false,
|
||||
$server->settings->delete_unused_volumes,
|
||||
$server->settings->delete_unused_networks
|
||||
);
|
||||
$this->dispatchedCount++;
|
||||
Log::channel('scheduled')->info('Docker cleanup dispatched', [
|
||||
'server_id' => $server->id,
|
||||
'server_name' => $server->name,
|
||||
'team_id' => $server->team_id,
|
||||
]);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::channel('scheduled-errors')->error('Error processing docker cleanup', [
|
||||
'server_id' => $server->id,
|
||||
'server_name' => $server->name,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function getServersForCleanup(): Collection
|
||||
private function getServersForCleanupQuery(): Builder
|
||||
{
|
||||
$query = Server::with('settings')
|
||||
->where('ip', '!=', '1.2.3.4');
|
||||
|
||||
if (isCloud()) {
|
||||
$servers = $query->whereRelation('team.subscription', 'stripe_invoice_paid', true)->get();
|
||||
$own = Team::find(0)->servers()->with('settings')->get();
|
||||
|
||||
return $servers->merge($own);
|
||||
$query
|
||||
->with('team.subscription')
|
||||
->where(function (Builder $query): void {
|
||||
$query
|
||||
->where('team_id', 0)
|
||||
->orWhereRelation('team.subscription', 'stripe_invoice_paid', true);
|
||||
});
|
||||
}
|
||||
|
||||
return $query->get();
|
||||
return $query;
|
||||
}
|
||||
|
||||
private function getDockerCleanupSkipReason(Server $server): ?string
|
||||
@@ -418,4 +496,71 @@ class ScheduledJobManager implements ShouldQueue
|
||||
'execution_time' => $this->executionTime?->toIso8601String(),
|
||||
], $context));
|
||||
}
|
||||
|
||||
private function shouldDispatch(string $frequency, Server $server, string $dedupKey): bool
|
||||
{
|
||||
return shouldRunCronNow(
|
||||
$this->normalizeFrequency($frequency),
|
||||
$this->serverTimezone($server),
|
||||
$dedupKey,
|
||||
$this->executionTime,
|
||||
);
|
||||
}
|
||||
|
||||
private function isDueCandidateBeforeExpensiveChecks(string $frequency, Server $server, string $dedupKey): bool
|
||||
{
|
||||
$cron = new CronExpression($this->normalizeFrequency($frequency));
|
||||
$executionTime = ($this->executionTime ?? Carbon::now())->copy()->setTimezone($this->serverTimezone($server));
|
||||
$lastDispatched = Cache::get($dedupKey);
|
||||
$previousDue = Carbon::instance($cron->getPreviousRunDate($executionTime, allowCurrentDate: true));
|
||||
|
||||
if ($lastDispatched === null) {
|
||||
$isDue = $cron->isDue($executionTime);
|
||||
|
||||
if (! $isDue) {
|
||||
Cache::put($dedupKey, $previousDue->toIso8601String(), 2592000);
|
||||
}
|
||||
|
||||
return $isDue;
|
||||
}
|
||||
|
||||
$shouldFire = $previousDue->gt(Carbon::parse($lastDispatched));
|
||||
|
||||
if (! $shouldFire) {
|
||||
Cache::put($dedupKey, $previousDue->toIso8601String(), 2592000);
|
||||
}
|
||||
|
||||
return $shouldFire;
|
||||
}
|
||||
|
||||
private function normalizeFrequency(string $frequency): string
|
||||
{
|
||||
return VALID_CRON_STRINGS[$frequency] ?? $frequency;
|
||||
}
|
||||
|
||||
private function serverTimezone(Server $server): string
|
||||
{
|
||||
$timezone = data_get($server->settings, 'server_timezone', config('app.timezone'));
|
||||
|
||||
return validate_timezone($timezone) ? $timezone : config('app.timezone');
|
||||
}
|
||||
|
||||
private function logBackupSkip(ScheduledDatabaseBackup $backup, string $reason): void
|
||||
{
|
||||
$this->logSkip('backup', $reason, [
|
||||
'backup_id' => $backup->id,
|
||||
'database_id' => $backup->database_id,
|
||||
'database_type' => $backup->database_type,
|
||||
'team_id' => $backup->team_id ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
private function logTaskSkip(ScheduledTask $task, string $reason, ?Server $server): void
|
||||
{
|
||||
$this->logSkip('task', $reason, [
|
||||
'task_id' => $task->id,
|
||||
'task_name' => $task->name,
|
||||
'team_id' => $server?->team_id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,13 +40,13 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue
|
||||
*/
|
||||
public $timeout = 300;
|
||||
|
||||
public Team $team;
|
||||
public ?Team $team = null;
|
||||
|
||||
public ?Server $server = null;
|
||||
|
||||
public ScheduledTask $task;
|
||||
|
||||
public Application|Service $resource;
|
||||
public Application|Service|null $resource = null;
|
||||
|
||||
public ?ScheduledTaskExecution $task_log = null;
|
||||
|
||||
@@ -61,25 +61,34 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue
|
||||
|
||||
public array $containers = [];
|
||||
|
||||
public string $server_timezone;
|
||||
public string $server_timezone = 'UTC';
|
||||
|
||||
public function __construct($task)
|
||||
public function __construct(ScheduledTask $task)
|
||||
{
|
||||
$this->onQueue(crons_queue());
|
||||
|
||||
$this->task = $task;
|
||||
if ($service = $task->service()->first()) {
|
||||
$this->resource = $service;
|
||||
} elseif ($application = $task->application()->first()) {
|
||||
$this->resource = $application;
|
||||
$this->timeout = $this->task->timeout ?? 300;
|
||||
}
|
||||
|
||||
private function initializeExecutionContext(): void
|
||||
{
|
||||
$this->task->loadMissing([
|
||||
'service.destination.server.settings',
|
||||
'application.destination.server.settings',
|
||||
]);
|
||||
|
||||
if ($this->task->service) {
|
||||
$this->resource = $this->task->service;
|
||||
} elseif ($this->task->application) {
|
||||
$this->resource = $this->task->application;
|
||||
} else {
|
||||
throw new \RuntimeException('ScheduledTaskJob failed: No resource found.');
|
||||
}
|
||||
$this->team = Team::findOrFail($task->team_id);
|
||||
$this->server_timezone = $this->getServerTimezone();
|
||||
|
||||
// Set timeout from task configuration
|
||||
$this->timeout = $this->task->timeout ?? 300;
|
||||
$this->team = Team::findOrFail($this->task->team_id);
|
||||
$this->server_timezone = $this->getServerTimezone();
|
||||
$this->server = $this->resource->destination->server;
|
||||
}
|
||||
|
||||
private function getServerTimezone(): string
|
||||
@@ -98,6 +107,8 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$startTime = Carbon::now();
|
||||
|
||||
try {
|
||||
$this->initializeExecutionContext();
|
||||
|
||||
$this->task_log = ScheduledTaskExecution::create([
|
||||
'scheduled_task_id' => $this->task->id,
|
||||
'started_at' => $startTime,
|
||||
@@ -107,8 +118,6 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue
|
||||
// Store execution ID for timeout handling
|
||||
$this->executionId = $this->task_log->id;
|
||||
|
||||
$this->server = $this->resource->destination->server;
|
||||
|
||||
if ($this->resource->type() === 'application') {
|
||||
$containers = getCurrentApplicationContainerStatus($this->server, $this->resource->id, 0);
|
||||
if ($containers->count() > 0) {
|
||||
@@ -179,7 +188,10 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue
|
||||
// Re-throw to trigger Laravel's retry mechanism with backoff
|
||||
throw $e;
|
||||
} finally {
|
||||
ScheduledTaskDone::dispatch($this->team->id);
|
||||
if ($this->team) {
|
||||
ScheduledTaskDone::dispatch($this->team->id);
|
||||
}
|
||||
|
||||
if ($this->task_log) {
|
||||
$finishedAt = Carbon::now();
|
||||
$duration = round($startTime->floatDiffInSeconds($finishedAt), 2);
|
||||
@@ -205,6 +217,8 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue
|
||||
*/
|
||||
public function failed(?\Throwable $exception): void
|
||||
{
|
||||
$this->team ??= Team::find($this->task->team_id);
|
||||
|
||||
Log::channel('scheduled-errors')->error('ScheduledTask permanently failed', [
|
||||
'job' => 'ScheduledTaskJob',
|
||||
'task_id' => $this->task->uuid,
|
||||
|
||||
Reference in New Issue
Block a user