diff --git a/README.md b/README.md index 56edffd31..b7a5fc3eb 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ Special thanks to our biggest sponsors! * [PFGlabs](https://pfglabs.com/?ref=coolify.io) - Build real project with Golang. * [Treive](https://trieve.ai/?ref=coolify.io) - An AI-powered search and discovery platform for enhancing information retrieval in large datasets. * [Blacksmith](https://blacksmith.sh/?ref=coolify.io) - A cloud-native platform for automating infrastructure provisioning and management across multiple cloud providers. -* [Brand Dev](https://brand.dev/?ref=coolify.io) - A web development agency specializing in creating custom digital experiences and brand identities. +* [Brand Dev](https://brand.dev/?ref=coolify.io) - The #1 Brand API for B2B software startups - instantly pull logos, fonts, descriptions, social links, slogans, and so much more from any domain via a single api call. * [Jobscollider](https://jobscollider.com/remote-jobs?ref=coolify.io) - A job search platform connecting professionals with remote work opportunities across various industries. * [Hostinger](https://www.hostinger.com/vps/coolify-hosting?ref=coolify.io) - A web hosting provider offering affordable hosting solutions, domain registration, and website building tools. * [Glueops](https://www.glueops.dev/?ref=coolify.io) - A DevOps consulting company providing infrastructure automation and cloud optimization services. @@ -75,6 +75,7 @@ Special thanks to our biggest sponsors! Lightspeed.run +DartNode FlintCompany American Cloud CryptoJobsList diff --git a/app/Actions/CoolifyTask/RunRemoteProcess.php b/app/Actions/CoolifyTask/RunRemoteProcess.php index 981b81378..926d30fe6 100644 --- a/app/Actions/CoolifyTask/RunRemoteProcess.php +++ b/app/Actions/CoolifyTask/RunRemoteProcess.php @@ -91,16 +91,9 @@ class RunRemoteProcess } else { if ($processResult->exitCode() == 0) { $status = ProcessStatus::FINISHED; - } - if ($processResult->exitCode() != 0 && ! $this->ignore_errors) { + } else { $status = ProcessStatus::ERROR; } - // if (($processResult->exitCode() == 0 && $this->is_finished) || $this->activity->properties->get('status') === ProcessStatus::FINISHED->value) { - // $status = ProcessStatus::FINISHED; - // } - // if ($processResult->exitCode() != 0 && !$this->ignore_errors) { - // $status = ProcessStatus::ERROR; - // } } $this->activity->properties = $this->activity->properties->merge([ @@ -110,9 +103,6 @@ class RunRemoteProcess 'status' => $status->value, ]); $this->activity->save(); - if ($processResult->exitCode() != 0 && ! $this->ignore_errors) { - throw new \RuntimeException($processResult->errorOutput(), $processResult->exitCode()); - } if ($this->call_event_on_finish) { try { if ($this->call_event_data) { @@ -128,6 +118,9 @@ class RunRemoteProcess Log::error('Error calling event: '.$e->getMessage()); } } + if ($processResult->exitCode() != 0 && ! $this->ignore_errors) { + throw new \RuntimeException($processResult->errorOutput(), $processResult->exitCode()); + } return $processResult; } diff --git a/app/Console/Commands/CleanupStuckedResources.php b/app/Console/Commands/CleanupStuckedResources.php index def3d5a2c..0b5eef84d 100644 --- a/app/Console/Commands/CleanupStuckedResources.php +++ b/app/Console/Commands/CleanupStuckedResources.php @@ -39,6 +39,11 @@ class CleanupStuckedResources extends Command $servers = Server::all()->filter(function ($server) { return $server->isFunctional(); }); + if (isCloud()) { + $servers = $servers->filter(function ($server) { + return data_get($server->team->subscription, 'stripe_invoice_paid', false) === true; + }); + } foreach ($servers as $server) { CleanupHelperContainersJob::dispatch($server); } diff --git a/app/Console/Commands/HorizonManage.php b/app/Console/Commands/HorizonManage.php new file mode 100644 index 000000000..ca2da147c --- /dev/null +++ b/app/Console/Commands/HorizonManage.php @@ -0,0 +1,178 @@ +option('can-i-restart-this-worker')) { + return $this->isThereAJobInProgress(); + } + + if ($this->option('job-status')) { + return $this->getJobStatus($this->option('job-status')); + } + + $action = select( + label: 'What to do?', + options: [ + 'pending' => 'Pending Jobs', + 'running' => 'Running Jobs', + 'can-i-restart-this-worker' => 'Can I restart this worker?', + 'job-status' => 'Job Status', + 'workers' => 'Workers', + 'failed' => 'Failed Jobs', + 'failed-delete' => 'Failed Jobs - Delete', + 'purge-queues' => 'Purge Queues', + ] + ); + + if ($action === 'can-i-restart-this-worker') { + $this->isThereAJobInProgress(); + } + + if ($action === 'job-status') { + $jobId = text('Which job to check?'); + $jobStatus = $this->getJobStatus($jobId); + $this->info('Job Status: '.$jobStatus); + } + + if ($action === 'pending') { + $pendingJobs = app(JobRepository::class)->getPending(); + $pendingJobsTable = []; + if (count($pendingJobs) === 0) { + $this->info('No pending jobs found.'); + + return; + } + foreach ($pendingJobs as $pendingJob) { + $pendingJobsTable[] = [ + 'id' => $pendingJob->id, + 'name' => $pendingJob->name, + 'status' => $pendingJob->status, + 'reserved_at' => $pendingJob->reserved_at ? now()->parse($pendingJob->reserved_at)->format('Y-m-d H:i:s') : null, + ]; + } + table($pendingJobsTable); + } + + if ($action === 'failed') { + $failedJobs = app(JobRepository::class)->getFailed(); + $failedJobsTable = []; + if (count($failedJobs) === 0) { + $this->info('No failed jobs found.'); + + return; + } + foreach ($failedJobs as $failedJob) { + $failedJobsTable[] = [ + 'id' => $failedJob->id, + 'name' => $failedJob->name, + 'failed_at' => $failedJob->failed_at ? now()->parse($failedJob->failed_at)->format('Y-m-d H:i:s') : null, + ]; + } + table($failedJobsTable); + } + + if ($action === 'failed-delete') { + $failedJobs = app(JobRepository::class)->getFailed(); + $failedJobsTable = []; + foreach ($failedJobs as $failedJob) { + $failedJobsTable[] = [ + 'id' => $failedJob->id, + 'name' => $failedJob->name, + 'failed_at' => $failedJob->failed_at ? now()->parse($failedJob->failed_at)->format('Y-m-d H:i:s') : null, + ]; + } + app(MetricsRepository::class)->clear(); + if (count($failedJobsTable) === 0) { + $this->info('No failed jobs found.'); + + return; + } + $jobIds = multiselect( + label: 'Which job to delete?', + options: collect($failedJobsTable)->mapWithKeys(fn ($job) => [$job['id'] => $job['id'].' - '.$job['name']])->toArray(), + ); + foreach ($jobIds as $jobId) { + Artisan::queue('horizon:forget', ['id' => $jobId]); + } + } + + if ($action === 'running') { + $redisJobRepository = app(CustomJobRepository::class); + $runningJobs = $redisJobRepository->getReservedJobs(); + $runningJobsTable = []; + if (count($runningJobs) === 0) { + $this->info('No running jobs found.'); + + return; + } + foreach ($runningJobs as $runningJob) { + $runningJobsTable[] = [ + 'id' => $runningJob->id, + 'name' => $runningJob->name, + 'reserved_at' => $runningJob->reserved_at ? now()->parse($runningJob->reserved_at)->format('Y-m-d H:i:s') : null, + ]; + } + table($runningJobsTable); + } + + if ($action === 'workers') { + $redisJobRepository = app(CustomJobRepository::class); + $workers = $redisJobRepository->getHorizonWorkers(); + $workersTable = []; + foreach ($workers as $worker) { + $workersTable[] = [ + 'name' => $worker->name, + ]; + } + table($workersTable); + } + + if ($action === 'purge-queues') { + $getQueues = app(CustomJobRepository::class)->getQueues(); + $queueName = select( + label: 'Which queue to purge?', + options: $getQueues, + ); + $redisJobRepository = app(RedisJobRepository::class); + $redisJobRepository->purge($queueName); + } + } + + public function isThereAJobInProgress() + { + $runningJobs = ApplicationDeploymentQueue::where('horizon_job_worker', gethostname())->where('status', ApplicationDeploymentStatus::IN_PROGRESS->value)->get(); + $count = $runningJobs->count(); + if ($count === 0) { + return false; + } + + return true; + } + + public function getJobStatus(string $jobId) + { + return getJobStatus($jobId); + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index e8781b01e..8b4240412 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -91,7 +91,13 @@ class Kernel extends ConsoleKernel private function pullImages(): void { - $servers = $this->allServers->whereRelation('settings', 'is_usable', true)->whereRelation('settings', 'is_reachable', true)->get(); + if (isCloud()) { + $servers = $this->allServers->whereRelation('team.subscription', 'stripe_invoice_paid', true)->whereRelation('settings', 'is_usable', true)->whereRelation('settings', 'is_reachable', true)->get(); + $own = Team::find(0)->servers; + $servers = $servers->merge($own); + } else { + $servers = $this->allServers->whereRelation('settings', 'is_usable', true)->whereRelation('settings', 'is_reachable', true)->get(); + } foreach ($servers as $server) { if ($server->isSentinelEnabled()) { $this->scheduleInstance->job(function () use ($server) { @@ -124,7 +130,7 @@ class Kernel extends ConsoleKernel private function checkResources(): void { if (isCloud()) { - $servers = $this->allServers->whereHas('team.subscription')->get(); + $servers = $this->allServers->whereRelation('team.subscription', 'stripe_invoice_paid', true)->get(); $own = Team::find(0)->servers; $servers = $servers->merge($own); } else { @@ -171,18 +177,40 @@ class Kernel extends ConsoleKernel if ($scheduled_backups->isEmpty()) { return; } + $finalScheduledBackups = collect(); foreach ($scheduled_backups as $scheduled_backup) { - if (is_null(data_get($scheduled_backup, 'database'))) { + if (blank(data_get($scheduled_backup, 'database'))) { $scheduled_backup->delete(); continue; } - $server = $scheduled_backup->server(); + if (blank($server)) { + $scheduled_backup->delete(); - if (is_null($server)) { continue; } + if ($server->isFunctional() === false) { + continue; + } + if (isCloud() && data_get($server->team->subscription, 'stripe_invoice_paid', false) === false && $server->team->id !== 0) { + continue; + } + $finalScheduledBackups->push($scheduled_backup); + } + + foreach ($finalScheduledBackups as $scheduled_backup) { + if (isset(VALID_CRON_STRINGS[$scheduled_backup->frequency])) { + $scheduled_backup->frequency = VALID_CRON_STRINGS[$scheduled_backup->frequency]; + } + + $server = $scheduled_backup->server(); + $serverTimezone = data_get($server->settings, 'server_timezone', $this->instanceTimezone); + + if (validate_timezone($serverTimezone) === false) { + $serverTimezone = config('app.timezone'); + } + if (isset(VALID_CRON_STRINGS[$scheduled_backup->frequency])) { $scheduled_backup->frequency = VALID_CRON_STRINGS[$scheduled_backup->frequency]; } @@ -199,35 +227,52 @@ class Kernel extends ConsoleKernel if ($scheduled_tasks->isEmpty()) { return; } + $finalScheduledTasks = collect(); foreach ($scheduled_tasks as $scheduled_task) { $service = $scheduled_task->service; $application = $scheduled_task->application; - if (! $application && ! $service) { + $server = $scheduled_task->server(); + if (blank($server)) { $scheduled_task->delete(); continue; } - if ($application) { - if (str($application->status)->contains('running') === false) { - continue; - } - } - if ($service) { - if (str($service->status)->contains('running') === false) { - continue; - } - } - $server = $scheduled_task->server(); - if (! $server) { + if ($server->isFunctional() === false) { continue; } + if (isCloud() && data_get($server->team->subscription, 'stripe_invoice_paid', false) === false && $server->team->id !== 0) { + continue; + } + + if (! $service && ! $application) { + $scheduled_task->delete(); + + continue; + } + + if ($application && str($application->status)->contains('running') === false) { + continue; + } + if ($service && str($service->status)->contains('running') === false) { + continue; + } + + $finalScheduledTasks->push($scheduled_task); + } + + foreach ($finalScheduledTasks as $scheduled_task) { + $server = $scheduled_task->server(); if (isset(VALID_CRON_STRINGS[$scheduled_task->frequency])) { $scheduled_task->frequency = VALID_CRON_STRINGS[$scheduled_task->frequency]; } $serverTimezone = data_get($server->settings, 'server_timezone', $this->instanceTimezone); + + if (validate_timezone($serverTimezone) === false) { + $serverTimezone = config('app.timezone'); + } $this->scheduleInstance->job(new ScheduledTaskJob( task: $scheduled_task ))->cron($scheduled_task->frequency)->timezone($serverTimezone)->onOneServer(); diff --git a/app/Contracts/CustomJobRepositoryInterface.php b/app/Contracts/CustomJobRepositoryInterface.php new file mode 100644 index 000000000..1fbd71f46 --- /dev/null +++ b/app/Contracts/CustomJobRepositoryInterface.php @@ -0,0 +1,19 @@ +application_deployment_queue_id]; + } + + public function __construct(public int $application_deployment_queue_id) { $this->onQueue('high'); + $this->application_deployment_queue = ApplicationDeploymentQueue::find($this->application_deployment_queue_id); $this->nixpacks_plan_json = collect([]); - $this->application_deployment_queue = ApplicationDeploymentQueue::find($application_deployment_queue_id); $this->application = Application::find($this->application_deployment_queue->application_id); $this->build_pack = data_get($this->application, 'build_pack'); $this->build_args = collect([]); - $this->application_deployment_queue_id = $application_deployment_queue_id; $this->deployment_uuid = $this->application_deployment_queue->deployment_uuid; $this->pull_request_id = $this->application_deployment_queue->pull_request_id; $this->commit = $this->application_deployment_queue->commit; @@ -237,15 +240,11 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } } - public function tags(): array - { - return ['server:'.gethostname()]; - } - public function handle(): void { $this->application_deployment_queue->update([ 'status' => ApplicationDeploymentStatus::IN_PROGRESS->value, + 'horizon_job_worker' => gethostname(), ]); if ($this->server->isFunctional() === false) { $this->application_deployment_queue->addLogEntry('Server is not functional.'); @@ -2391,7 +2390,8 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); queue_next_deployment($this->application); // If the deployment is cancelled by the user, don't update the status if ( - $this->application_deployment_queue->status !== ApplicationDeploymentStatus::CANCELLED_BY_USER->value && $this->application_deployment_queue->status !== ApplicationDeploymentStatus::FAILED->value + $this->application_deployment_queue->status !== ApplicationDeploymentStatus::CANCELLED_BY_USER->value && + $this->application_deployment_queue->status !== ApplicationDeploymentStatus::FAILED->value ) { $this->application_deployment_queue->update([ 'status' => $status, diff --git a/app/Jobs/CheckAndStartSentinelJob.php b/app/Jobs/CheckAndStartSentinelJob.php index 788db89ea..304b2a15c 100644 --- a/app/Jobs/CheckAndStartSentinelJob.php +++ b/app/Jobs/CheckAndStartSentinelJob.php @@ -24,7 +24,7 @@ class CheckAndStartSentinelJob implements ShouldBeEncrypted, ShouldQueue $latestVersion = get_latest_sentinel_version(); // Check if sentinel is running - $sentinelFound = instant_remote_process(['docker inspect coolify-sentinel'], $this->server, false); + $sentinelFound = instant_remote_process_with_timeout(['docker inspect coolify-sentinel'], $this->server, false, 10); $sentinelFoundJson = json_decode($sentinelFound, true); $sentinelStatus = data_get($sentinelFoundJson, '0.State.Status', 'exited'); if ($sentinelStatus !== 'running') { @@ -33,7 +33,7 @@ class CheckAndStartSentinelJob implements ShouldBeEncrypted, ShouldQueue return; } // If sentinel is running, check if it needs an update - $runningVersion = instant_remote_process(['docker exec coolify-sentinel sh -c "curl http://127.0.0.1:8888/api/version"'], $this->server, false); + $runningVersion = instant_remote_process_with_timeout(['docker exec coolify-sentinel sh -c "curl http://127.0.0.1:8888/api/version"'], $this->server, false); if (empty($runningVersion)) { $runningVersion = '0.0.0'; } diff --git a/app/Jobs/CleanupHelperContainersJob.php b/app/Jobs/CleanupHelperContainersJob.php index f185ab781..0e1fcb4d7 100644 --- a/app/Jobs/CleanupHelperContainersJob.php +++ b/app/Jobs/CleanupHelperContainersJob.php @@ -20,11 +20,11 @@ class CleanupHelperContainersJob implements ShouldBeEncrypted, ShouldBeUnique, S public function handle(): void { try { - $containers = instant_remote_process(['docker container ps --format \'{{json .}}\' | jq -s \'map(select(.Image | contains("ghcr.io/coollabsio/coolify-helper")))\''], $this->server, false); + $containers = instant_remote_process_with_timeout(['docker container ps --format \'{{json .}}\' | jq -s \'map(select(.Image | contains("ghcr.io/coollabsio/coolify-helper")))\''], $this->server, false); $containerIds = collect(json_decode($containers))->pluck('ID'); if ($containerIds->count() > 0) { foreach ($containerIds as $containerId) { - instant_remote_process(['docker container rm -f '.$containerId], $this->server, false); + instant_remote_process_with_timeout(['docker container rm -f '.$containerId], $this->server, false); } } } catch (\Throwable $e) { diff --git a/app/Jobs/PushServerUpdateJob.php b/app/Jobs/PushServerUpdateJob.php index 24f8d1e6b..93b203fcb 100644 --- a/app/Jobs/PushServerUpdateJob.php +++ b/app/Jobs/PushServerUpdateJob.php @@ -19,6 +19,7 @@ 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\Collection; @@ -68,6 +69,11 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue public bool $foundLogDrainContainer = false; + public function middleware(): array + { + return [(new WithoutOverlapping($this->server->uuid))->dontRelease()]; + } + public function backoff(): int { return isDev() ? 1 : 3; diff --git a/app/Livewire/ActivityMonitor.php b/app/Livewire/ActivityMonitor.php index 2e36f34ee..024f53c3d 100644 --- a/app/Livewire/ActivityMonitor.php +++ b/app/Livewire/ActivityMonitor.php @@ -42,14 +42,8 @@ class ActivityMonitor extends Component public function polling() { $this->hydrateActivity(); - // $this->setStatus(ProcessStatus::IN_PROGRESS); $exit_code = data_get($this->activity, 'properties.exitCode'); if ($exit_code !== null) { - // if ($exit_code === 0) { - // // $this->setStatus(ProcessStatus::FINISHED); - // } else { - // // $this->setStatus(ProcessStatus::ERROR); - // } $this->isPollingActive = false; if ($exit_code === 0) { if ($this->eventToDispatch !== null) { @@ -70,12 +64,4 @@ class ActivityMonitor extends Component } } } - - // protected function setStatus($status) - // { - // $this->activity->properties = $this->activity->properties->merge([ - // 'status' => $status, - // ]); - // $this->activity->save(); - // } } diff --git a/app/Livewire/Destination/New/Docker.php b/app/Livewire/Destination/New/Docker.php index 337f1d067..0e60025e5 100644 --- a/app/Livewire/Destination/New/Docker.php +++ b/app/Livewire/Destination/New/Docker.php @@ -83,9 +83,7 @@ class Docker extends Component ]); } } - $connectProxyToDockerNetworks = connectProxyToNetworks($this->selectedServer); - instant_remote_process($connectProxyToDockerNetworks, $this->selectedServer, false); - $this->dispatch('reloadWindow'); + $this->redirect(route('destination.show', $docker->uuid)); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Project/Application/Deployment/Show.php b/app/Livewire/Project/Application/Deployment/Show.php index 4e86b5c7e..7b2ac09d3 100644 --- a/app/Livewire/Project/Application/Deployment/Show.php +++ b/app/Livewire/Project/Application/Deployment/Show.php @@ -14,6 +14,8 @@ class Show extends Component public string $deployment_uuid; + public string $horizon_job_status; + public $isKeepAliveOn = true; protected $listeners = ['refreshQueue']; @@ -44,7 +46,9 @@ class Show extends Component } $this->application = $application; $this->application_deployment_queue = $application_deployment_queue; + $this->horizon_job_status = $this->application_deployment_queue->getHorizonJobStatus(); $this->deployment_uuid = $deploymentUuid; + $this->isKeepAliveOn(); } public function refreshQueue() @@ -52,13 +56,21 @@ class Show extends Component $this->application_deployment_queue->refresh(); } + private function isKeepAliveOn() + { + if (data_get($this->application_deployment_queue, 'status') === 'finished' || data_get($this->application_deployment_queue, 'status') === 'failed') { + $this->isKeepAliveOn = false; + } else { + $this->isKeepAliveOn = true; + } + } + public function polling() { $this->dispatch('deploymentFinished'); $this->application_deployment_queue->refresh(); - if (data_get($this->application_deployment_queue, 'status') === 'finished' || data_get($this->application_deployment_queue, 'status') === 'failed') { - $this->isKeepAliveOn = false; - } + $this->horizon_job_status = $this->application_deployment_queue->getHorizonJobStatus(); + $this->isKeepAliveOn(); } public function getLogLinesProperty() diff --git a/app/Livewire/Project/Database/BackupExecutions.php b/app/Livewire/Project/Database/BackupExecutions.php index f91b8bfaf..ce168a352 100644 --- a/app/Livewire/Project/Database/BackupExecutions.php +++ b/app/Livewire/Project/Database/BackupExecutions.php @@ -83,8 +83,10 @@ class BackupExecutions extends Component public function refreshBackupExecutions(): void { - if ($this->backup) { - $this->executions = $this->backup->executions()->get(); + if ($this->backup && $this->backup->exists) { + $this->executions = $this->backup->executions()->get()->toArray(); + } else { + $this->executions = []; } } diff --git a/app/Livewire/Project/Service/Configuration.php b/app/Livewire/Project/Service/Configuration.php index 4a5e8627f..d1744b178 100644 --- a/app/Livewire/Project/Service/Configuration.php +++ b/app/Livewire/Project/Service/Configuration.php @@ -99,7 +99,7 @@ class Configuration extends Component $this->service->databases->each(function ($database) { $database->refresh(); }); - $this->dispatch('$refresh'); + $this->dispatch('refresh'); } catch (\Exception $e) { return handleError($e, $this); } diff --git a/app/Livewire/Project/Service/Navbar.php b/app/Livewire/Project/Service/Navbar.php index 22fc1c0d6..915fb54c6 100644 --- a/app/Livewire/Project/Service/Navbar.php +++ b/app/Livewire/Project/Service/Navbar.php @@ -5,6 +5,7 @@ namespace App\Livewire\Project\Service; use App\Actions\Service\StartService; use App\Actions\Service\StopService; use App\Actions\Shared\PullImage; +use App\Enums\ProcessStatus; use App\Events\ServiceStatusChanged; use App\Models\Service; use Illuminate\Support\Facades\Auth; @@ -68,11 +69,9 @@ class Navbar extends Component public function checkDeployments() { try { - // TODO: This is a temporary solution. We need to refactor this. - // We need to delete null bytes somehow. $activity = Activity::where('properties->type_uuid', $this->service->uuid)->latest()->first(); $status = data_get($activity, 'properties.status'); - if ($status === 'queued' || $status === 'in_progress') { + if ($status === ProcessStatus::QUEUED->value || $status === ProcessStatus::IN_PROGRESS->value) { $this->isDeploymentProgress = true; } else { $this->isDeploymentProgress = false; @@ -80,25 +79,46 @@ class Navbar extends Component } catch (\Throwable) { $this->isDeploymentProgress = false; } + + return $this->isDeploymentProgress; } public function start() { - $this->checkDeployments(); - if ($this->isDeploymentProgress) { - $this->dispatch('error', 'There is a deployment in progress.'); - - return; - } $this->service->parse(); $activity = StartService::run($this->service); $this->dispatch('activityMonitor', $activity->id); } - public function stop() + public function forceDeploy() { - StopService::run($this->service, false, $this->docker_cleanup); - ServiceStatusChanged::dispatch(); + try { + $activities = Activity::where('properties->type_uuid', $this->service->uuid)->where('properties->status', ProcessStatus::IN_PROGRESS->value)->orWhere('properties->status', ProcessStatus::QUEUED->value)->get(); + foreach ($activities as $activity) { + $activity->properties->status = ProcessStatus::ERROR->value; + $activity->save(); + } + $this->service->parse(); + $activity = StartService::run($this->service); + $this->dispatch('activityMonitor', $activity->id); + } catch (\Exception $e) { + $this->dispatch('error', $e->getMessage()); + } + } + + public function stop($cleanupContainers = false) + { + try { + StopService::run($this->service, false, $this->docker_cleanup); + ServiceStatusChanged::dispatch(); + if ($cleanupContainers) { + $this->dispatch('success', 'Containers cleaned up.'); + } else { + $this->dispatch('success', 'Service stopped.'); + } + } catch (\Exception $e) { + $this->dispatch('error', $e->getMessage()); + } } public function restart() diff --git a/app/Models/ApplicationDeploymentQueue.php b/app/Models/ApplicationDeploymentQueue.php index c261c30c6..95c78d725 100644 --- a/app/Models/ApplicationDeploymentQueue.php +++ b/app/Models/ApplicationDeploymentQueue.php @@ -70,6 +70,11 @@ class ApplicationDeploymentQueue extends Model return collect(json_decode($this->logs))->where('name', $name)->first()?->output ?? null; } + public function getHorizonJobStatus() + { + return getJobStatus($this->horizon_job_id); + } + public function commitMessage() { if (empty($this->commit_message) || is_null($this->commit_message)) { diff --git a/app/Models/Server.php b/app/Models/Server.php index 8d11e23a9..2867f95cb 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -54,6 +54,8 @@ class Server extends BaseModel public static $batch_counter = 0; + protected $appends = ['is_coolify_host']; + protected static function booted() { static::saving(function ($server) { @@ -156,6 +158,15 @@ class Server extends BaseModel return 'server'; } + protected function isCoolifyHost(): Attribute + { + return Attribute::make( + get: function () { + return $this->id === 0; + } + ); + } + public static function isReachable() { return Server::ownedByCurrentTeam()->whereRelation('settings', 'is_reachable', true); @@ -656,9 +667,9 @@ $schema://$host { $containers = collect([]); $containerReplicates = collect([]); if ($this->isSwarm()) { - $containers = instant_remote_process(["docker service inspect $(docker service ls -q) --format '{{json .}}'"], $this, false); + $containers = instant_remote_process_with_timeout(["docker service inspect $(docker service ls -q) --format '{{json .}}'"], $this, false); $containers = format_docker_command_output_to_json($containers); - $containerReplicates = instant_remote_process(["docker service ls --format '{{json .}}'"], $this, false); + $containerReplicates = instant_remote_process_with_timeout(["docker service ls --format '{{json .}}'"], $this, false); if ($containerReplicates) { $containerReplicates = format_docker_command_output_to_json($containerReplicates); foreach ($containerReplicates as $containerReplica) { @@ -682,7 +693,7 @@ $schema://$host { } } } else { - $containers = instant_remote_process(["docker container inspect $(docker container ls -aq) --format '{{json .}}'"], $this, false); + $containers = instant_remote_process_with_timeout(["docker container inspect $(docker container ls -aq) --format '{{json .}}'"], $this, false); $containers = format_docker_command_output_to_json($containers); $containerReplicates = collect([]); } diff --git a/app/Models/Service.php b/app/Models/Service.php index 1df579802..25e6b92ea 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -1050,10 +1050,11 @@ class Service extends BaseModel $fields->put('MySQL', $data->toArray()); break; case $image->contains('mariadb'): - $userVariables = ['SERVICE_USER_MARIADB', 'SERVICE_USER_WORDPRESS', '_APP_DB_USER', 'SERVICE_USER_MYSQL', 'MYSQL_USER']; + $userVariables = ['SERVICE_USER_MARIADB', 'SERVICE_USER_WORDPRESS', 'SERVICE_USER_MYSQL', 'MYSQL_USER']; $passwordVariables = ['SERVICE_PASSWORD_MARIADB', 'SERVICE_PASSWORD_WORDPRESS', '_APP_DB_PASS', 'MYSQL_PASSWORD']; $rootPasswordVariables = ['SERVICE_PASSWORD_MARIADBROOT', 'SERVICE_PASSWORD_ROOT', '_APP_DB_ROOT_PASS', 'MYSQL_ROOT_PASSWORD']; $dbNameVariables = ['SERVICE_DATABASE_MARIADB', 'SERVICE_DATABASE_WORDPRESS', '_APP_DB_SCHEMA', 'MYSQL_DATABASE']; + $mariadb_user = $this->environment_variables()->whereIn('key', $userVariables)->first(); $mariadb_password = $this->environment_variables()->whereIn('key', $passwordVariables)->first(); $mariadb_root_password = $this->environment_variables()->whereIn('key', $rootPasswordVariables)->first(); @@ -1102,6 +1103,23 @@ class Service extends BaseModel break; } } + $fields = collect($fields)->map(function ($extraFields) { + if (is_array($extraFields)) { + $extraFields = collect($extraFields)->map(function ($field) { + if (filled($field['value']) && str($field['value'])->startsWith('$SERVICE_')) { + $searchValue = str($field['value'])->after('$')->value; + $newValue = $this->environment_variables()->where('key', $searchValue)->first(); + if ($newValue) { + $field['value'] = $newValue->value; + } + } + + return $field; + }); + } + + return $extraFields; + }); return $fields; } diff --git a/app/Models/StandaloneDocker.php b/app/Models/StandaloneDocker.php index 1ef6ff587..9db6a2d29 100644 --- a/app/Models/StandaloneDocker.php +++ b/app/Models/StandaloneDocker.php @@ -6,6 +6,19 @@ class StandaloneDocker extends BaseModel { protected $guarded = []; + protected static function boot() + { + parent::boot(); + static::created(function ($newStandaloneDocker) { + $server = $newStandaloneDocker->server; + instant_remote_process([ + "docker network inspect $newStandaloneDocker->network >/dev/null 2>&1 || docker network create --driver overlay --attachable $newStandaloneDocker->network >/dev/null", + ], $server, false); + $connectProxyToDockerNetworks = connectProxyToNetworks($server); + instant_remote_process($connectProxyToDockerNetworks, $server, false); + }); + } + public function applications() { return $this->morphMany(Application::class, 'destination'); diff --git a/app/Models/Team.php b/app/Models/Team.php index 8651df3c8..07959dd16 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -247,8 +247,17 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen public function sources() { $sources = collect([]); - $github_apps = $this->hasMany(GithubApp::class)->whereisPublic(false)->get(); - $gitlab_apps = $this->hasMany(GitlabApp::class)->whereisPublic(false)->get(); + $github_apps = GithubApp::where(function ($query) { + $query->where('team_id', $this->id) + ->Where('is_public', false) + ->orWhere('is_system_wide', true); + })->get(); + + $gitlab_apps = GitlabApp::where(function ($query) { + $query->where('team_id', $this->id) + ->Where('is_public', false) + ->orWhere('is_system_wide', true); + })->get(); return $sources->merge($github_apps)->merge($gitlab_apps); } diff --git a/app/Providers/HorizonServiceProvider.php b/app/Providers/HorizonServiceProvider.php index f851f1f3b..0caa3a3a9 100644 --- a/app/Providers/HorizonServiceProvider.php +++ b/app/Providers/HorizonServiceProvider.php @@ -2,15 +2,52 @@ namespace App\Providers; +use App\Contracts\CustomJobRepositoryInterface; +use App\Models\ApplicationDeploymentQueue; use App\Models\User; +use App\Repositories\CustomJobRepository; +use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Gate; +use Laravel\Horizon\Contracts\JobRepository; +use Laravel\Horizon\Events\JobReserved; use Laravel\Horizon\HorizonApplicationServiceProvider; class HorizonServiceProvider extends HorizonApplicationServiceProvider { + /** + * Register services. + */ + public function register(): void + { + $this->app->singleton(JobRepository::class, CustomJobRepository::class); + $this->app->singleton(CustomJobRepositoryInterface::class, CustomJobRepository::class); + } + + /** + * Bootstrap services. + */ public function boot(): void { parent::boot(); + Event::listen(function (JobReserved $event) { + $payload = $event->payload->decoded; + $jobName = $payload['displayName']; + if ($jobName === 'App\Jobs\ApplicationDeploymentJob') { + $tags = $payload['tags']; + $id = $payload['id']; + $deploymentQueueId = collect($tags)->first(function ($tag) { + return str_contains($tag, 'App\Models\ApplicationDeploymentQueue'); + }); + if (blank($deploymentQueueId)) { + return; + } + $deploymentQueueId = explode(':', $deploymentQueueId)[1]; + $deploymentQueue = ApplicationDeploymentQueue::find($deploymentQueueId); + $deploymentQueue->update([ + 'horizon_job_id' => $id, + ]); + } + }); } protected function gate(): void diff --git a/app/Repositories/CustomJobRepository.php b/app/Repositories/CustomJobRepository.php new file mode 100644 index 000000000..502dd252b --- /dev/null +++ b/app/Repositories/CustomJobRepository.php @@ -0,0 +1,51 @@ +all(); + } + + public function getReservedJobs(): Collection + { + return $this->getJobsByStatus('reserved'); + } + + public function getJobsByStatus(string $status): Collection + { + $jobs = new Collection; + + $this->getRecent()->each(function ($job) use ($jobs, $status) { + if ($job->status === $status) { + $jobs->push($job); + } + }); + + return $jobs; + } + + public function countJobsByStatus(string $status): int + { + return $this->getJobsByStatus($status)->count(); + } + + public function getQueues(): array + { + $queues = $this->connection()->keys('queue:*'); + $queues = array_map(function ($queue) { + return explode(':', $queue)[2]; + }, $queues); + + return $queues; + } +} diff --git a/app/View/Components/services/advanced.php b/app/View/Components/services/advanced.php new file mode 100644 index 000000000..7e68c6dda --- /dev/null +++ b/app/View/Components/services/advanced.php @@ -0,0 +1,26 @@ +toArray() : $command; + if ($server->isNonRoot() && ! $no_sudo) { + $command = parseCommandsByLineForSudo(collect($command), $server); + } + $command_string = implode("\n", $command); + + // $start_time = microtime(true); + $sshCommand = SshMultiplexingHelper::generateSshCommand($server, $command_string); + $process = Process::timeout(30)->run($sshCommand); + // $end_time = microtime(true); + + // $execution_time = ($end_time - $start_time) * 1000; // Convert to milliseconds + // ray('SSH command execution time:', $execution_time.' ms')->orange(); + + $output = trim($process->output()); + $exitCode = $process->exitCode(); + + if ($exitCode !== 0) { + return $throwError ? excludeCertainErrors($process->errorOutput(), $exitCode) : null; + } + + return $output === 'null' ? null : $output; +} function instant_remote_process(Collection|array $command, Server $server, bool $throwError = true, bool $no_sudo = false): ?string { $command = $command instanceof Collection ? $command->toArray() : $command; diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 4cd40f203..5d34c63b6 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -41,6 +41,7 @@ use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Validator; use Illuminate\Support\Str; use Illuminate\Support\Stringable; +use Laravel\Horizon\Contracts\JobRepository; use Lcobucci\JWT\Encoding\ChainedFormatter; use Lcobucci\JWT\Encoding\JoseEncoder; use Lcobucci\JWT\Signer\Hmac\Sha256; @@ -1257,14 +1258,22 @@ function get_public_ips() function isAnyDeploymentInprogress() { - // Only use it in the deployment script - $count = ApplicationDeploymentQueue::whereIn('status', [ApplicationDeploymentStatus::IN_PROGRESS, ApplicationDeploymentStatus::QUEUED])->count(); - if ($count > 0) { - echo "There are $count deployments in progress. Exiting...\n"; - exit(1); + $runningJobs = ApplicationDeploymentQueue::where('horizon_job_worker', gethostname())->where('status', ApplicationDeploymentStatus::IN_PROGRESS->value)->get(); + $horizonJobIds = []; + foreach ($runningJobs as $runningJob) { + $horizonJobStatus = getJobStatus($runningJob->horizon_job_id); + if ($horizonJobStatus === 'unknown') { + return true; + } + $horizonJobIds[] = $runningJob->horizon_job_id; } - echo "No deployments in progress.\n"; - exit(0); + if (count($horizonJobIds) === 0) { + echo "No deployments in progress.\n"; + exit(0); + } + $horizonJobIds = collect($horizonJobIds)->unique()->toArray(); + echo 'There are '.count($horizonJobIds)." deployments in progress.\n"; + exit(1); } function isBase64Encoded($strValue) @@ -4124,3 +4133,16 @@ function convertGitUrl(string $gitRepository, string $deploymentType, ?GithubApp 'port' => $providerInfo['port'], ]; } + +function getJobStatus(?string $jobId = null) +{ + if (blank($jobId)) { + return 'unknown'; + } + $jobFound = app(JobRepository::class)->getJobs([$jobId]); + if ($jobFound->isEmpty()) { + return 'unknown'; + } + + return $jobFound->first()->status; +} diff --git a/composer.json b/composer.json index 5a51db531..055059a56 100644 --- a/composer.json +++ b/composer.json @@ -30,7 +30,7 @@ "league/flysystem-sftp-v3": "^3.0", "livewire/livewire": "^3.5", "log1x/laravel-webfonts": "^1.0", - "lorisleiva/laravel-actions": "^2.7", + "lorisleiva/laravel-actions": "^2.8", "nubs/random-name-generator": "^2.2", "phpseclib/phpseclib": "^3.0", "pion/laravel-chunk-upload": "^1.5", @@ -124,4 +124,4 @@ "@php artisan key:generate --ansi" ] } -} +} \ No newline at end of file diff --git a/composer.lock b/composer.lock index 8c05ebb36..7ca8219c7 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ccced2490c39e4f6f1bf9b036bfa3ef0", + "content-hash": "85a775fb1a4b9ea329d8d893f43621c2", "packages": [ { "name": "3sidedcube/laravel-redoc", @@ -928,16 +928,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.336.11", + "version": "3.336.12", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "442039c766a82f06ecfecb0ac2c610d6aaba228d" + "reference": "a173ab3af8d9186d266e4937d8254597f36a9e15" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/442039c766a82f06ecfecb0ac2c610d6aaba228d", - "reference": "442039c766a82f06ecfecb0ac2c610d6aaba228d", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/a173ab3af8d9186d266e4937d8254597f36a9e15", + "reference": "a173ab3af8d9186d266e4937d8254597f36a9e15", "shasum": "" }, "require": { @@ -1020,9 +1020,9 @@ "support": { "forum": "https://forums.aws.amazon.com/forum.jspa?forumID=80", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.336.11" + "source": "https://github.com/aws/aws-sdk-php/tree/3.336.12" }, - "time": "2025-01-08T19:06:59+00:00" + "time": "2025-01-09T19:04:34+00:00" }, { "name": "bacon/bacon-qr-code", @@ -13198,16 +13198,16 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.1", + "version": "1.12.15", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "cd6e973e04b4c2b94c86e8612b5a65f0da0e08e7" + "reference": "c91d4e8bc056f46cf653656e6f71004b254574d1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/cd6e973e04b4c2b94c86e8612b5a65f0da0e08e7", - "reference": "cd6e973e04b4c2b94c86e8612b5a65f0da0e08e7", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/c91d4e8bc056f46cf653656e6f71004b254574d1", + "reference": "c91d4e8bc056f46cf653656e6f71004b254574d1", "shasum": "" }, "require": { @@ -13252,7 +13252,7 @@ "type": "github" } ], - "time": "2025-01-05T16:43:48+00:00" + "time": "2025-01-05T16:40:22+00:00" }, { "name": "phpunit/php-code-coverage", @@ -15362,12 +15362,12 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": {}, + "stability-flags": [], "prefer-stable": true, "prefer-lowest": false, "platform": { "php": "^8.4" }, - "platform-dev": {}, + "platform-dev": [], "plugin-api-version": "2.6.0" } diff --git a/database/migrations/2025_01_10_135244_add_horizon_job_details_to_queue.php b/database/migrations/2025_01_10_135244_add_horizon_job_details_to_queue.php new file mode 100644 index 000000000..8ce5821ef --- /dev/null +++ b/database/migrations/2025_01_10_135244_add_horizon_job_details_to_queue.php @@ -0,0 +1,30 @@ +string('horizon_job_id')->nullable(); + $table->string('horizon_job_worker')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('application_deployment_queues', function (Blueprint $table) { + $table->dropColumn('horizon_job_id'); + $table->dropColumn('horizon_job_worker'); + }); + } +}; diff --git a/resources/views/components/forms/checkbox.blade.php b/resources/views/components/forms/checkbox.blade.php index 39704a122..29c97735f 100644 --- a/resources/views/components/forms/checkbox.blade.php +++ b/resources/views/components/forms/checkbox.blade.php @@ -11,20 +11,21 @@ ])
$fullWidth, + 'dark:hover:bg-coolgray-100 cursor-pointer' => !$disabled, ])> -