Merge branch 'next' into shadow/fix-docker-time-command

This commit is contained in:
Andras Bacsai authored and GitHub committed 2025-11-28 10:25:42 +01:00
commit 1d054b23b8
318 files changed
+21712 -4743

No files matched your search

+1 -1
View File
@@ -22,7 +22,7 @@ class RestartDatabase
if (! $server->isFunctional()) {
return 'Server is not functional';
}
StopDatabase::run($database);
StopDatabase::run($database, dockerCleanup: false);
return StartDatabase::run($database);
}
+182 -74
View File
@@ -8,13 +8,17 @@ use App\Events\ServiceChecked;
use App\Models\ApplicationPreview;
use App\Models\Server;
use App\Models\ServiceDatabase;
use App\Services\ContainerStatusAggregator;
use App\Traits\CalculatesExcludedStatus;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Lorisleiva\Actions\Concerns\AsAction;
class GetContainersStatus
{
use AsAction;
use CalculatesExcludedStatus;
public string $jobQueue = 'high';
@@ -28,6 +32,10 @@ class GetContainersStatus
protected ?Collection $applicationContainerStatuses;
protected ?Collection $applicationContainerRestartCounts;
protected ?Collection $serviceContainerStatuses;
public function handle(Server $server, ?Collection $containers = null, ?Collection $containerReplicates = null)
{
$this->containers = $containers;
@@ -95,11 +103,15 @@ class GetContainersStatus
$labels = data_get($container, 'Config.Labels');
}
$containerStatus = data_get($container, 'State.Status');
$containerHealth = data_get($container, 'State.Health.Status', 'unhealthy');
$containerHealth = data_get($container, 'State.Health.Status');
if ($containerStatus === 'restarting') {
$containerStatus = "restarting ($containerHealth)";
$healthSuffix = $containerHealth ?? 'unknown';
$containerStatus = "restarting:$healthSuffix";
} elseif ($containerStatus === 'exited') {
// Keep as-is, no health suffix for exited containers
} else {
$containerStatus = "$containerStatus ($containerHealth)";
$healthSuffix = $containerHealth ?? 'unknown';
$containerStatus = "$containerStatus:$healthSuffix";
}
$labels = Arr::undot(format_docker_labels_to_json($labels));
$applicationId = data_get($labels, 'coolify.applicationId');
@@ -136,6 +148,18 @@ class GetContainersStatus
if ($containerName) {
$this->applicationContainerStatuses->get($applicationId)->put($containerName, $containerStatus);
}
// Track restart counts for applications
$restartCount = data_get($container, 'RestartCount', 0);
if (! isset($this->applicationContainerRestartCounts)) {
$this->applicationContainerRestartCounts = collect();
}
if (! $this->applicationContainerRestartCounts->has($applicationId)) {
$this->applicationContainerRestartCounts->put($applicationId, collect());
}
if ($containerName) {
$this->applicationContainerRestartCounts->get($applicationId)->put($containerName, $restartCount);
}
} else {
// Notify user that this container should not be there.
}
@@ -207,23 +231,34 @@ class GetContainersStatus
if ($serviceLabelId) {
$subType = data_get($labels, 'coolify.service.subType');
$subId = data_get($labels, 'coolify.service.subId');
$service = $services->where('id', $serviceLabelId)->first();
if (! $service) {
$parentService = $services->where('id', $serviceLabelId)->first();
if (! $parentService) {
continue;
}
// Store container status for aggregation
if (! isset($this->serviceContainerStatuses)) {
$this->serviceContainerStatuses = collect();
}
$key = $serviceLabelId.':'.$subType.':'.$subId;
if (! $this->serviceContainerStatuses->has($key)) {
$this->serviceContainerStatuses->put($key, collect());
}
$containerName = data_get($labels, 'com.docker.compose.service');
if ($containerName) {
$this->serviceContainerStatuses->get($key)->put($containerName, $containerStatus);
}
// Mark service as found
if ($subType === 'application') {
$service = $service->applications()->where('id', $subId)->first();
$service = $parentService->applications()->where('id', $subId)->first();
} else {
$service = $service->databases()->where('id', $subId)->first();
$service = $parentService->databases()->where('id', $subId)->first();
}
if ($service) {
$foundServices[] = "$service->id-$service->name";
$statusFromDb = $service->status;
if ($statusFromDb !== $containerStatus) {
$service->update(['status' => $containerStatus]);
} else {
$service->update(['last_online_at' => now()]);
}
}
}
}
@@ -291,7 +326,24 @@ class GetContainersStatus
continue;
}
$application->update(['status' => 'exited']);
// If container was recently restarting (crash loop), keep it as degraded for a grace period
// This prevents false "exited" status during the brief moment between container removal and recreation
$recentlyRestarted = $application->restart_count > 0 &&
$application->last_restart_at &&
$application->last_restart_at->greaterThan(now()->subSeconds(30));
if ($recentlyRestarted) {
// Keep it as degraded if it was recently in a crash loop
$application->update(['status' => 'degraded:unhealthy']);
} else {
// Reset restart count when application exits completely
$application->update([
'status' => 'exited',
'restart_count' => 0,
'last_restart_at' => null,
'last_restart_type' => null,
]);
}
}
$notRunningApplicationPreviews = $previews->pluck('id')->diff($foundApplicationPreviews);
foreach ($notRunningApplicationPreviews as $previewId) {
@@ -340,88 +392,144 @@ class GetContainersStatus
continue;
}
$aggregatedStatus = $this->aggregateApplicationStatus($application, $containerStatuses);
if ($aggregatedStatus) {
$statusFromDb = $application->status;
if ($statusFromDb !== $aggregatedStatus) {
$application->update(['status' => $aggregatedStatus]);
} else {
$application->update(['last_online_at' => now()]);
}
// Track restart counts first
$maxRestartCount = 0;
if (isset($this->applicationContainerRestartCounts) && $this->applicationContainerRestartCounts->has($applicationId)) {
$containerRestartCounts = $this->applicationContainerRestartCounts->get($applicationId);
$maxRestartCount = $containerRestartCounts->max() ?? 0;
}
// Wrap all database updates in a transaction to ensure consistency
DB::transaction(function () use ($application, $maxRestartCount, $containerStatuses) {
$previousRestartCount = $application->restart_count ?? 0;
if ($maxRestartCount > $previousRestartCount) {
// Restart count increased - this is a crash restart
$application->update([
'restart_count' => $maxRestartCount,
'last_restart_at' => now(),
'last_restart_type' => 'crash',
]);
// Send notification
$containerName = $application->name;
$projectUuid = data_get($application, 'environment.project.uuid');
$environmentName = data_get($application, 'environment.name');
$applicationUuid = data_get($application, 'uuid');
if ($projectUuid && $applicationUuid && $environmentName) {
$url = base_url().'/project/'.$projectUuid.'/'.$environmentName.'/application/'.$applicationUuid;
} else {
$url = null;
}
}
// Aggregate status after tracking restart counts
$aggregatedStatus = $this->aggregateApplicationStatus($application, $containerStatuses, $maxRestartCount);
if ($aggregatedStatus) {
$statusFromDb = $application->status;
if ($statusFromDb !== $aggregatedStatus) {
$application->update(['status' => $aggregatedStatus]);
} else {
$application->update(['last_online_at' => now()]);
}
}
});
}
}
// Aggregate multi-container service statuses
$this->aggregateServiceContainerStatuses($services);
ServiceChecked::dispatch($this->server->team->id);
}
private function aggregateApplicationStatus($application, Collection $containerStatuses): ?string
private function aggregateApplicationStatus($application, Collection $containerStatuses, int $maxRestartCount = 0): ?string
{
// Parse docker compose to check for excluded containers
$dockerComposeRaw = data_get($application, 'docker_compose_raw');
$excludedContainers = collect();
if ($dockerComposeRaw) {
try {
$dockerCompose = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw);
$services = data_get($dockerCompose, 'services', []);
foreach ($services as $serviceName => $serviceConfig) {
// Check if container should be excluded
$excludeFromHc = data_get($serviceConfig, 'exclude_from_hc', false);
$restartPolicy = data_get($serviceConfig, 'restart', 'always');
if ($excludeFromHc || $restartPolicy === 'no') {
$excludedContainers->push($serviceName);
}
}
} catch (\Exception $e) {
// If we can't parse, treat all containers as included
}
}
$excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw);
// Filter out excluded containers
$relevantStatuses = $containerStatuses->filter(function ($status, $containerName) use ($excludedContainers) {
return ! $excludedContainers->contains($containerName);
});
// If all containers are excluded, don't update status
// If all containers are excluded, calculate status from excluded containers
if ($relevantStatuses->isEmpty()) {
return null;
return $this->calculateExcludedStatusFromStrings($containerStatuses);
}
$hasRunning = false;
$hasRestarting = false;
$hasUnhealthy = false;
$hasExited = false;
// Use ContainerStatusAggregator service for state machine logic
$aggregator = new ContainerStatusAggregator;
foreach ($relevantStatuses as $status) {
if (str($status)->contains('restarting')) {
$hasRestarting = true;
} elseif (str($status)->contains('running')) {
$hasRunning = true;
if (str($status)->contains('unhealthy')) {
$hasUnhealthy = true;
return $aggregator->aggregateFromStrings($relevantStatuses, $maxRestartCount);
}
private function aggregateServiceContainerStatuses($services)
{
if (! isset($this->serviceContainerStatuses) || $this->serviceContainerStatuses->isEmpty()) {
return;
}
foreach ($this->serviceContainerStatuses as $key => $containerStatuses) {
// Parse key: serviceId:subType:subId
[$serviceId, $subType, $subId] = explode(':', $key);
$service = $services->where('id', $serviceId)->first();
if (! $service) {
continue;
}
// Get the service sub-resource (ServiceApplication or ServiceDatabase)
$subResource = null;
if ($subType === 'application') {
$subResource = $service->applications()->where('id', $subId)->first();
} elseif ($subType === 'database') {
$subResource = $service->databases()->where('id', $subId)->first();
}
if (! $subResource) {
continue;
}
// Parse docker compose from service to check for excluded containers
$dockerComposeRaw = data_get($service, 'docker_compose_raw');
$excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw);
// Filter out excluded containers
$relevantStatuses = $containerStatuses->filter(function ($status, $containerName) use ($excludedContainers) {
return ! $excludedContainers->contains($containerName);
});
// If all containers are excluded, calculate status from excluded containers
if ($relevantStatuses->isEmpty()) {
$aggregatedStatus = $this->calculateExcludedStatusFromStrings($containerStatuses);
if ($aggregatedStatus) {
$statusFromDb = $subResource->status;
if ($statusFromDb !== $aggregatedStatus) {
$subResource->update(['status' => $aggregatedStatus]);
} else {
$subResource->update(['last_online_at' => now()]);
}
}
continue;
}
// Use ContainerStatusAggregator service for state machine logic
$aggregator = new ContainerStatusAggregator;
$aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses);
// Update service sub-resource status with aggregated result
if ($aggregatedStatus) {
$statusFromDb = $subResource->status;
if ($statusFromDb !== $aggregatedStatus) {
$subResource->update(['status' => $aggregatedStatus]);
} else {
$subResource->update(['last_online_at' => now()]);
}
} elseif (str($status)->contains('exited')) {
$hasExited = true;
$hasUnhealthy = true;
}
}
if ($hasRestarting) {
return 'degraded (unhealthy)';
}
if ($hasRunning && $hasExited) {
return 'degraded (unhealthy)';
}
if ($hasRunning) {
return $hasUnhealthy ? 'running (unhealthy)' : 'running (healthy)';
}
// All containers are exited
return 'exited (unhealthy)';
}
}
+19 -3
View File
@@ -13,7 +13,7 @@ class StartProxy
{
use AsAction;
public function handle(Server $server, bool $async = true, bool $force = false): string|Activity
public function handle(Server $server, bool $async = true, bool $force = false, bool $restarting = false): string|Activity
{
$proxyType = $server->proxyType();
if ((is_null($proxyType) || $proxyType === 'NONE' || $server->proxy->force_stop || $server->isBuildServer()) && $force === false) {
@@ -22,7 +22,10 @@ class StartProxy
$server->proxy->set('status', 'starting');
$server->save();
$server->refresh();
ProxyStatusChangedUI::dispatch($server->team_id);
if (! $restarting) {
ProxyStatusChangedUI::dispatch($server->team_id);
}
$commands = collect([]);
$proxy_path = $server->proxyPath();
@@ -60,9 +63,22 @@ class StartProxy
'docker compose pull',
'if docker ps -a --format "{{.Names}}" | grep -q "^coolify-proxy$"; then',
" echo 'Stopping and removing existing coolify-proxy.'",
' docker rm -f coolify-proxy || true',
' docker stop coolify-proxy 2>/dev/null || true',
' docker rm -f coolify-proxy 2>/dev/null || true',
' # Wait for container to be fully removed',
' for i in {1..10}; do',
' if ! docker ps -a --format "{{.Names}}" | grep -q "^coolify-proxy$"; then',
' break',
' fi',
' echo "Waiting for coolify-proxy to be removed... ($i/10)"',
' sleep 1',
' done',
" echo 'Successfully stopped and removed existing coolify-proxy.'",
'fi',
]);
// Ensure required networks exist BEFORE docker compose up (networks are declared as external)
$commands = $commands->merge(ensureProxyNetworksExist($server));
$commands = $commands->merge([
"echo 'Starting coolify-proxy.'",
'docker compose up -d --wait --remove-orphans',
"echo 'Successfully started coolify-proxy.'",
+18 -5
View File
@@ -12,17 +12,27 @@ class StopProxy
{
use AsAction;
public function handle(Server $server, bool $forceStop = true, int $timeout = 30)
public function handle(Server $server, bool $forceStop = true, int $timeout = 30, bool $restarting = false)
{
try {
$containerName = $server->isSwarm() ? 'coolify-proxy_traefik' : 'coolify-proxy';
$server->proxy->status = 'stopping';
$server->save();
ProxyStatusChangedUI::dispatch($server->team_id);
if (! $restarting) {
ProxyStatusChangedUI::dispatch($server->team_id);
}
instant_remote_process(command: [
"docker stop -t $timeout $containerName",
"docker rm -f $containerName",
"docker stop -t=$timeout $containerName 2>/dev/null || true",
"docker rm -f $containerName 2>/dev/null || true",
'# Wait for container to be fully removed',
'for i in {1..10}; do',
" if ! docker ps -a --format \"{{.Names}}\" | grep -q \"^$containerName$\"; then",
' break',
' fi',
' sleep 1',
'done',
], server: $server, throwError: false);
$server->proxy->force_stop = $forceStop;
@@ -32,7 +42,10 @@ class StopProxy
return handleError($e);
} finally {
ProxyDashboardCacheService::clearCache($server);
ProxyStatusChanged::dispatch($server->id);
if (! $restarting) {
ProxyStatusChanged::dispatch($server->id);
}
}
}
}
-31
View File
@@ -59,8 +59,6 @@ class InstallDocker
$command = collect([]);
if (isDev() && $server->id === 0) {
$command = $command->merge([
"echo 'Installing Prerequisites...'",
'sleep 1',
"echo 'Installing Docker Engine...'",
"echo 'Configuring Docker Engine (merging existing configuration with the required)...'",
'sleep 4',
@@ -70,35 +68,6 @@ class InstallDocker
return remote_process($command, $server);
} else {
if ($supported_os_type->contains('debian')) {
$command = $command->merge([
"echo 'Installing Prerequisites...'",
'apt-get update -y',
'command -v curl >/dev/null || apt install -y curl',
'command -v wget >/dev/null || apt install -y wget',
'command -v git >/dev/null || apt install -y git',
'command -v jq >/dev/null || apt install -y jq',
]);
} elseif ($supported_os_type->contains('rhel')) {
$command = $command->merge([
"echo 'Installing Prerequisites...'",
'command -v curl >/dev/null || dnf install -y curl',
'command -v wget >/dev/null || dnf install -y wget',
'command -v git >/dev/null || dnf install -y git',
'command -v jq >/dev/null || dnf install -y jq',
]);
} elseif ($supported_os_type->contains('sles')) {
$command = $command->merge([
"echo 'Installing Prerequisites...'",
'zypper update -y',
'command -v curl >/dev/null || zypper install -y curl',
'command -v wget >/dev/null || zypper install -y wget',
'command -v git >/dev/null || zypper install -y git',
'command -v jq >/dev/null || zypper install -y jq',
]);
} else {
throw new \Exception('Unsupported OS');
}
$command = $command->merge([
"echo 'Installing Docker Engine...'",
]);
@@ -0,0 +1,57 @@
<?php
namespace App\Actions\Server;
use App\Models\Server;
use Lorisleiva\Actions\Concerns\AsAction;
class InstallPrerequisites
{
use AsAction;
public string $jobQueue = 'high';
public function handle(Server $server)
{
$supported_os_type = $server->validateOS();
if (! $supported_os_type) {
throw new \Exception('Server OS type is not supported for automated installation. Please install prerequisites manually.');
}
$command = collect([]);
if ($supported_os_type->contains('debian')) {
$command = $command->merge([
"echo 'Installing Prerequisites...'",
'apt-get update -y',
'command -v curl >/dev/null || apt install -y curl',
'command -v wget >/dev/null || apt install -y wget',
'command -v git >/dev/null || apt install -y git',
'command -v jq >/dev/null || apt install -y jq',
]);
} elseif ($supported_os_type->contains('rhel')) {
$command = $command->merge([
"echo 'Installing Prerequisites...'",
'command -v curl >/dev/null || dnf install -y curl',
'command -v wget >/dev/null || dnf install -y wget',
'command -v git >/dev/null || dnf install -y git',
'command -v jq >/dev/null || dnf install -y jq',
]);
} elseif ($supported_os_type->contains('sles')) {
$command = $command->merge([
"echo 'Installing Prerequisites...'",
'zypper update -y',
'command -v curl >/dev/null || zypper install -y curl',
'command -v wget >/dev/null || zypper install -y wget',
'command -v git >/dev/null || zypper install -y git',
'command -v jq >/dev/null || zypper install -y jq',
]);
} else {
throw new \Exception('Unsupported OS type for prerequisites installation');
}
$command->push("echo 'Prerequisites installed successfully.'");
return remote_process($command, $server);
}
}
+3 -2
View File
@@ -2,7 +2,6 @@
namespace App\Actions\Server;
use App\Jobs\PullHelperImageJob;
use App\Models\Server;
use Illuminate\Support\Sleep;
use Lorisleiva\Actions\Concerns\AsAction;
@@ -50,7 +49,9 @@ class UpdateCoolify
private function update()
{
PullHelperImageJob::dispatch($this->server);
$helperImage = config('constants.coolify.helper_image');
$latest_version = getHelperVersion();
instant_remote_process(["docker pull -q {$helperImage}:{$latest_version}"], $this->server, false);
$image = config('constants.coolify.registry_url').'/coollabsio/coolify:'.$this->latestVersion;
instant_remote_process(["docker pull -q $image"], $this->server, false);
@@ -0,0 +1,40 @@
<?php
namespace App\Actions\Server;
use App\Models\Server;
use Lorisleiva\Actions\Concerns\AsAction;
class ValidatePrerequisites
{
use AsAction;
public string $jobQueue = 'high';
/**
* Validate that required commands are available on the server.
*
* @return array{success: bool, missing: array<string>, found: array<string>}
*/
public function handle(Server $server): array
{
$requiredCommands = ['git', 'curl', 'jq'];
$missing = [];
$found = [];
foreach ($requiredCommands as $cmd) {
$result = instant_remote_process(["command -v {$cmd}"], $server, false);
if (! $result) {
$missing[] = $cmd;
} else {
$found[] = $cmd;
}
}
return [
'success' => empty($missing),
'missing' => $missing,
'found' => $found,
];
}
}
+10
View File
@@ -45,6 +45,16 @@ class ValidateServer
throw new \Exception($this->error);
}
$validationResult = $server->validatePrerequisites();
if (! $validationResult['success']) {
$missingCommands = implode(', ', $validationResult['missing']);
$this->error = "Prerequisites ({$missingCommands}) are not installed. Please install them before continuing or use the validation with installation endpoint.";
$server->update([
'validation_logs' => $this->error,
]);
throw new \Exception($this->error);
}
$this->docker_installed = $server->validateDockerEngine();
$this->docker_compose_installed = $server->validateDockerCompose();
if (! $this->docker_installed || ! $this->docker_compose_installed) {
+9 -4
View File
@@ -20,18 +20,23 @@ class StartService
}
$service->saveComposeConfigs();
$service->isConfigurationChanged(save: true);
$commands[] = 'cd '.$service->workdir();
$commands[] = "echo 'Saved configuration files to {$service->workdir()}.'";
$workdir = $service->workdir();
// $commands[] = "cd {$workdir}";
$commands[] = "echo 'Saved configuration files to {$workdir}.'";
// Ensure .env exists in the correct directory before docker compose tries to load it
// This is defensive programming - saveComposeConfigs() already creates it,
// but we guarantee it here in case of any edge cases or manual deployments
$commands[] = "touch {$workdir}/.env";
if ($pullLatestImages) {
$commands[] = "echo 'Pulling images.'";
$commands[] = 'docker compose pull';
$commands[] = "docker compose --project-directory {$workdir} pull";
}
if ($service->networks()->count() > 0) {
$commands[] = "echo 'Creating Docker network.'";
$commands[] = "docker network inspect $service->uuid >/dev/null 2>&1 || docker network create --attachable $service->uuid";
}
$commands[] = 'echo Starting service.';
$commands[] = 'docker compose up -d --remove-orphans --force-recreate --build';
$commands[] = "docker compose --project-directory {$workdir} -f {$workdir}/docker-compose.yml --project-name {$service->uuid} up -d --remove-orphans --force-recreate --build";
$commands[] = "docker network connect $service->uuid coolify-proxy >/dev/null 2>&1 || true";
if (data_get($service, 'connect_to_docker_network')) {
$compose = data_get($service, 'docker_compose', []);
+19 -65
View File
@@ -3,11 +3,14 @@
namespace App\Actions\Shared;
use App\Models\Application;
use App\Services\ContainerStatusAggregator;
use App\Traits\CalculatesExcludedStatus;
use Lorisleiva\Actions\Concerns\AsAction;
class ComplexStatusCheck
{
use AsAction;
use CalculatesExcludedStatus;
public function handle(Application $application)
{
@@ -17,11 +20,11 @@ class ComplexStatusCheck
$is_main_server = $application->destination->server->id === $server->id;
if (! $server->isFunctional()) {
if ($is_main_server) {
$application->update(['status' => 'exited:unhealthy']);
$application->update(['status' => 'exited']);
continue;
} else {
$application->additional_servers()->updateExistingPivot($server->id, ['status' => 'exited:unhealthy']);
$application->additional_servers()->updateExistingPivot($server->id, ['status' => 'exited']);
continue;
}
@@ -46,11 +49,11 @@ class ComplexStatusCheck
}
} else {
if ($is_main_server) {
$application->update(['status' => 'exited:unhealthy']);
$application->update(['status' => 'exited']);
continue;
} else {
$application->additional_servers()->updateExistingPivot($server->id, ['status' => 'exited:unhealthy']);
$application->additional_servers()->updateExistingPivot($server->id, ['status' => 'exited']);
continue;
}
@@ -61,74 +64,25 @@ class ComplexStatusCheck
private function aggregateContainerStatuses($application, $containers)
{
$dockerComposeRaw = data_get($application, 'docker_compose_raw');
$excludedContainers = collect();
$excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw);
if ($dockerComposeRaw) {
try {
$dockerCompose = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw);
$services = data_get($dockerCompose, 'services', []);
foreach ($services as $serviceName => $serviceConfig) {
$excludeFromHc = data_get($serviceConfig, 'exclude_from_hc', false);
$restartPolicy = data_get($serviceConfig, 'restart', 'always');
if ($excludeFromHc || $restartPolicy === 'no') {
$excludedContainers->push($serviceName);
}
}
} catch (\Exception $e) {
// If we can't parse, treat all containers as included
}
}
$hasRunning = false;
$hasRestarting = false;
$hasUnhealthy = false;
$hasExited = false;
$relevantContainerCount = 0;
foreach ($containers as $container) {
// Filter non-excluded containers
$relevantContainers = collect($containers)->filter(function ($container) use ($excludedContainers) {
$labels = data_get($container, 'Config.Labels', []);
$serviceName = data_get($labels, 'com.docker.compose.service');
if ($serviceName && $excludedContainers->contains($serviceName)) {
continue;
}
return ! ($serviceName && $excludedContainers->contains($serviceName));
});
$relevantContainerCount++;
$containerStatus = data_get($container, 'State.Status');
$containerHealth = data_get($container, 'State.Health.Status', 'unhealthy');
if ($containerStatus === 'restarting') {
$hasRestarting = true;
$hasUnhealthy = true;
} elseif ($containerStatus === 'running') {
$hasRunning = true;
if ($containerHealth === 'unhealthy') {
$hasUnhealthy = true;
}
} elseif ($containerStatus === 'exited') {
$hasExited = true;
$hasUnhealthy = true;
}
// If all containers are excluded, calculate status from excluded containers
// but mark it with :excluded to indicate monitoring is disabled
if ($relevantContainers->isEmpty()) {
return $this->calculateExcludedStatus($containers, $excludedContainers);
}
if ($relevantContainerCount === 0) {
return 'running:healthy';
}
// Use ContainerStatusAggregator service for state machine logic
$aggregator = new ContainerStatusAggregator;
if ($hasRestarting) {
return 'degraded:unhealthy';
}
if ($hasRunning && $hasExited) {
return 'degraded:unhealthy';
}
if ($hasRunning) {
return $hasUnhealthy ? 'running:unhealthy' : 'running:healthy';
}
return 'exited:unhealthy';
return $aggregator->aggregateFromContainers($relevantContainers);
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\Console\Commands;
use App\Jobs\CheckTraefikVersionJob;
use Illuminate\Console\Command;
class CheckTraefikVersionCommand extends Command
{
protected $signature = 'traefik:check-version';
protected $description = 'Check Traefik proxy versions on all servers and send notifications for outdated versions';
public function handle(): int
{
$this->info('Checking Traefik versions on all servers...');
try {
CheckTraefikVersionJob::dispatch();
$this->info('Traefik version check job dispatched successfully.');
$this->info('Notifications will be sent to teams with outdated Traefik versions.');
return Command::SUCCESS;
} catch (\Exception $e) {
$this->error('Failed to dispatch Traefik version check job: '.$e->getMessage());
return Command::FAILURE;
}
}
}
+16 -50
View File
@@ -63,8 +63,6 @@ class CleanupNames extends Command
public function handle(): int
{
$this->info('🔍 Scanning for invalid characters in name fields...');
if ($this->option('backup') && ! $this->option('dry-run')) {
$this->createBackup();
}
@@ -75,7 +73,7 @@ class CleanupNames extends Command
: $this->modelsToClean;
if ($modelFilter && ! isset($this->modelsToClean[$modelFilter])) {
$this->error("❌ Unknown model: {$modelFilter}");
$this->error("Unknown model: {$modelFilter}");
$this->info('Available models: '.implode(', ', array_keys($this->modelsToClean)));
return self::FAILURE;
@@ -88,19 +86,21 @@ class CleanupNames extends Command
$this->processModel($modelName, $modelClass);
}
$this->displaySummary();
if (! $this->option('dry-run') && $this->totalCleaned > 0) {
$this->logChanges();
}
if ($this->option('dry-run')) {
$this->info("Name cleanup: would sanitize {$this->totalCleaned} records");
} else {
$this->info("Name cleanup: sanitized {$this->totalCleaned} records");
}
return self::SUCCESS;
}
protected function processModel(string $modelName, string $modelClass): void
{
$this->info("\n📋 Processing {$modelName}...");
try {
$records = $modelClass::all(['id', 'name']);
$cleaned = 0;
@@ -128,21 +128,17 @@ class CleanupNames extends Command
$cleaned++;
$this->totalCleaned++;
$this->warn(" 🧹 {$modelName} #{$record->id}:");
$this->line(' From: '.$this->truncate($originalName, 80));
$this->line(' To: '.$this->truncate($sanitizedName, 80));
// Only log in dry-run mode to preview changes
if ($this->option('dry-run')) {
$this->warn(" 🧹 {$modelName} #{$record->id}:");
$this->line(' From: '.$this->truncate($originalName, 80));
$this->line(' To: '.$this->truncate($sanitizedName, 80));
}
}
}
if ($cleaned > 0) {
$action = $this->option('dry-run') ? 'would be sanitized' : 'sanitized';
$this->info(" ✅ {$cleaned}/{$records->count()} records {$action}");
} else {
$this->info(' ✨ No invalid characters found');
}
} catch (\Exception $e) {
$this->error(" ❌ Error processing {$modelName}: ".$e->getMessage());
$this->error("Error processing {$modelName}: ".$e->getMessage());
}
}
@@ -165,28 +161,6 @@ class CleanupNames extends Command
return $sanitized;
}
protected function displaySummary(): void
{
$this->info("\n".str_repeat('=', 60));
$this->info('📊 CLEANUP SUMMARY');
$this->info(str_repeat('=', 60));
$this->line("Records processed: {$this->totalProcessed}");
$this->line("Records with invalid characters: {$this->totalCleaned}");
if ($this->option('dry-run')) {
$this->warn("\n🔍 DRY RUN - No changes were made to the database");
$this->info('Run without --dry-run to apply these changes');
} else {
if ($this->totalCleaned > 0) {
$this->info("\n✅ Database successfully sanitized!");
$this->info('Changes logged to storage/logs/name-cleanup.log');
} else {
$this->info("\n✨ No cleanup needed - all names are valid!");
}
}
}
protected function logChanges(): void
{
$logFile = storage_path('logs/name-cleanup.log');
@@ -208,8 +182,6 @@ class CleanupNames extends Command
protected function createBackup(): void
{
$this->info('💾 Creating database backup...');
try {
$backupFile = storage_path('backups/name-cleanup-backup-'.now()->format('Y-m-d-H-i-s').'.sql');
@@ -229,15 +201,9 @@ class CleanupNames extends Command
);
exec($command, $output, $returnCode);
if ($returnCode === 0) {
$this->info("✅ Backup created: {$backupFile}");
} else {
$this->warn('⚠️ Backup creation may have failed. Proceeding anyway...');
}
} catch (\Exception $e) {
$this->warn('⚠️ Could not create backup: '.$e->getMessage());
$this->warn('Proceeding without backup...');
// Log failure but continue - backup is optional safeguard
Log::warning('Name cleanup backup failed', ['error' => $e->getMessage()]);
}
}
+133 -43
View File
@@ -7,7 +7,7 @@ use Illuminate\Support\Facades\Redis;
class CleanupRedis extends Command
{
protected $signature = 'cleanup:redis {--dry-run : Show what would be deleted without actually deleting} {--skip-overlapping : Skip overlapping queue cleanup} {--clear-locks : Clear stale WithoutOverlapping locks}';
protected $signature = 'cleanup:redis {--dry-run : Show what would be deleted without actually deleting} {--skip-overlapping : Skip overlapping queue cleanup} {--clear-locks : Clear stale WithoutOverlapping locks} {--restart : Aggressive cleanup mode for system restart (marks all processing jobs as failed)}';
protected $description = 'Cleanup Redis (Horizon jobs, metrics, overlapping queues, cache locks, and related data)';
@@ -18,10 +18,6 @@ class CleanupRedis extends Command
$dryRun = $this->option('dry-run');
$skipOverlapping = $this->option('skip-overlapping');
if ($dryRun) {
$this->info('DRY RUN MODE - No data will be deleted');
}
$deletedCount = 0;
$totalKeys = 0;
@@ -29,8 +25,6 @@ class CleanupRedis extends Command
$keys = $redis->keys('*');
$totalKeys = count($keys);
$this->info("Scanning {$totalKeys} keys for cleanup...");
foreach ($keys as $key) {
$keyWithoutPrefix = str_replace($prefix, '', $key);
$type = $redis->command('type', [$keyWithoutPrefix]);
@@ -51,22 +45,27 @@ class CleanupRedis extends Command
// Clean up overlapping queues if not skipped
if (! $skipOverlapping) {
$this->info('Cleaning up overlapping queues...');
$overlappingCleaned = $this->cleanupOverlappingQueues($redis, $prefix, $dryRun);
$deletedCount += $overlappingCleaned;
}
// Clean up stale cache locks (WithoutOverlapping middleware)
if ($this->option('clear-locks')) {
$this->info('Cleaning up stale cache locks...');
$locksCleaned = $this->cleanupCacheLocks($dryRun);
$deletedCount += $locksCleaned;
}
// Clean up stuck jobs (restart mode = aggressive, runtime mode = conservative)
$isRestart = $this->option('restart');
if ($isRestart || $this->option('clear-locks')) {
$jobsCleaned = $this->cleanupStuckJobs($redis, $prefix, $dryRun, $isRestart);
$deletedCount += $jobsCleaned;
}
if ($dryRun) {
$this->info("DRY RUN: Would delete {$deletedCount} out of {$totalKeys} keys");
$this->info("Redis cleanup: would delete {$deletedCount} items");
} else {
$this->info("Deleted {$deletedCount} out of {$totalKeys} keys");
$this->info("Redis cleanup: deleted {$deletedCount} items");
}
}
@@ -77,11 +76,8 @@ class CleanupRedis extends Command
// Delete completed and failed jobs
if (in_array($status, ['completed', 'failed'])) {
if ($dryRun) {
$this->line("Would delete job: {$keyWithoutPrefix} (status: {$status})");
} else {
if (! $dryRun) {
$redis->command('del', [$keyWithoutPrefix]);
$this->line("Deleted job: {$keyWithoutPrefix} (status: {$status})");
}
return true;
@@ -107,11 +103,8 @@ class CleanupRedis extends Command
foreach ($patterns as $pattern => $description) {
if (str_contains($keyWithoutPrefix, $pattern)) {
if ($dryRun) {
$this->line("Would delete {$description}: {$keyWithoutPrefix}");
} else {
if (! $dryRun) {
$redis->command('del', [$keyWithoutPrefix]);
$this->line("Deleted {$description}: {$keyWithoutPrefix}");
}
return true;
@@ -124,11 +117,8 @@ class CleanupRedis extends Command
$weekAgo = now()->subDays(7)->timestamp;
if ($timestamp < $weekAgo) {
if ($dryRun) {
$this->line("Would delete old timestamped data: {$keyWithoutPrefix}");
} else {
if (! $dryRun) {
$redis->command('del', [$keyWithoutPrefix]);
$this->line("Deleted old timestamped data: {$keyWithoutPrefix}");
}
return true;
@@ -152,8 +142,6 @@ class CleanupRedis extends Command
}
}
$this->info('Found '.count($queueKeys).' queue-related keys');
// Group queues by name pattern to find duplicates
$queueGroups = [];
foreach ($queueKeys as $queueKey) {
@@ -185,7 +173,6 @@ class CleanupRedis extends Command
private function deduplicateQueueGroup($redis, $baseName, $keys, $dryRun)
{
$cleanedCount = 0;
$this->line("Processing queue group: {$baseName} (".count($keys).' keys)');
// Sort keys to keep the most recent one
usort($keys, function ($a, $b) {
@@ -236,11 +223,8 @@ class CleanupRedis extends Command
}
if ($shouldDelete) {
if ($dryRun) {
$this->line(" Would delete empty queue: {$redundantKey}");
} else {
if (! $dryRun) {
$redis->command('del', [$redundantKey]);
$this->line(" Deleted empty queue: {$redundantKey}");
}
$cleanedCount++;
}
@@ -263,15 +247,12 @@ class CleanupRedis extends Command
if (count($uniqueItems) < count($items)) {
$duplicates = count($items) - count($uniqueItems);
if ($dryRun) {
$this->line(" Would remove {$duplicates} duplicate jobs from queue: {$queueKey}");
} else {
if (! $dryRun) {
// Rebuild the list with unique items
$redis->command('del', [$queueKey]);
foreach (array_reverse($uniqueItems) as $item) {
$redis->command('lpush', [$queueKey, $item]);
}
$this->line(" Removed {$duplicates} duplicate jobs from queue: {$queueKey}");
}
$cleanedCount += $duplicates;
}
@@ -299,13 +280,9 @@ class CleanupRedis extends Command
}
}
if (empty($lockKeys)) {
$this->info(' No cache locks found.');
return 0;
}
$this->info(' Found '.count($lockKeys).' cache lock(s)');
foreach ($lockKeys as $lockKey) {
// Check TTL to identify stale locks
$ttl = $redis->ttl($lockKey);
@@ -318,16 +295,129 @@ class CleanupRedis extends Command
$this->warn(" Would delete STALE lock (no expiration): {$lockKey}");
} else {
$redis->del($lockKey);
$this->info(" ✓ Deleted STALE lock: {$lockKey}");
}
$cleanedCount++;
} elseif ($ttl > 0) {
$this->line(" Skipping active lock (expires in {$ttl}s): {$lockKey}");
}
}
if ($cleanedCount === 0) {
$this->info(' No stale locks found (all locks have expiration set)');
return $cleanedCount;
}
/**
* Clean up stuck jobs based on mode (restart vs runtime).
*
* @param mixed $redis Redis connection
* @param string $prefix Horizon prefix
* @param bool $dryRun Dry run mode
* @param bool $isRestart Restart mode (aggressive) vs runtime mode (conservative)
* @return int Number of jobs cleaned
*/
private function cleanupStuckJobs($redis, string $prefix, bool $dryRun, bool $isRestart): int
{
$cleanedCount = 0;
$now = time();
// Get all keys with the horizon prefix
$cursor = 0;
$keys = [];
do {
$result = $redis->scan($cursor, ['match' => '*', 'count' => 100]);
// Guard against scan() returning false
if ($result === false) {
$this->error('Redis scan failed, stopping key retrieval');
break;
}
$cursor = $result[0];
$keys = array_merge($keys, $result[1]);
} while ($cursor !== 0);
foreach ($keys as $key) {
$keyWithoutPrefix = str_replace($prefix, '', $key);
$type = $redis->command('type', [$keyWithoutPrefix]);
// Only process hash-type keys (individual jobs)
if ($type !== 5) {
continue;
}
$data = $redis->command('hgetall', [$keyWithoutPrefix]);
$status = data_get($data, 'status');
$payload = data_get($data, 'payload');
// Only process jobs in "processing" or "reserved" state
if (! in_array($status, ['processing', 'reserved'])) {
continue;
}
// Parse job payload to get job class and started time
$payloadData = json_decode($payload, true);
// Check for JSON decode errors
if ($payloadData === null || json_last_error() !== JSON_ERROR_NONE) {
$errorMsg = json_last_error_msg();
$truncatedPayload = is_string($payload) ? substr($payload, 0, 200) : 'non-string payload';
$this->error("Failed to decode job payload for {$keyWithoutPrefix}: {$errorMsg}. Payload: {$truncatedPayload}");
continue;
}
$jobClass = data_get($payloadData, 'displayName', 'Unknown');
// Prefer reserved_at (when job started processing), fallback to created_at
$reservedAt = (int) data_get($data, 'reserved_at', 0);
$createdAt = (int) data_get($data, 'created_at', 0);
$startTime = $reservedAt ?: $createdAt;
// If we can't determine when the job started, skip it
if (! $startTime) {
continue;
}
// Calculate how long the job has been processing
$processingTime = $now - $startTime;
$shouldFail = false;
$reason = '';
if ($isRestart) {
// RESTART MODE: Mark ALL processing/reserved jobs as failed
// Safe because all workers are dead on restart
$shouldFail = true;
$reason = 'System restart - all workers terminated';
} else {
// RUNTIME MODE: Only mark truly stuck jobs as failed
// Be conservative to avoid killing legitimate long-running jobs
// Skip ApplicationDeploymentJob entirely (has dynamic_timeout, can run 2+ hours)
if (str_contains($jobClass, 'ApplicationDeploymentJob')) {
continue;
}
// Skip DatabaseBackupJob (large backups can take hours)
if (str_contains($jobClass, 'DatabaseBackupJob')) {
continue;
}
// For other jobs, only fail if processing > 12 hours
if ($processingTime > 43200) { // 12 hours
$shouldFail = true;
$reason = 'Processing for more than 12 hours';
}
}
if ($shouldFail) {
if ($dryRun) {
$this->warn(" Would mark as FAILED: {$jobClass} (processing for ".round($processingTime / 60, 1)." min) - {$reason}");
} else {
// Mark job as failed
$redis->command('hset', [$keyWithoutPrefix, 'status', 'failed']);
$redis->command('hset', [$keyWithoutPrefix, 'failed_at', $now]);
$redis->command('hset', [$keyWithoutPrefix, 'exception', "Job cleaned up by cleanup:redis - {$reason}"]);
}
$cleanedCount++;
}
}
return $cleanedCount;
@@ -222,9 +222,14 @@ class CleanupStuckedResources extends Command
try {
$scheduled_backups = ScheduledDatabaseBackup::all();
foreach ($scheduled_backups as $scheduled_backup) {
if (! $scheduled_backup->server()) {
echo "Deleting stuck scheduledbackup: {$scheduled_backup->name}\n";
$scheduled_backup->delete();
try {
$server = $scheduled_backup->server();
if (! $server) {
echo "Deleting stuck scheduledbackup: {$scheduled_backup->name}\n";
$scheduled_backup->delete();
}
} catch (\Throwable $e) {
echo "Error checking server for scheduledbackup {$scheduled_backup->id}: {$e->getMessage()}\n";
}
}
} catch (\Throwable $e) {
@@ -416,7 +421,7 @@ class CleanupStuckedResources extends Command
foreach ($serviceApplications as $service) {
if (! data_get($service, 'service')) {
echo 'ServiceApplication without service: '.$service->name.'\n';
DeleteResourceJob::dispatch($service);
$service->forceDelete();
continue;
}
@@ -429,7 +434,7 @@ class CleanupStuckedResources extends Command
foreach ($serviceDatabases as $service) {
if (! data_get($service, 'service')) {
echo 'ServiceDatabase without service: '.$service->name.'\n';
DeleteResourceJob::dispatch($service);
$service->forceDelete();
continue;
}
+41
View File
@@ -4,6 +4,9 @@ namespace App\Console\Commands;
use App\Jobs\CheckHelperImageJob;
use App\Models\InstanceSettings;
use App\Models\ScheduledDatabaseBackupExecution;
use App\Models\ScheduledTaskExecution;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Artisan;
@@ -45,6 +48,44 @@ class Dev extends Command
} else {
echo "Instance already initialized.\n";
}
// Clean up stuck jobs and stale locks on development startup
try {
echo "Cleaning up Redis (stuck jobs and stale locks)...\n";
Artisan::call('cleanup:redis', ['--restart' => true, '--clear-locks' => true]);
echo "Redis cleanup completed.\n";
} catch (\Throwable $e) {
echo "Error in cleanup:redis: {$e->getMessage()}\n";
}
try {
$updatedTaskCount = ScheduledTaskExecution::where('status', 'running')->update([
'status' => 'failed',
'message' => 'Marked as failed during Coolify startup - job was interrupted',
'finished_at' => Carbon::now(),
]);
if ($updatedTaskCount > 0) {
echo "Marked {$updatedTaskCount} stuck scheduled task executions as failed\n";
}
} catch (\Throwable $e) {
echo "Could not cleanup stuck scheduled task executions: {$e->getMessage()}\n";
}
try {
$updatedBackupCount = ScheduledDatabaseBackupExecution::where('status', 'running')->update([
'status' => 'failed',
'message' => 'Marked as failed during Coolify startup - job was interrupted',
'finished_at' => Carbon::now(),
]);
if ($updatedBackupCount > 0) {
echo "Marked {$updatedBackupCount} stuck database backup executions as failed\n";
}
} catch (\Throwable $e) {
echo "Could not cleanup stuck database backup executions: {$e->getMessage()}\n";
}
CheckHelperImageJob::dispatch();
}
}
+1 -1
View File
@@ -167,7 +167,7 @@ class Emails extends Command
]);
}
$output = 'Because of an error, the backup of the database '.$db->name.' failed.';
$this->mail = (new BackupFailed($backup, $db, $output))->toMail();
$this->mail = (new BackupFailed($backup, $db, $output, $backup->database_name ?? 'unknown'))->toMail();
$this->sendEmail();
break;
case 'backup-success':
+33 -1
View File
@@ -10,9 +10,12 @@ use App\Models\ApplicationDeploymentQueue;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ScheduledDatabaseBackupExecution;
use App\Models\ScheduledTaskExecution;
use App\Models\Server;
use App\Models\StandalonePostgresql;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\File;
@@ -73,7 +76,7 @@ class Init extends Command
$this->cleanupUnusedNetworkFromCoolifyProxy();
try {
$this->call('cleanup:redis', ['--clear-locks' => true]);
$this->call('cleanup:redis', ['--restart' => true, '--clear-locks' => true]);
} catch (\Throwable $e) {
echo "Error in cleanup:redis command: {$e->getMessage()}\n";
}
@@ -86,6 +89,7 @@ class Init extends Command
$this->call('cleanup:stucked-resources');
} catch (\Throwable $e) {
echo "Error in cleanup:stucked-resources command: {$e->getMessage()}\n";
echo "Continuing with initialization - cleanup errors will not prevent Coolify from starting\n";
}
try {
$updatedCount = ApplicationDeploymentQueue::whereIn('status', [
@@ -102,6 +106,34 @@ class Init extends Command
echo "Could not cleanup inprogress deployments: {$e->getMessage()}\n";
}
try {
$updatedTaskCount = ScheduledTaskExecution::where('status', 'running')->update([
'status' => 'failed',
'message' => 'Marked as failed during Coolify startup - job was interrupted',
'finished_at' => Carbon::now(),
]);
if ($updatedTaskCount > 0) {
echo "Marked {$updatedTaskCount} stuck scheduled task executions as failed\n";
}
} catch (\Throwable $e) {
echo "Could not cleanup stuck scheduled task executions: {$e->getMessage()}\n";
}
try {
$updatedBackupCount = ScheduledDatabaseBackupExecution::where('status', 'running')->update([
'status' => 'failed',
'message' => 'Marked as failed during Coolify startup - job was interrupted',
'finished_at' => Carbon::now(),
]);
if ($updatedBackupCount > 0) {
echo "Marked {$updatedBackupCount} stuck database backup executions as failed\n";
}
} catch (\Throwable $e) {
echo "Could not cleanup stuck database backup executions: {$e->getMessage()}\n";
}
try {
$localhost = $this->servers->where('id', 0)->first();
if ($localhost) {
+402 -8
View File
@@ -16,7 +16,7 @@ class SyncBunny extends Command
*
* @var string
*/
protected $signature = 'sync:bunny {--templates} {--release} {--github-releases} {--nightly}';
protected $signature = 'sync:bunny {--templates} {--release} {--github-releases} {--github-versions} {--nightly}';
/**
* The console command description.
@@ -50,6 +50,7 @@ class SyncBunny extends Command
// Clone the repository
$this->info('Cloning coolify-cdn repository...');
$output = [];
exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to clone repository: '.implode("\n", $output));
@@ -59,6 +60,7 @@ class SyncBunny extends Command
// Create feature branch
$this->info('Creating feature branch...');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to create branch: '.implode("\n", $output));
@@ -70,12 +72,25 @@ class SyncBunny extends Command
// Write releases.json
$this->info('Writing releases.json...');
$releasesPath = "$tmpDir/json/releases.json";
$releasesDir = dirname($releasesPath);
// Ensure directory exists
if (! is_dir($releasesDir)) {
$this->info("Creating directory: $releasesDir");
if (! mkdir($releasesDir, 0755, true)) {
$this->error("Failed to create directory: $releasesDir");
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
}
$jsonContent = json_encode($releases, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
$bytesWritten = file_put_contents($releasesPath, $jsonContent);
if ($bytesWritten === false) {
$this->error("Failed to write releases.json to: $releasesPath");
$this->error('Possible reasons: directory does not exist, permission denied, or disk full.');
$this->error('Possible reasons: permission denied or disk full.');
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
@@ -83,6 +98,7 @@ class SyncBunny extends Command
// Stage and commit
$this->info('Committing changes...');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git add json/releases.json 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to stage changes: '.implode("\n", $output));
@@ -120,6 +136,7 @@ class SyncBunny extends Command
// Push to remote
$this->info('Pushing branch to remote...');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to push branch: '.implode("\n", $output));
@@ -133,6 +150,7 @@ class SyncBunny extends Command
$prTitle = 'Update releases.json - '.date('Y-m-d H:i:s');
$prBody = 'Automated update of releases.json with latest '.count($releases).' releases from GitHub API';
$prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1';
$output = [];
exec($prCommand, $output, $returnCode);
// Clean up
@@ -158,6 +176,343 @@ class SyncBunny extends Command
}
}
/**
* Sync both releases.json and versions.json to GitHub repository in one PR
*/
private function syncReleasesAndVersionsToGitHubRepo(string $versionsLocation, bool $nightly = false): bool
{
$this->info('Syncing releases.json and versions.json to GitHub repository...');
try {
// 1. Fetch releases from GitHub API
$this->info('Fetching releases from GitHub API...');
$response = Http::timeout(30)
->get('https://api.github.com/repos/coollabsio/coolify/releases', [
'per_page' => 30,
]);
if (! $response->successful()) {
$this->error('Failed to fetch releases from GitHub: '.$response->status());
return false;
}
$releases = $response->json();
// 2. Read versions.json
if (! file_exists($versionsLocation)) {
$this->error("versions.json not found at: $versionsLocation");
return false;
}
$file = file_get_contents($versionsLocation);
$versionsJson = json_decode($file, true);
$actualVersion = data_get($versionsJson, 'coolify.v4.version');
$timestamp = time();
$tmpDir = sys_get_temp_dir().'/coolify-cdn-combined-'.$timestamp;
$branchName = 'update-releases-and-versions-'.$timestamp;
$versionsTargetPath = $nightly ? 'json/versions-nightly.json' : 'json/versions.json';
// 3. Clone the repository
$this->info('Cloning coolify-cdn repository...');
$output = [];
exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to clone repository: '.implode("\n", $output));
return false;
}
// 4. Create feature branch
$this->info('Creating feature branch...');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to create branch: '.implode("\n", $output));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// 5. Write releases.json
$this->info('Writing releases.json...');
$releasesPath = "$tmpDir/json/releases.json";
$releasesDir = dirname($releasesPath);
if (! is_dir($releasesDir)) {
if (! mkdir($releasesDir, 0755, true)) {
$this->error("Failed to create directory: $releasesDir");
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
}
$releasesJsonContent = json_encode($releases, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
if (file_put_contents($releasesPath, $releasesJsonContent) === false) {
$this->error("Failed to write releases.json to: $releasesPath");
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// 6. Write versions.json
$this->info('Writing versions.json...');
$versionsPath = "$tmpDir/$versionsTargetPath";
$versionsDir = dirname($versionsPath);
if (! is_dir($versionsDir)) {
if (! mkdir($versionsDir, 0755, true)) {
$this->error("Failed to create directory: $versionsDir");
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
}
$versionsJsonContent = json_encode($versionsJson, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
if (file_put_contents($versionsPath, $versionsJsonContent) === false) {
$this->error("Failed to write versions.json to: $versionsPath");
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// 7. Stage both files
$this->info('Staging changes...');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git add json/releases.json '.escapeshellarg($versionsTargetPath).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to stage changes: '.implode("\n", $output));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// 8. Check for changes
$this->info('Checking for changes...');
$statusOutput = [];
exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain 2>&1', $statusOutput, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to check repository status: '.implode("\n", $statusOutput));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
if (empty(array_filter($statusOutput))) {
$this->info('Both files are already up to date. No changes to commit.');
exec('rm -rf '.escapeshellarg($tmpDir));
return true;
}
// 9. Commit changes
$envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
$commitMessage = "Update releases.json and $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to commit changes: '.implode("\n", $output));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// 10. Push to remote
$this->info('Pushing branch to remote...');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to push branch: '.implode("\n", $output));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// 11. Create pull request
$this->info('Creating pull request...');
$prTitle = "Update releases.json and $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s');
$prBody = "Automated update:\n- releases.json with latest ".count($releases)." releases from GitHub API\n- $envLabel versions.json to version $actualVersion";
$prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1';
$output = [];
exec($prCommand, $output, $returnCode);
// 12. Clean up
exec('rm -rf '.escapeshellarg($tmpDir));
if ($returnCode !== 0) {
$this->error('Failed to create PR: '.implode("\n", $output));
return false;
}
$this->info('Pull request created successfully!');
if (! empty($output)) {
$this->info('PR URL: '.implode("\n", $output));
}
$this->info("Version synced: $actualVersion");
$this->info('Total releases synced: '.count($releases));
return true;
} catch (\Throwable $e) {
$this->error('Error syncing to GitHub: '.$e->getMessage());
return false;
}
}
/**
* Sync versions.json to GitHub repository via PR
*/
private function syncVersionsToGitHubRepo(string $versionsLocation, bool $nightly = false): bool
{
$this->info('Syncing versions.json to GitHub repository...');
try {
if (! file_exists($versionsLocation)) {
$this->error("versions.json not found at: $versionsLocation");
return false;
}
$file = file_get_contents($versionsLocation);
$json = json_decode($file, true);
$actualVersion = data_get($json, 'coolify.v4.version');
$timestamp = time();
$tmpDir = sys_get_temp_dir().'/coolify-cdn-versions-'.$timestamp;
$branchName = 'update-versions-'.$timestamp;
$targetPath = $nightly ? 'json/versions-nightly.json' : 'json/versions.json';
// Clone the repository
$this->info('Cloning coolify-cdn repository...');
exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to clone repository: '.implode("\n", $output));
return false;
}
// Create feature branch
$this->info('Creating feature branch...');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to create branch: '.implode("\n", $output));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// Write versions.json
$this->info('Writing versions.json...');
$versionsPath = "$tmpDir/$targetPath";
$versionsDir = dirname($versionsPath);
// Ensure directory exists
if (! is_dir($versionsDir)) {
$this->info("Creating directory: $versionsDir");
if (! mkdir($versionsDir, 0755, true)) {
$this->error("Failed to create directory: $versionsDir");
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
}
$jsonContent = json_encode($json, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
$bytesWritten = file_put_contents($versionsPath, $jsonContent);
if ($bytesWritten === false) {
$this->error("Failed to write versions.json to: $versionsPath");
$this->error('Possible reasons: permission denied or disk full.');
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// Stage and commit
$this->info('Committing changes...');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git add '.escapeshellarg($targetPath).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to stage changes: '.implode("\n", $output));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
$this->info('Checking for changes...');
$statusOutput = [];
exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain '.escapeshellarg($targetPath).' 2>&1', $statusOutput, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to check repository status: '.implode("\n", $statusOutput));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
if (empty(array_filter($statusOutput))) {
$this->info('versions.json is already up to date. No changes to commit.');
exec('rm -rf '.escapeshellarg($tmpDir));
return true;
}
$envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
$commitMessage = "Update $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to commit changes: '.implode("\n", $output));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// Push to remote
$this->info('Pushing branch to remote...');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to push branch: '.implode("\n", $output));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// Create pull request
$this->info('Creating pull request...');
$prTitle = "Update $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s');
$prBody = "Automated update of $envLabel versions.json to version $actualVersion";
$output = [];
$prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1';
exec($prCommand, $output, $returnCode);
// Clean up
exec('rm -rf '.escapeshellarg($tmpDir));
if ($returnCode !== 0) {
$this->error('Failed to create PR: '.implode("\n", $output));
return false;
}
$this->info('Pull request created successfully!');
if (! empty($output)) {
$this->info('PR URL: '.implode("\n", $output));
}
$this->info("Version synced: $actualVersion");
return true;
} catch (\Throwable $e) {
$this->error('Error syncing versions.json: '.$e->getMessage());
return false;
}
}
/**
* Execute the console command.
*/
@@ -167,6 +522,7 @@ class SyncBunny extends Command
$only_template = $this->option('templates');
$only_version = $this->option('release');
$only_github_releases = $this->option('github-releases');
$only_github_versions = $this->option('github-versions');
$nightly = $this->option('nightly');
$bunny_cdn = 'https://cdn.coollabs.io';
$bunny_cdn_path = 'coolify';
@@ -224,7 +580,7 @@ class SyncBunny extends Command
$install_script_location = "$parent_dir/other/nightly/$install_script";
$versions_location = "$parent_dir/other/nightly/$versions";
}
if (! $only_template && ! $only_version && ! $only_github_releases) {
if (! $only_template && ! $only_version && ! $only_github_releases && ! $only_github_versions) {
if ($nightly) {
$this->info('About to sync files NIGHTLY (docker-compose.prod.yaml, upgrade.sh, install.sh, etc) to BunnyCDN.');
} else {
@@ -250,25 +606,47 @@ class SyncBunny extends Command
return;
} elseif ($only_version) {
if ($nightly) {
$this->info('About to sync NIGHLTY versions.json to BunnyCDN.');
$this->info('About to sync NIGHTLY versions.json to BunnyCDN and create GitHub PR.');
} else {
$this->info('About to sync PRODUCTION versions.json to BunnyCDN.');
$this->info('About to sync PRODUCTION versions.json to BunnyCDN and create GitHub PR.');
}
$file = file_get_contents($versions_location);
$json = json_decode($file, true);
$actual_version = data_get($json, 'coolify.v4.version');
$confirmed = confirm("Are you sure you want to sync to {$actual_version}?");
$this->info("Version: {$actual_version}");
$this->info('This will:');
$this->info(' 1. Sync versions.json to BunnyCDN (deprecated but still supported)');
$this->info(' 2. Create ONE GitHub PR with both releases.json and versions.json');
$this->newLine();
$confirmed = confirm('Are you sure you want to proceed?');
if (! $confirmed) {
return;
}
// Sync versions.json to BunnyCDN
// 1. Sync versions.json to BunnyCDN (deprecated but still needed)
$this->info('Step 1/2: Syncing versions.json to BunnyCDN...');
Http::pool(fn (Pool $pool) => [
$pool->storage(fileName: $versions_location)->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$versions"),
$pool->purge("$bunny_cdn/$bunny_cdn_path/$versions"),
]);
$this->info('versions.json uploaded & purged...');
$this->info('✓ versions.json uploaded & purged to BunnyCDN');
$this->newLine();
// 2. Create GitHub PR with both releases.json and versions.json
$this->info('Step 2/2: Creating GitHub PR with releases.json and versions.json...');
$githubSuccess = $this->syncReleasesAndVersionsToGitHubRepo($versions_location, $nightly);
if ($githubSuccess) {
$this->info('✓ GitHub PR created successfully with both files');
} else {
$this->error('✗ Failed to create GitHub PR');
}
$this->newLine();
$this->info('=== Summary ===');
$this->info('BunnyCDN sync: ✓ Complete');
$this->info('GitHub PR: '.($githubSuccess ? '✓ Created (releases.json + versions.json)' : '✗ Failed'));
return;
} elseif ($only_github_releases) {
@@ -281,6 +659,22 @@ class SyncBunny extends Command
// Sync releases to GitHub repository
$this->syncReleasesToGitHubRepo();
return;
} elseif ($only_github_versions) {
$envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
$file = file_get_contents($versions_location);
$json = json_decode($file, true);
$actual_version = data_get($json, 'coolify.v4.version');
$this->info("About to sync $envLabel versions.json ($actual_version) to GitHub repository.");
$confirmed = confirm('Are you sure you want to sync versions.json via GitHub PR?');
if (! $confirmed) {
return;
}
// Sync versions.json to GitHub repository
$this->syncVersionsToGitHubRepo($versions_location, $nightly);
return;
}
+3
View File
@@ -5,6 +5,7 @@ namespace App\Console;
use App\Jobs\CheckAndStartSentinelJob;
use App\Jobs\CheckForUpdatesJob;
use App\Jobs\CheckHelperImageJob;
use App\Jobs\CheckTraefikVersionJob;
use App\Jobs\CleanupInstanceStuffsJob;
use App\Jobs\PullChangelog;
use App\Jobs\PullTemplatesFromCDN;
@@ -83,6 +84,8 @@ class Kernel extends ConsoleKernel
$this->scheduleInstance->job(new RegenerateSslCertJob)->twiceDaily();
$this->scheduleInstance->job(new CheckTraefikVersionJob)->weekly()->sundays()->at('00:00')->timezone($this->instanceTimezone)->onOneServer();
$this->scheduleInstance->command('cleanup:database --yes')->daily();
$this->scheduleInstance->command('uploads:clear')->everyTwoMinutes();
}
+3 -1
View File
@@ -10,6 +10,8 @@ class ServerMetadata extends Data
{
public function __construct(
public ?ProxyTypes $type,
public ?ProxyStatus $status
public ?ProxyStatus $status,
public ?string $last_saved_settings = null,
public ?string $last_applied_settings = null
) {}
}
+17 -11
View File
@@ -17,17 +17,23 @@ class RestoreJobFinished
$tmpPath = data_get($data, 'tmpPath');
$container = data_get($data, 'container');
$serverId = data_get($data, 'serverId');
if (filled($scriptPath) && filled($tmpPath) && filled($container) && filled($serverId)) {
if (str($tmpPath)->startsWith('/tmp/')
&& str($scriptPath)->startsWith('/tmp/')
&& ! str($tmpPath)->contains('..')
&& ! str($scriptPath)->contains('..')
&& strlen($tmpPath) > 5 // longer than just "/tmp/"
&& strlen($scriptPath) > 5
) {
$commands[] = "docker exec {$container} sh -c 'rm {$scriptPath}'";
$commands[] = "docker exec {$container} sh -c 'rm {$tmpPath}'";
instant_remote_process($commands, Server::find($serverId), throwError: true);
if (filled($container) && filled($serverId)) {
$commands = [];
if (isSafeTmpPath($scriptPath)) {
$commands[] = 'docker exec '.escapeshellarg($container)." sh -c 'rm ".escapeshellarg($scriptPath)." 2>/dev/null || true'";
}
if (isSafeTmpPath($tmpPath)) {
$commands[] = 'docker exec '.escapeshellarg($container)." sh -c 'rm ".escapeshellarg($tmpPath)." 2>/dev/null || true'";
}
if (! empty($commands)) {
$server = Server::find($serverId);
if ($server) {
instant_remote_process($commands, $server, throwError: false);
}
}
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace App\Events;
use App\Models\Server;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class S3RestoreJobFinished
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct($data)
{
$containerName = data_get($data, 'containerName');
$serverTmpPath = data_get($data, 'serverTmpPath');
$scriptPath = data_get($data, 'scriptPath');
$containerTmpPath = data_get($data, 'containerTmpPath');
$container = data_get($data, 'container');
$serverId = data_get($data, 'serverId');
// Most cleanup now happens inline during restore process
// This acts as a safety net for edge cases (errors, interruptions)
if (filled($serverId)) {
$commands = [];
// Ensure helper container is removed (may already be gone from inline cleanup)
if (filled($containerName)) {
$commands[] = 'docker rm -f '.escapeshellarg($containerName).' 2>/dev/null || true';
}
// Clean up server temp file if still exists (should already be cleaned)
if (isSafeTmpPath($serverTmpPath)) {
$commands[] = 'rm -f '.escapeshellarg($serverTmpPath).' 2>/dev/null || true';
}
// Clean up any remaining files in database container (may already be cleaned)
if (filled($container)) {
if (isSafeTmpPath($containerTmpPath)) {
$commands[] = 'docker exec '.escapeshellarg($container).' rm -f '.escapeshellarg($containerTmpPath).' 2>/dev/null || true';
}
if (isSafeTmpPath($scriptPath)) {
$commands[] = 'docker exec '.escapeshellarg($container).' rm -f '.escapeshellarg($scriptPath).' 2>/dev/null || true';
}
}
if (! empty($commands)) {
$server = Server::find($serverId);
if ($server) {
instant_remote_process($commands, $server, throwError: false);
}
}
}
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Exceptions;
use Exception;
/**
* Exception for expected deployment failures caused by user/application errors.
* These are not Coolify bugs and should not be logged to laravel.log.
* Examples: Nixpacks detection failures, missing Dockerfiles, invalid configs, etc.
*/
class DeploymentException extends Exception
{
/**
* Create a new deployment exception instance.
*
* @param string $message
* @param int $code
*/
public function __construct($message = '', $code = 0, ?\Throwable $previous = null)
{
parent::__construct($message, $code, $previous);
}
/**
* Create from another exception, preserving its message and stack trace.
*/
public static function fromException(\Throwable $exception): static
{
return new static($exception->getMessage(), $exception->getCode(), $exception);
}
}
+1
View File
@@ -30,6 +30,7 @@ class Handler extends ExceptionHandler
protected $dontReport = [
ProcessException::class,
NonReportableException::class,
DeploymentException::class,
];
/**
@@ -351,7 +351,7 @@ class ServicesController extends Controller
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
];
if ($oneClickServiceName === 'cloudflared') {
if ($oneClickServiceName === 'pgadmin') {
data_set($servicePayload, 'connect_to_docker_network', true);
}
$service = Service::create($servicePayload);
+70
View File
@@ -246,6 +246,40 @@ class Github extends Controller
if ($action === 'closed') {
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
if ($found) {
// Cancel any active deployments for this PR immediately
$activeDeployment = \App\Models\ApplicationDeploymentQueue::where('application_id', $application->id)
->where('pull_request_id', $pull_request_id)
->whereIn('status', [
\App\Enums\ApplicationDeploymentStatus::QUEUED->value,
\App\Enums\ApplicationDeploymentStatus::IN_PROGRESS->value,
])
->first();
if ($activeDeployment) {
try {
// Mark deployment as cancelled
$activeDeployment->update([
'status' => \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
]);
// Add cancellation log entry
$activeDeployment->addLogEntry('Deployment cancelled: Pull request closed.', 'stderr');
// Check if helper container exists and kill it
$deployment_uuid = $activeDeployment->deployment_uuid;
$server = $application->destination->server;
$checkCommand = "docker ps -a --filter name={$deployment_uuid} --format '{{.Names}}'";
$containerExists = instant_remote_process([$checkCommand], $server);
if ($containerExists && str($containerExists)->trim()->isNotEmpty()) {
instant_remote_process(["docker rm -f {$deployment_uuid}"], $server);
$activeDeployment->addLogEntry('Deployment container stopped.');
}
} catch (\Throwable $e) {
// Silently handle errors during deployment cancellation
}
}
DeleteResourceJob::dispatch($found);
$return_payloads->push([
'application' => $application->name,
@@ -481,6 +515,42 @@ class Github extends Controller
if ($action === 'closed' || $action === 'close') {
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
if ($found) {
// Cancel any active deployments for this PR immediately
$activeDeployment = \App\Models\ApplicationDeploymentQueue::where('application_id', $application->id)
->where('pull_request_id', $pull_request_id)
->whereIn('status', [
\App\Enums\ApplicationDeploymentStatus::QUEUED->value,
\App\Enums\ApplicationDeploymentStatus::IN_PROGRESS->value,
])
->first();
if ($activeDeployment) {
try {
// Mark deployment as cancelled
$activeDeployment->update([
'status' => \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
]);
// Add cancellation log entry
$activeDeployment->addLogEntry('Deployment cancelled: Pull request closed.', 'stderr');
// Check if helper container exists and kill it
$deployment_uuid = $activeDeployment->deployment_uuid;
$server = $application->destination->server;
$checkCommand = "docker ps -a --filter name={$deployment_uuid} --format '{{.Names}}'";
$containerExists = instant_remote_process([$checkCommand], $server);
if ($containerExists && str($containerExists)->trim()->isNotEmpty()) {
instant_remote_process(["docker rm -f {$deployment_uuid}"], $server);
$activeDeployment->addLogEntry('Deployment container stopped.');
}
} catch (\Throwable $e) {
// Silently handle errors during deployment cancellation
}
}
// Clean up any deployed containers
$containers = getCurrentApplicationContainerStatus($application->destination->server, $application->id, $pull_request_id);
if ($containers->isNotEmpty()) {
$containers->each(function ($container) use ($application) {
File diff suppressed because it is too large. Load diff
+3
View File
@@ -33,6 +33,9 @@ class CheckForUpdatesJob implements ShouldBeEncrypted, ShouldQueue
// New version available
$settings->update(['new_version_available' => true]);
File::put(base_path('versions.json'), json_encode($versions, JSON_PRETTY_PRINT));
// Invalidate cache to ensure fresh data is loaded
invalidate_versions_cache();
} else {
$settings->update(['new_version_available' => false]);
}
@@ -0,0 +1,173 @@
<?php
namespace App\Jobs;
use App\Models\Server;
use App\Notifications\Server\TraefikVersionOutdated;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class CheckTraefikVersionForServerJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $tries = 3;
public $timeout = 60;
/**
* Create a new job instance.
*/
public function __construct(
public Server $server,
public array $traefikVersions
) {}
/**
* Execute the job.
*/
public function handle(): void
{
// Detect current version (makes SSH call)
$currentVersion = getTraefikVersionFromDockerCompose($this->server);
// Update detected version in database
$this->server->update(['detected_traefik_version' => $currentVersion]);
if (! $currentVersion) {
return;
}
// Check if image tag is 'latest' by inspecting the image (makes SSH call)
$imageTag = instant_remote_process([
"docker inspect coolify-proxy --format '{{.Config.Image}}' 2>/dev/null",
], $this->server, false);
// Handle empty/null response from SSH command
if (empty(trim($imageTag))) {
return;
}
if (str_contains(strtolower(trim($imageTag)), ':latest')) {
return;
}
// Parse current version to extract major.minor.patch
$current = ltrim($currentVersion, 'v');
if (! preg_match('/^(\d+\.\d+)\.(\d+)$/', $current, $matches)) {
return;
}
$currentBranch = $matches[1]; // e.g., "3.6"
// Find the latest version for this branch
$latestForBranch = $this->traefikVersions["v{$currentBranch}"] ?? null;
if (! $latestForBranch) {
// User is on a branch we don't track - check if newer branches exist
$newerBranchInfo = $this->getNewerBranchInfo($currentBranch);
if ($newerBranchInfo) {
$this->storeOutdatedInfo($current, $newerBranchInfo['latest'], 'minor_upgrade', $newerBranchInfo['target']);
} else {
// No newer branch found, clear outdated info
$this->server->update(['traefik_outdated_info' => null]);
}
return;
}
// Compare patch version within the same branch
$latest = ltrim($latestForBranch, 'v');
// Always check for newer branches first
$newerBranchInfo = $this->getNewerBranchInfo($currentBranch);
if (version_compare($current, $latest, '<')) {
// Patch update available
$this->storeOutdatedInfo($current, $latest, 'patch_update', null, $newerBranchInfo);
} elseif ($newerBranchInfo) {
// Only newer branch available (no patch update)
$this->storeOutdatedInfo($current, $newerBranchInfo['latest'], 'minor_upgrade', $newerBranchInfo['target']);
} else {
// Fully up to date
$this->server->update(['traefik_outdated_info' => null]);
}
}
/**
* Get information about newer branches if available.
*/
private function getNewerBranchInfo(string $currentBranch): ?array
{
$newestBranch = null;
$newestVersion = null;
foreach ($this->traefikVersions as $branch => $version) {
$branchNum = ltrim($branch, 'v');
if (version_compare($branchNum, $currentBranch, '>')) {
if (! $newestVersion || version_compare($version, $newestVersion, '>')) {
$newestBranch = $branchNum;
$newestVersion = $version;
}
}
}
if ($newestVersion) {
return [
'target' => "v{$newestBranch}",
'latest' => ltrim($newestVersion, 'v'),
];
}
return null;
}
/**
* Store outdated information in database and send immediate notification.
*/
private function storeOutdatedInfo(string $current, string $latest, string $type, ?string $upgradeTarget = null, ?array $newerBranchInfo = null): void
{
$outdatedInfo = [
'current' => $current,
'latest' => $latest,
'type' => $type,
'checked_at' => now()->toIso8601String(),
];
// For minor upgrades, add the upgrade_target field (e.g., "v3.6")
if ($type === 'minor_upgrade' && $upgradeTarget) {
$outdatedInfo['upgrade_target'] = $upgradeTarget;
}
// If there's a newer branch available (even for patch updates), include that info
if ($newerBranchInfo) {
$outdatedInfo['newer_branch_target'] = $newerBranchInfo['target'];
$outdatedInfo['newer_branch_latest'] = $newerBranchInfo['latest'];
}
$this->server->update(['traefik_outdated_info' => $outdatedInfo]);
// Send immediate notification to the team
$this->sendNotification($outdatedInfo);
}
/**
* Send notification to team about outdated Traefik.
*/
private function sendNotification(array $outdatedInfo): void
{
// Attach the outdated info as a dynamic property for the notification
$this->server->outdatedInfo = $outdatedInfo;
// Get the team and send notification
$team = $this->server->team()->first();
if ($team) {
$team->notify(new TraefikVersionOutdated(collect([$this->server])));
}
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace App\Jobs;
use App\Enums\ProxyTypes;
use App\Models\Server;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class CheckTraefikVersionJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $tries = 3;
public function handle(): void
{
// Load versions from cached data
$traefikVersions = get_traefik_versions();
if (empty($traefikVersions)) {
return;
}
// Query all servers with Traefik proxy that are reachable
$servers = Server::whereNotNull('proxy')
->whereProxyType(ProxyTypes::TRAEFIK->value)
->whereRelation('settings', 'is_reachable', true)
->whereRelation('settings', 'is_usable', true)
->get();
if ($servers->isEmpty()) {
return;
}
// Dispatch individual server check jobs in parallel
// Each job will send immediate notifications when outdated Traefik is detected
foreach ($servers as $server) {
CheckTraefikVersionForServerJob::dispatch($server, $traefikVersions);
}
}
}
+46 -3
View File
@@ -2,6 +2,8 @@
namespace App\Jobs;
use App\Enums\ApplicationDeploymentStatus;
use App\Models\ApplicationDeploymentQueue;
use App\Models\Server;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
@@ -20,10 +22,51 @@ class CleanupHelperContainersJob implements ShouldBeEncrypted, ShouldBeUnique, S
public function handle(): void
{
try {
// Get all active deployments on this server
$activeDeployments = ApplicationDeploymentQueue::where('server_id', $this->server->id)
->whereIn('status', [
ApplicationDeploymentStatus::IN_PROGRESS->value,
ApplicationDeploymentStatus::QUEUED->value,
])
->pluck('deployment_uuid')
->toArray();
\Log::info('CleanupHelperContainersJob - Active deployments', [
'server' => $this->server->name,
'active_deployment_uuids' => $activeDeployments,
]);
$containers = instant_remote_process_with_timeout(['docker container ps --format \'{{json .}}\' | jq -s \'map(select(.Image | contains("'.config('constants.coolify.registry_url').'/coollabsio/coolify-helper")))\''], $this->server, false);
$containerIds = collect(json_decode($containers))->pluck('ID');
if ($containerIds->count() > 0) {
foreach ($containerIds as $containerId) {
$helperContainers = collect(json_decode($containers));
if ($helperContainers->count() > 0) {
foreach ($helperContainers as $container) {
$containerId = data_get($container, 'ID');
$containerName = data_get($container, 'Names');
// Check if this container belongs to an active deployment
$isActiveDeployment = false;
foreach ($activeDeployments as $deploymentUuid) {
if (str_contains($containerName, $deploymentUuid)) {
$isActiveDeployment = true;
break;
}
}
if ($isActiveDeployment) {
\Log::info('CleanupHelperContainersJob - Skipping active deployment container', [
'container' => $containerName,
'id' => $containerId,
]);
continue;
}
\Log::info('CleanupHelperContainersJob - Removing orphaned helper container', [
'container' => $containerName,
'id' => $containerId,
]);
instant_remote_process_with_timeout(['docker container rm -f '.$containerId], $this->server, false);
}
}
+66
View File
@@ -3,18 +3,35 @@
namespace App\Jobs;
use App\Actions\CoolifyTask\RunRemoteProcess;
use App\Enums\ProcessStatus;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Spatie\Activitylog\Models\Activity;
class CoolifyTask implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* The number of times the job may be attempted.
*/
public $tries = 3;
/**
* The maximum number of unhandled exceptions to allow before failing.
*/
public $maxExceptions = 1;
/**
* The number of seconds the job can run before timing out.
*/
public $timeout = 600;
/**
* Create a new job instance.
*/
@@ -42,4 +59,53 @@ class CoolifyTask implements ShouldBeEncrypted, ShouldQueue
$remote_process();
}
/**
* Calculate the number of seconds to wait before retrying the job.
*/
public function backoff(): array
{
return [30, 90, 180]; // 30s, 90s, 180s between retries
}
/**
* Handle a job failure.
*/
public function failed(?\Throwable $exception): void
{
Log::channel('scheduled-errors')->error('CoolifyTask permanently failed', [
'job' => 'CoolifyTask',
'activity_id' => $this->activity->id,
'server_uuid' => $this->activity->getExtraProperty('server_uuid'),
'command_preview' => substr($this->activity->getExtraProperty('command') ?? '', 0, 200),
'error' => $exception?->getMessage(),
'total_attempts' => $this->attempts(),
'trace' => $exception?->getTraceAsString(),
]);
// Update activity status to reflect permanent failure
$this->activity->properties = $this->activity->properties->merge([
'status' => ProcessStatus::ERROR->value,
'error' => $exception?->getMessage() ?? 'Job permanently failed',
'failed_at' => now()->toIso8601String(),
]);
$this->activity->save();
// Dispatch cleanup event on failure (same as on success)
if ($this->call_event_on_finish) {
try {
$eventClass = "App\\Events\\$this->call_event_on_finish";
if (! is_null($this->call_event_data)) {
event(new $eventClass($this->call_event_data));
} else {
event(new $eventClass($this->activity->causer_id));
}
Log::info('Cleanup event dispatched after job failure', [
'event' => $this->call_event_on_finish,
]);
} catch (\Throwable $e) {
Log::error('Error dispatching cleanup event on failure: '.$e->getMessage());
}
}
}
}
+50 -8
View File
@@ -23,6 +23,7 @@ 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\Str;
use Throwable;
use Visus\Cuid2\Cuid2;
@@ -31,6 +32,8 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $maxExceptions = 1;
public ?Team $team = null;
public Server $server;
@@ -74,7 +77,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
public function __construct(public ScheduledDatabaseBackup $backup)
{
$this->onQueue('high');
$this->timeout = $backup->timeout;
$this->timeout = $backup->timeout ?? 3600;
}
public function handle(): void
@@ -486,17 +489,22 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
$collectionsToExclude = collect();
}
$commands[] = 'mkdir -p '.$this->backup_dir;
// Validate and escape database name to prevent command injection
validateShellSafePath($databaseName, 'database name');
$escapedDatabaseName = escapeshellarg($databaseName);
if ($collectionsToExclude->count() === 0) {
if (str($this->database->image)->startsWith('mongo:4')) {
$commands[] = "docker exec $this->container_name mongodump --uri=\"$url\" --gzip --archive > $this->backup_location";
} else {
$commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --db $databaseName --gzip --archive > $this->backup_location";
$commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --db $escapedDatabaseName --gzip --archive > $this->backup_location";
}
} else {
if (str($this->database->image)->startsWith('mongo:4')) {
$commands[] = "docker exec $this->container_name mongodump --uri=$url --gzip --excludeCollection ".$collectionsToExclude->implode(' --excludeCollection ')." --archive > $this->backup_location";
} else {
$commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --db $databaseName --gzip --excludeCollection ".$collectionsToExclude->implode(' --excludeCollection ')." --archive > $this->backup_location";
$commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --db $escapedDatabaseName --gzip --excludeCollection ".$collectionsToExclude->implode(' --excludeCollection ')." --archive > $this->backup_location";
}
}
}
@@ -522,7 +530,10 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
if ($this->backup->dump_all) {
$backupCommand .= " $this->container_name pg_dumpall --username {$this->database->postgres_user} | gzip > $this->backup_location";
} else {
$backupCommand .= " $this->container_name pg_dump --format=custom --no-acl --no-owner --username {$this->database->postgres_user} $database > $this->backup_location";
// Validate and escape database name to prevent command injection
validateShellSafePath($database, 'database name');
$escapedDatabase = escapeshellarg($database);
$backupCommand .= " $this->container_name pg_dump --format=custom --no-acl --no-owner --username {$this->database->postgres_user} $escapedDatabase > $this->backup_location";
}
$commands[] = $backupCommand;
@@ -544,7 +555,10 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
if ($this->backup->dump_all) {
$commands[] = "docker exec $this->container_name mysqldump -u root -p\"{$this->database->mysql_root_password}\" --all-databases --single-transaction --quick --lock-tables=false --compress | gzip > $this->backup_location";
} else {
$commands[] = "docker exec $this->container_name mysqldump -u root -p\"{$this->database->mysql_root_password}\" $database > $this->backup_location";
// Validate and escape database name to prevent command injection
validateShellSafePath($database, 'database name');
$escapedDatabase = escapeshellarg($database);
$commands[] = "docker exec $this->container_name mysqldump -u root -p\"{$this->database->mysql_root_password}\" $escapedDatabase > $this->backup_location";
}
$this->backup_output = instant_remote_process($commands, $this->server);
$this->backup_output = trim($this->backup_output);
@@ -564,7 +578,10 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
if ($this->backup->dump_all) {
$commands[] = "docker exec $this->container_name mariadb-dump -u root -p\"{$this->database->mariadb_root_password}\" --all-databases --single-transaction --quick --lock-tables=false --compress > $this->backup_location";
} else {
$commands[] = "docker exec $this->container_name mariadb-dump -u root -p\"{$this->database->mariadb_root_password}\" $database > $this->backup_location";
// Validate and escape database name to prevent command injection
validateShellSafePath($database, 'database name');
$escapedDatabase = escapeshellarg($database);
$commands[] = "docker exec $this->container_name mariadb-dump -u root -p\"{$this->database->mariadb_root_password}\" $escapedDatabase > $this->backup_location";
}
$this->backup_output = instant_remote_process($commands, $this->server);
$this->backup_output = trim($this->backup_output);
@@ -636,7 +653,13 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
} else {
$commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup_log_uuid} --rm -v $this->backup_location:$this->backup_location:ro {$fullImageName}";
}
$commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc alias set temporary {$endpoint} {$key} \"{$secret}\"";
// Escape S3 credentials to prevent command injection
$escapedEndpoint = escapeshellarg($endpoint);
$escapedKey = escapeshellarg($key);
$escapedSecret = escapeshellarg($secret);
$commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc alias set temporary {$escapedEndpoint} {$escapedKey} {$escapedSecret}";
$commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc cp $this->backup_location temporary/$bucket{$this->backup_dir}/";
instant_remote_process($commands, $this->server);
@@ -661,15 +684,34 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
public function failed(?Throwable $exception): void
{
Log::channel('scheduled-errors')->error('DatabaseBackup permanently failed', [
'job' => 'DatabaseBackupJob',
'backup_id' => $this->backup->uuid,
'database' => $this->database?->name ?? 'unknown',
'database_type' => get_class($this->database ?? new \stdClass),
'server' => $this->server?->name ?? 'unknown',
'total_attempts' => $this->attempts(),
'error' => $exception?->getMessage(),
'trace' => $exception?->getTraceAsString(),
]);
$log = ScheduledDatabaseBackupExecution::where('uuid', $this->backup_log_uuid)->first();
if ($log) {
$log->update([
'status' => 'failed',
'message' => 'Job failed: '.($exception?->getMessage() ?? 'Unknown error'),
'message' => 'Job permanently failed after '.$this->attempts().' attempts: '.($exception?->getMessage() ?? 'Unknown error'),
'size' => 0,
'filename' => null,
'finished_at' => Carbon::now(),
]);
}
// Notify team about permanent failure
if ($this->team) {
$databaseName = $log?->database_name ?? 'unknown';
$output = $this->backup_output ?? $exception?->getMessage() ?? 'Unknown error';
$this->team->notify(new BackupFailed($this->backup, $this->database, $output, $databaseName));
}
}
}
+40 -3
View File
@@ -124,16 +124,54 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue
$this->resource->delete();
}
// Cancel any active deployments for this PR (same logic as API cancel_deployment)
$activeDeployments = \App\Models\ApplicationDeploymentQueue::where('application_id', $application->id)
->where('pull_request_id', $pull_request_id)
->whereIn('status', [
\App\Enums\ApplicationDeploymentStatus::QUEUED->value,
\App\Enums\ApplicationDeploymentStatus::IN_PROGRESS->value,
])
->get();
foreach ($activeDeployments as $activeDeployment) {
try {
// Mark deployment as cancelled
$activeDeployment->update([
'status' => \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
]);
// Add cancellation log entry
$activeDeployment->addLogEntry('Deployment cancelled: Pull request closed.', 'stderr');
// Check if helper container exists and kill it
$deployment_uuid = $activeDeployment->deployment_uuid;
$escapedDeploymentUuid = escapeshellarg($deployment_uuid);
$checkCommand = "docker ps -a --filter name={$escapedDeploymentUuid} --format '{{.Names}}'";
$containerExists = instant_remote_process([$checkCommand], $server);
if ($containerExists && str($containerExists)->trim()->isNotEmpty()) {
instant_remote_process(["docker rm -f {$escapedDeploymentUuid}"], $server);
$activeDeployment->addLogEntry('Deployment container stopped.');
} else {
$activeDeployment->addLogEntry('Helper container not yet started. Deployment will be cancelled when job checks status.');
}
} catch (\Throwable $e) {
// Silently handle errors during deployment cancellation
}
}
try {
if ($server->isSwarm()) {
instant_remote_process(["docker stack rm {$application->uuid}-{$pull_request_id}"], $server);
$escapedStackName = escapeshellarg("{$application->uuid}-{$pull_request_id}");
instant_remote_process(["docker stack rm {$escapedStackName}"], $server);
} else {
$containers = getCurrentApplicationContainerStatus($server, $application->id, $pull_request_id)->toArray();
$this->stopPreviewContainers($containers, $server);
}
} catch (\Throwable $e) {
// Log the error but don't fail the job
ray('Error stopping preview containers: '.$e->getMessage());
\Log::warning('Error stopping preview containers for application '.$application->uuid.', PR #'.$pull_request_id.': '.$e->getMessage());
}
// Finally, force delete to trigger resource cleanup
@@ -156,7 +194,6 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue
"docker stop -t $timeout $containerList",
"docker rm -f $containerList",
];
instant_remote_process(
command: $commands,
server: $server,
-30
View File
@@ -1,30 +0,0 @@
<?php
namespace App\Jobs;
use App\Models\Server;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class PullHelperImageJob implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $timeout = 1000;
public function __construct(public Server $server)
{
$this->onQueue('high');
}
public function handle(): void
{
$helperImage = config('constants.coolify.helper_image');
$latest_version = getHelperVersion();
instant_remote_process(["docker pull -q {$helperImage}:{$latest_version}"], $this->server, false);
}
}
+173 -97
View File
@@ -13,6 +13,8 @@ use App\Models\Server;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use App\Notifications\Container\ContainerRestarted;
use App\Services\ContainerStatusAggregator;
use App\Traits\CalculatesExcludedStatus;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
@@ -25,6 +27,7 @@ use Laravel\Horizon\Contracts\Silenced;
class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
{
use CalculatesExcludedStatus;
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $tries = 1;
@@ -67,6 +70,8 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
public Collection $applicationContainerStatuses;
public Collection $serviceContainerStatuses;
public bool $foundProxy = false;
public bool $foundLogDrainContainer = false;
@@ -90,6 +95,7 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
$this->foundApplicationPreviewsIds = collect();
$this->foundServiceDatabaseIds = collect();
$this->applicationContainerStatuses = collect();
$this->serviceContainerStatuses = collect();
$this->allApplicationIds = collect();
$this->allDatabaseUuids = collect();
$this->allTcpProxyUuids = collect();
@@ -99,6 +105,20 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
public function handle()
{
// Defensive initialization for Collection properties to handle queue deserialization edge cases
$this->serviceContainerStatuses ??= collect();
$this->applicationContainerStatuses ??= collect();
$this->foundApplicationIds ??= collect();
$this->foundDatabaseUuids ??= collect();
$this->foundServiceApplicationIds ??= collect();
$this->foundApplicationPreviewsIds ??= collect();
$this->foundServiceDatabaseIds ??= collect();
$this->allApplicationIds ??= collect();
$this->allDatabaseUuids ??= collect();
$this->allTcpProxyUuids ??= collect();
$this->allServiceApplicationIds ??= collect();
$this->allServiceDatabaseIds ??= collect();
// TODO: Swarm is not supported yet
if (! $this->data) {
throw new \Exception('No data provided');
@@ -108,7 +128,6 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
$this->server->sentinelHeartbeat();
$this->containers = collect(data_get($data, 'containers'));
$filesystemUsageRoot = data_get($data, 'filesystem_usage_root.used_percentage');
ServerStorageCheckJob::dispatch($this->server, $filesystemUsageRoot);
@@ -141,65 +160,88 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
foreach ($this->containers as $container) {
$containerStatus = data_get($container, 'state', 'exited');
$containerHealth = data_get($container, 'health_status', 'unhealthy');
$containerStatus = "$containerStatus ($containerHealth)";
$rawHealthStatus = data_get($container, 'health_status');
$containerHealth = $rawHealthStatus ?? 'unknown';
// Only append health status if container is not exited
if ($containerStatus !== 'exited') {
$containerStatus = "$containerStatus:$containerHealth";
}
$labels = collect(data_get($container, 'labels'));
$coolify_managed = $labels->has('coolify.managed');
if ($coolify_managed) {
$name = data_get($container, 'name');
if ($name === 'coolify-log-drain' && $this->isRunning($containerStatus)) {
$this->foundLogDrainContainer = true;
}
if ($labels->has('coolify.applicationId')) {
$applicationId = $labels->get('coolify.applicationId');
$pullRequestId = $labels->get('coolify.pullRequestId', '0');
try {
if ($pullRequestId === '0') {
if ($this->allApplicationIds->contains($applicationId) && $this->isRunning($containerStatus)) {
$this->foundApplicationIds->push($applicationId);
}
// Store container status for aggregation
if (! $this->applicationContainerStatuses->has($applicationId)) {
$this->applicationContainerStatuses->put($applicationId, collect());
}
$containerName = $labels->get('com.docker.compose.service');
if ($containerName) {
$this->applicationContainerStatuses->get($applicationId)->put($containerName, $containerStatus);
}
} else {
$previewKey = $applicationId.':'.$pullRequestId;
if ($this->allApplicationPreviewsIds->contains($previewKey) && $this->isRunning($containerStatus)) {
$this->foundApplicationPreviewsIds->push($previewKey);
}
$this->updateApplicationPreviewStatus($applicationId, $pullRequestId, $containerStatus);
if (! $coolify_managed) {
continue;
}
$name = data_get($container, 'name');
if ($name === 'coolify-log-drain' && $this->isRunning($containerStatus)) {
$this->foundLogDrainContainer = true;
}
if ($labels->has('coolify.applicationId')) {
$applicationId = $labels->get('coolify.applicationId');
$pullRequestId = $labels->get('coolify.pullRequestId', '0');
try {
if ($pullRequestId === '0') {
if ($this->allApplicationIds->contains($applicationId)) {
$this->foundApplicationIds->push($applicationId);
}
// Store container status for aggregation
if (! $this->applicationContainerStatuses->has($applicationId)) {
$this->applicationContainerStatuses->put($applicationId, collect());
}
$containerName = $labels->get('com.docker.compose.service');
if ($containerName) {
$this->applicationContainerStatuses->get($applicationId)->put($containerName, $containerStatus);
}
} catch (\Exception $e) {
}
} elseif ($labels->has('coolify.serviceId')) {
$serviceId = $labels->get('coolify.serviceId');
$subType = $labels->get('coolify.service.subType');
$subId = $labels->get('coolify.service.subId');
if ($subType === 'application' && $this->isRunning($containerStatus)) {
$this->foundServiceApplicationIds->push($subId);
$this->updateServiceSubStatus($serviceId, $subType, $subId, $containerStatus);
} elseif ($subType === 'database' && $this->isRunning($containerStatus)) {
$this->foundServiceDatabaseIds->push($subId);
$this->updateServiceSubStatus($serviceId, $subType, $subId, $containerStatus);
}
} else {
$uuid = $labels->get('com.docker.compose.service');
$type = $labels->get('coolify.type');
if ($name === 'coolify-proxy' && $this->isRunning($containerStatus)) {
$this->foundProxy = true;
} elseif ($type === 'service' && $this->isRunning($containerStatus)) {
} else {
if ($this->allDatabaseUuids->contains($uuid) && $this->isRunning($containerStatus)) {
$this->foundDatabaseUuids->push($uuid);
if ($this->allTcpProxyUuids->contains($uuid) && $this->isRunning($containerStatus)) {
$this->updateDatabaseStatus($uuid, $containerStatus, tcpProxy: true);
} else {
$this->updateDatabaseStatus($uuid, $containerStatus, tcpProxy: false);
}
$previewKey = $applicationId.':'.$pullRequestId;
if ($this->allApplicationPreviewsIds->contains($previewKey)) {
$this->foundApplicationPreviewsIds->push($previewKey);
}
$this->updateApplicationPreviewStatus($applicationId, $pullRequestId, $containerStatus);
}
} catch (\Exception $e) {
}
} elseif ($labels->has('coolify.serviceId')) {
$serviceId = $labels->get('coolify.serviceId');
$subType = $labels->get('coolify.service.subType');
$subId = $labels->get('coolify.service.subId');
if ($subType === 'application') {
$this->foundServiceApplicationIds->push($subId);
// Store container status for aggregation
$key = $serviceId.':'.$subType.':'.$subId;
if (! $this->serviceContainerStatuses->has($key)) {
$this->serviceContainerStatuses->put($key, collect());
}
$containerName = $labels->get('com.docker.compose.service');
if ($containerName) {
$this->serviceContainerStatuses->get($key)->put($containerName, $containerStatus);
}
} elseif ($subType === 'database') {
$this->foundServiceDatabaseIds->push($subId);
// Store container status for aggregation
$key = $serviceId.':'.$subType.':'.$subId;
if (! $this->serviceContainerStatuses->has($key)) {
$this->serviceContainerStatuses->put($key, collect());
}
$containerName = $labels->get('com.docker.compose.service');
if ($containerName) {
$this->serviceContainerStatuses->get($key)->put($containerName, $containerStatus);
}
}
} else {
$uuid = $labels->get('com.docker.compose.service');
$type = $labels->get('coolify.type');
if ($name === 'coolify-proxy' && $this->isRunning($containerStatus)) {
$this->foundProxy = true;
} elseif ($type === 'service' && $this->isRunning($containerStatus)) {
} else {
if ($this->allDatabaseUuids->contains($uuid) && $this->isRunning($containerStatus)) {
$this->foundDatabaseUuids->push($uuid);
if ($this->allTcpProxyUuids->contains($uuid) && $this->isRunning($containerStatus)) {
$this->updateDatabaseStatus($uuid, $containerStatus, tcpProxy: true);
} else {
$this->updateDatabaseStatus($uuid, $containerStatus, tcpProxy: false);
}
}
}
@@ -218,6 +260,9 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
// Aggregate multi-container application statuses
$this->aggregateMultiContainerStatuses();
// Aggregate multi-container service statuses
$this->aggregateServiceContainerStatuses();
$this->checkLogDrainContainer();
}
@@ -235,57 +280,28 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
// Parse docker compose to check for excluded containers
$dockerComposeRaw = data_get($application, 'docker_compose_raw');
$excludedContainers = collect();
if ($dockerComposeRaw) {
try {
$dockerCompose = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw);
$services = data_get($dockerCompose, 'services', []);
foreach ($services as $serviceName => $serviceConfig) {
// Check if container should be excluded
$excludeFromHc = data_get($serviceConfig, 'exclude_from_hc', false);
$restartPolicy = data_get($serviceConfig, 'restart', 'always');
if ($excludeFromHc || $restartPolicy === 'no') {
$excludedContainers->push($serviceName);
}
}
} catch (\Exception $e) {
// If we can't parse, treat all containers as included
}
}
$excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw);
// Filter out excluded containers
$relevantStatuses = $containerStatuses->filter(function ($status, $containerName) use ($excludedContainers) {
return ! $excludedContainers->contains($containerName);
});
// If all containers are excluded, don't update status
// If all containers are excluded, calculate status from excluded containers
if ($relevantStatuses->isEmpty()) {
$aggregatedStatus = $this->calculateExcludedStatusFromStrings($containerStatuses);
if ($aggregatedStatus && $application->status !== $aggregatedStatus) {
$application->status = $aggregatedStatus;
$application->save();
}
continue;
}
// Aggregate status: if any container is running, app is running
$hasRunning = false;
$hasUnhealthy = false;
foreach ($relevantStatuses as $status) {
if (str($status)->contains('running')) {
$hasRunning = true;
if (str($status)->contains('unhealthy')) {
$hasUnhealthy = true;
}
}
}
$aggregatedStatus = null;
if ($hasRunning) {
$aggregatedStatus = $hasUnhealthy ? 'running (unhealthy)' : 'running (healthy)';
} else {
// All containers are exited
$aggregatedStatus = 'exited (unhealthy)';
}
// Use ContainerStatusAggregator service for state machine logic
$aggregator = new ContainerStatusAggregator;
$aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, 0);
// Update application status with aggregated result
if ($aggregatedStatus && $application->status !== $aggregatedStatus) {
@@ -295,6 +311,66 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
}
}
private function aggregateServiceContainerStatuses()
{
if ($this->serviceContainerStatuses->isEmpty()) {
return;
}
foreach ($this->serviceContainerStatuses as $key => $containerStatuses) {
// Parse key: serviceId:subType:subId
[$serviceId, $subType, $subId] = explode(':', $key);
$service = $this->services->where('id', $serviceId)->first();
if (! $service) {
continue;
}
// Get the service sub-resource (ServiceApplication or ServiceDatabase)
$subResource = null;
if ($subType === 'application') {
$subResource = $service->applications()->where('id', $subId)->first();
} elseif ($subType === 'database') {
$subResource = $service->databases()->where('id', $subId)->first();
}
if (! $subResource) {
continue;
}
// Parse docker compose from service to check for excluded containers
$dockerComposeRaw = data_get($service, 'docker_compose_raw');
$excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw);
// Filter out excluded containers
$relevantStatuses = $containerStatuses->filter(function ($status, $containerName) use ($excludedContainers) {
return ! $excludedContainers->contains($containerName);
});
// If all containers are excluded, calculate status from excluded containers
if ($relevantStatuses->isEmpty()) {
$aggregatedStatus = $this->calculateExcludedStatusFromStrings($containerStatuses);
if ($aggregatedStatus && $subResource->status !== $aggregatedStatus) {
$subResource->status = $aggregatedStatus;
$subResource->save();
}
continue;
}
// Use ContainerStatusAggregator service for state machine logic
// NOTE: Sentinel does NOT provide restart count data, so maxRestartCount is always 0
$aggregator = new ContainerStatusAggregator;
$aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, 0);
// Update service sub-resource status with aggregated result
if ($aggregatedStatus && $subResource->status !== $aggregatedStatus) {
$subResource->status = $aggregatedStatus;
$subResource->save();
}
}
}
private function updateApplicationStatus(string $applicationId, string $containerStatus)
{
$application = $this->applications->where('id', $applicationId)->first();
+2 -2
View File
@@ -31,12 +31,12 @@ class RestartProxyJob implements ShouldBeEncrypted, ShouldQueue
public function handle()
{
try {
StopProxy::run($this->server);
StopProxy::run($this->server, restarting: true);
$this->server->proxy->force_stop = false;
$this->server->save();
StartProxy::run($this->server, force: true);
StartProxy::run($this->server, force: true, restarting: true);
} catch (\Throwable $e) {
return handleError($e);
+1 -1
View File
@@ -52,7 +52,7 @@ class ScheduledJobManager implements ShouldQueue
{
return [
(new WithoutOverlapping('scheduled-job-manager'))
->expireAfter(60) // Lock expires after 1 minute to prevent stale locks
->expireAfter(90) // Lock expires after 90s to handle high-load environments with many tasks
->dontRelease(), // Don't re-queue on lock conflict
];
}
+120 -3
View File
@@ -18,14 +18,30 @@ use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class ScheduledTaskJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* The number of times the job may be attempted.
*/
public $tries = 3;
/**
* The maximum number of unhandled exceptions to allow before failing.
*/
public $maxExceptions = 1;
/**
* The number of seconds the job can run before timing out.
*/
public $timeout = 300;
public Team $team;
public Server $server;
public ?Server $server = null;
public ScheduledTask $task;
@@ -33,6 +49,11 @@ class ScheduledTaskJob implements ShouldQueue
public ?ScheduledTaskExecution $task_log = null;
/**
* Store execution ID to survive job serialization for timeout handling.
*/
protected ?int $executionId = null;
public string $task_status = 'failed';
public ?string $task_output = null;
@@ -55,6 +76,9 @@ class ScheduledTaskJob implements ShouldQueue
}
$this->team = Team::findOrFail($task->team_id);
$this->server_timezone = $this->getServerTimezone();
// Set timeout from task configuration
$this->timeout = $this->task->timeout ?? 300;
}
private function getServerTimezone(): string
@@ -70,11 +94,18 @@ class ScheduledTaskJob implements ShouldQueue
public function handle(): void
{
$startTime = Carbon::now();
try {
$this->task_log = ScheduledTaskExecution::create([
'scheduled_task_id' => $this->task->id,
'started_at' => $startTime,
'retry_count' => $this->attempts() - 1,
]);
// Store execution ID for timeout handling
$this->executionId = $this->task_log->id;
$this->server = $this->resource->destination->server;
if ($this->resource->type() === 'application') {
@@ -129,15 +160,101 @@ class ScheduledTaskJob implements ShouldQueue
'message' => $this->task_output ?? $e->getMessage(),
]);
}
$this->team?->notify(new TaskFailed($this->task, $e->getMessage()));
// Log the error to the scheduled-errors channel
Log::channel('scheduled-errors')->error('ScheduledTask execution failed', [
'job' => 'ScheduledTaskJob',
'task_id' => $this->task->uuid,
'task_name' => $this->task->name,
'server' => $this->server?->name ?? 'unknown',
'attempt' => $this->attempts(),
'error' => $e->getMessage(),
]);
// Only notify and throw on final failure
// Re-throw to trigger Laravel's retry mechanism with backoff
throw $e;
} finally {
ScheduledTaskDone::dispatch($this->team->id);
if ($this->task_log) {
$finishedAt = Carbon::now();
$duration = round($startTime->floatDiffInSeconds($finishedAt), 2);
$this->task_log->update([
'finished_at' => Carbon::now()->toImmutable(),
'finished_at' => $finishedAt->toImmutable(),
'duration' => $duration,
]);
}
}
}
/**
* Calculate the number of seconds to wait before retrying the job.
*/
public function backoff(): array
{
return [30, 60, 120]; // 30s, 60s, 120s between retries
}
/**
* Handle a job failure.
*/
public function failed(?\Throwable $exception): void
{
Log::channel('scheduled-errors')->error('ScheduledTask permanently failed', [
'job' => 'ScheduledTaskJob',
'task_id' => $this->task->uuid,
'task_name' => $this->task->name,
'server' => $this->server?->name ?? 'unknown',
'total_attempts' => $this->attempts(),
'error' => $exception?->getMessage(),
'trace' => $exception?->getTraceAsString(),
]);
// Reload execution log from database
// When a job times out, failed() is called in a fresh process with the original
// queue payload, so $executionId will be null. We need to query for the latest execution.
$execution = null;
// Try to find execution using stored ID first (works for non-timeout failures)
if ($this->executionId) {
$execution = ScheduledTaskExecution::find($this->executionId);
}
// If no stored ID or not found, query for the most recent execution log for this task
if (! $execution) {
$execution = ScheduledTaskExecution::query()
->where('scheduled_task_id', $this->task->id)
->orderBy('created_at', 'desc')
->first();
}
// Last resort: check task_log property
if (! $execution && $this->task_log) {
$execution = $this->task_log;
}
if ($execution) {
$errorMessage = 'Job permanently failed after '.$this->attempts().' attempts';
if ($exception) {
$errorMessage .= ': '.$exception->getMessage();
}
$execution->update([
'status' => 'failed',
'message' => $errorMessage,
'error_details' => $exception?->getTraceAsString(),
'finished_at' => Carbon::now()->toImmutable(),
]);
} else {
Log::channel('scheduled-errors')->warning('Could not find execution log to update', [
'execution_id' => $this->executionId,
'task_id' => $this->task->uuid,
]);
}
// Notify team about permanent failure
$this->team?->notify(new TaskFailed($this->task, $exception?->getMessage() ?? 'Unknown error'));
}
}
+2 -2
View File
@@ -87,7 +87,7 @@ class ServerManagerJob implements ShouldQueue
Log::channel('scheduled-errors')->error('Failed to dispatch ServerConnectionCheck', [
'server_id' => $server->id,
'server_name' => $server->name,
'error' => $e->getMessage(),
'error' => get_class($e).': '.$e->getMessage(),
]);
}
});
@@ -103,7 +103,7 @@ class ServerManagerJob implements ShouldQueue
Log::channel('scheduled-errors')->error('Error processing server tasks', [
'server_id' => $server->id,
'server_name' => $server->name,
'error' => $e->getMessage(),
'error' => get_class($e).': '.$e->getMessage(),
]);
}
}
+39
View File
@@ -72,6 +72,42 @@ class ValidateAndInstallServerJob implements ShouldQueue
return;
}
// Check and install prerequisites
$validationResult = $this->server->validatePrerequisites();
if (! $validationResult['success']) {
if ($this->numberOfTries >= $this->maxTries) {
$missingCommands = implode(', ', $validationResult['missing']);
$errorMessage = "Prerequisites ({$missingCommands}) could not be installed after {$this->maxTries} attempts. Please install them manually before continuing.";
$this->server->update([
'validation_logs' => $errorMessage,
'is_validating' => false,
]);
Log::error('ValidateAndInstallServer: Prerequisites installation failed after max tries', [
'server_id' => $this->server->id,
'attempts' => $this->numberOfTries,
'missing_commands' => $validationResult['missing'],
'found_commands' => $validationResult['found'],
]);
return;
}
Log::info('ValidateAndInstallServer: Installing prerequisites', [
'server_id' => $this->server->id,
'attempt' => $this->numberOfTries + 1,
'missing_commands' => $validationResult['missing'],
'found_commands' => $validationResult['found'],
]);
// Install prerequisites
$this->server->installPrerequisites();
// Retry validation after installation
self::dispatch($this->server, $this->numberOfTries + 1)->delay(now()->addSeconds(30));
return;
}
// Check if Docker is installed
$dockerInstalled = $this->server->validateDockerEngine();
$dockerComposeInstalled = $this->server->validateDockerCompose();
@@ -132,6 +168,9 @@ class ValidateAndInstallServerJob implements ShouldQueue
if (! $this->server->isBuildServer()) {
$proxyShouldRun = CheckProxy::run($this->server, true);
if ($proxyShouldRun) {
// Ensure networks exist BEFORE dispatching async proxy startup
// This prevents race condition where proxy tries to start before networks are created
instant_remote_process(ensureProxyNetworksExist($this->server)->toArray(), $this->server, false);
StartProxy::dispatch($this->server);
}
}
+25 -2
View File
@@ -10,7 +10,7 @@ class ActivityMonitor extends Component
{
public ?string $header = null;
public $activityId;
public $activityId = null;
public $eventToDispatch = 'activityFinished';
@@ -28,12 +28,20 @@ class ActivityMonitor extends Component
protected $listeners = ['activityMonitor' => 'newMonitorActivity'];
public function newMonitorActivity($activityId, $eventToDispatch = 'activityFinished', $eventData = null)
public function newMonitorActivity($activityId, $eventToDispatch = 'activityFinished', $eventData = null, $header = null)
{
// Reset event dispatched flag for new activity
self::$eventDispatched = false;
$this->activityId = $activityId;
$this->eventToDispatch = $eventToDispatch;
$this->eventData = $eventData;
// Update header if provided
if ($header !== null) {
$this->header = $header;
}
$this->hydrateActivity();
$this->isPollingActive = true;
@@ -41,9 +49,24 @@ class ActivityMonitor extends Component
public function hydrateActivity()
{
if ($this->activityId === null) {
$this->activity = null;
return;
}
$this->activity = Activity::find($this->activityId);
}
public function updatedActivityId($value)
{
if ($value) {
$this->hydrateActivity();
$this->isPollingActive = true;
self::$eventDispatched = false;
}
}
public function polling()
{
$this->hydrateActivity();
+66 -1
View File
@@ -14,7 +14,10 @@ use Visus\Cuid2\Cuid2;
class Index extends Component
{
protected $listeners = ['refreshBoardingIndex' => 'validateServer'];
protected $listeners = [
'refreshBoardingIndex' => 'validateServer',
'prerequisitesInstalled' => 'handlePrerequisitesInstalled',
];
#[\Livewire\Attributes\Url(as: 'step', history: true)]
public string $currentState = 'welcome';
@@ -76,6 +79,10 @@ class Index extends Component
public ?string $minDockerVersion = null;
public int $prerequisiteInstallAttempts = 0;
public int $maxPrerequisiteInstallAttempts = 3;
public function mount()
{
if (auth()->user()?->isMember() && auth()->user()->currentTeam()->show_boarding === true) {
@@ -320,6 +327,62 @@ class Index extends Component
return handleError(error: $e, livewire: $this);
}
try {
// Check prerequisites
$validationResult = $this->createdServer->validatePrerequisites();
if (! $validationResult['success']) {
// Check if we've exceeded max attempts
if ($this->prerequisiteInstallAttempts >= $this->maxPrerequisiteInstallAttempts) {
$missingCommands = implode(', ', $validationResult['missing']);
throw new \Exception("Prerequisites ({$missingCommands}) could not be installed after {$this->maxPrerequisiteInstallAttempts} attempts. Please install them manually.");
}
// Start async installation and wait for completion via ActivityMonitor
$activity = $this->createdServer->installPrerequisites();
$this->prerequisiteInstallAttempts++;
$this->dispatch('activityMonitor', $activity->id, 'prerequisitesInstalled');
// Return early - handlePrerequisitesInstalled() will be called when installation completes
return;
}
// Prerequisites are already installed, continue with validation
$this->continueValidation();
} catch (\Throwable $e) {
return handleError(error: $e, livewire: $this);
}
}
public function handlePrerequisitesInstalled()
{
try {
// Revalidate prerequisites after installation completes
$validationResult = $this->createdServer->validatePrerequisites();
if (! $validationResult['success']) {
// Installation completed but prerequisites still missing - retry
$missingCommands = implode(', ', $validationResult['missing']);
if ($this->prerequisiteInstallAttempts >= $this->maxPrerequisiteInstallAttempts) {
throw new \Exception("Prerequisites ({$missingCommands}) could not be installed after {$this->maxPrerequisiteInstallAttempts} attempts. Please install them manually.");
}
// Try again
$activity = $this->createdServer->installPrerequisites();
$this->prerequisiteInstallAttempts++;
$this->dispatch('activityMonitor', $activity->id, 'prerequisitesInstalled');
return;
}
// Prerequisites validated successfully - continue with Docker validation
$this->continueValidation();
} catch (\Throwable $e) {
return handleError(error: $e, livewire: $this);
}
}
private function continueValidation()
{
try {
$dockerVersion = instant_remote_process(["docker version|head -2|grep -i version| awk '{print $2}'"], $this->createdServer, true);
$dockerVersion = checkMinimumDockerEngineVersion($dockerVersion);
@@ -347,6 +410,8 @@ class Index extends Component
}
$this->createdServer->proxy->type = $proxyType;
$this->createdServer->proxy->status = 'exited';
$this->createdServer->proxy->last_saved_settings = null;
$this->createdServer->proxy->last_applied_settings = null;
$this->createdServer->save();
$this->getProjects();
}
+5
View File
@@ -62,6 +62,9 @@ class Discord extends Component
#[Validate(['boolean'])]
public bool $serverPatchDiscordNotifications = false;
#[Validate(['boolean'])]
public bool $traefikOutdatedDiscordNotifications = true;
#[Validate(['boolean'])]
public bool $discordPingEnabled = true;
@@ -98,6 +101,7 @@ class Discord extends Component
$this->settings->server_reachable_discord_notifications = $this->serverReachableDiscordNotifications;
$this->settings->server_unreachable_discord_notifications = $this->serverUnreachableDiscordNotifications;
$this->settings->server_patch_discord_notifications = $this->serverPatchDiscordNotifications;
$this->settings->traefik_outdated_discord_notifications = $this->traefikOutdatedDiscordNotifications;
$this->settings->discord_ping_enabled = $this->discordPingEnabled;
@@ -120,6 +124,7 @@ class Discord extends Component
$this->serverReachableDiscordNotifications = $this->settings->server_reachable_discord_notifications;
$this->serverUnreachableDiscordNotifications = $this->settings->server_unreachable_discord_notifications;
$this->serverPatchDiscordNotifications = $this->settings->server_patch_discord_notifications;
$this->traefikOutdatedDiscordNotifications = $this->settings->traefik_outdated_discord_notifications;
$this->discordPingEnabled = $this->settings->discord_ping_enabled;
}
+5
View File
@@ -104,6 +104,9 @@ class Email extends Component
#[Validate(['boolean'])]
public bool $serverPatchEmailNotifications = false;
#[Validate(['boolean'])]
public bool $traefikOutdatedEmailNotifications = true;
#[Validate(['nullable', 'email'])]
public ?string $testEmailAddress = null;
@@ -155,6 +158,7 @@ class Email extends Component
$this->settings->server_reachable_email_notifications = $this->serverReachableEmailNotifications;
$this->settings->server_unreachable_email_notifications = $this->serverUnreachableEmailNotifications;
$this->settings->server_patch_email_notifications = $this->serverPatchEmailNotifications;
$this->settings->traefik_outdated_email_notifications = $this->traefikOutdatedEmailNotifications;
$this->settings->save();
} else {
@@ -187,6 +191,7 @@ class Email extends Component
$this->serverReachableEmailNotifications = $this->settings->server_reachable_email_notifications;
$this->serverUnreachableEmailNotifications = $this->settings->server_unreachable_email_notifications;
$this->serverPatchEmailNotifications = $this->settings->server_patch_email_notifications;
$this->traefikOutdatedEmailNotifications = $this->settings->traefik_outdated_email_notifications;
}
}
+5
View File
@@ -70,6 +70,9 @@ class Pushover extends Component
#[Validate(['boolean'])]
public bool $serverPatchPushoverNotifications = false;
#[Validate(['boolean'])]
public bool $traefikOutdatedPushoverNotifications = true;
public function mount()
{
try {
@@ -104,6 +107,7 @@ class Pushover extends Component
$this->settings->server_reachable_pushover_notifications = $this->serverReachablePushoverNotifications;
$this->settings->server_unreachable_pushover_notifications = $this->serverUnreachablePushoverNotifications;
$this->settings->server_patch_pushover_notifications = $this->serverPatchPushoverNotifications;
$this->settings->traefik_outdated_pushover_notifications = $this->traefikOutdatedPushoverNotifications;
$this->settings->save();
refreshSession();
@@ -125,6 +129,7 @@ class Pushover extends Component
$this->serverReachablePushoverNotifications = $this->settings->server_reachable_pushover_notifications;
$this->serverUnreachablePushoverNotifications = $this->settings->server_unreachable_pushover_notifications;
$this->serverPatchPushoverNotifications = $this->settings->server_patch_pushover_notifications;
$this->traefikOutdatedPushoverNotifications = $this->settings->traefik_outdated_pushover_notifications;
}
}
+5
View File
@@ -67,6 +67,9 @@ class Slack extends Component
#[Validate(['boolean'])]
public bool $serverPatchSlackNotifications = false;
#[Validate(['boolean'])]
public bool $traefikOutdatedSlackNotifications = true;
public function mount()
{
try {
@@ -100,6 +103,7 @@ class Slack extends Component
$this->settings->server_reachable_slack_notifications = $this->serverReachableSlackNotifications;
$this->settings->server_unreachable_slack_notifications = $this->serverUnreachableSlackNotifications;
$this->settings->server_patch_slack_notifications = $this->serverPatchSlackNotifications;
$this->settings->traefik_outdated_slack_notifications = $this->traefikOutdatedSlackNotifications;
$this->settings->save();
refreshSession();
@@ -120,6 +124,7 @@ class Slack extends Component
$this->serverReachableSlackNotifications = $this->settings->server_reachable_slack_notifications;
$this->serverUnreachableSlackNotifications = $this->settings->server_unreachable_slack_notifications;
$this->serverPatchSlackNotifications = $this->settings->server_patch_slack_notifications;
$this->traefikOutdatedSlackNotifications = $this->settings->traefik_outdated_slack_notifications;
}
}
+10
View File
@@ -70,6 +70,9 @@ class Telegram extends Component
#[Validate(['boolean'])]
public bool $serverPatchTelegramNotifications = false;
#[Validate(['boolean'])]
public bool $traefikOutdatedTelegramNotifications = true;
#[Validate(['nullable', 'string'])]
public ?string $telegramNotificationsDeploymentSuccessThreadId = null;
@@ -109,6 +112,9 @@ class Telegram extends Component
#[Validate(['nullable', 'string'])]
public ?string $telegramNotificationsServerPatchThreadId = null;
#[Validate(['nullable', 'string'])]
public ?string $telegramNotificationsTraefikOutdatedThreadId = null;
public function mount()
{
try {
@@ -143,6 +149,7 @@ class Telegram extends Component
$this->settings->server_reachable_telegram_notifications = $this->serverReachableTelegramNotifications;
$this->settings->server_unreachable_telegram_notifications = $this->serverUnreachableTelegramNotifications;
$this->settings->server_patch_telegram_notifications = $this->serverPatchTelegramNotifications;
$this->settings->traefik_outdated_telegram_notifications = $this->traefikOutdatedTelegramNotifications;
$this->settings->telegram_notifications_deployment_success_thread_id = $this->telegramNotificationsDeploymentSuccessThreadId;
$this->settings->telegram_notifications_deployment_failure_thread_id = $this->telegramNotificationsDeploymentFailureThreadId;
@@ -157,6 +164,7 @@ class Telegram extends Component
$this->settings->telegram_notifications_server_reachable_thread_id = $this->telegramNotificationsServerReachableThreadId;
$this->settings->telegram_notifications_server_unreachable_thread_id = $this->telegramNotificationsServerUnreachableThreadId;
$this->settings->telegram_notifications_server_patch_thread_id = $this->telegramNotificationsServerPatchThreadId;
$this->settings->telegram_notifications_traefik_outdated_thread_id = $this->telegramNotificationsTraefikOutdatedThreadId;
$this->settings->save();
} else {
@@ -177,6 +185,7 @@ class Telegram extends Component
$this->serverReachableTelegramNotifications = $this->settings->server_reachable_telegram_notifications;
$this->serverUnreachableTelegramNotifications = $this->settings->server_unreachable_telegram_notifications;
$this->serverPatchTelegramNotifications = $this->settings->server_patch_telegram_notifications;
$this->traefikOutdatedTelegramNotifications = $this->settings->traefik_outdated_telegram_notifications;
$this->telegramNotificationsDeploymentSuccessThreadId = $this->settings->telegram_notifications_deployment_success_thread_id;
$this->telegramNotificationsDeploymentFailureThreadId = $this->settings->telegram_notifications_deployment_failure_thread_id;
@@ -191,6 +200,7 @@ class Telegram extends Component
$this->telegramNotificationsServerReachableThreadId = $this->settings->telegram_notifications_server_reachable_thread_id;
$this->telegramNotificationsServerUnreachableThreadId = $this->settings->telegram_notifications_server_unreachable_thread_id;
$this->telegramNotificationsServerPatchThreadId = $this->settings->telegram_notifications_server_patch_thread_id;
$this->telegramNotificationsTraefikOutdatedThreadId = $this->settings->telegram_notifications_traefik_outdated_thread_id;
}
}
+5
View File
@@ -62,6 +62,9 @@ class Webhook extends Component
#[Validate(['boolean'])]
public bool $serverPatchWebhookNotifications = false;
#[Validate(['boolean'])]
public bool $traefikOutdatedWebhookNotifications = true;
public function mount()
{
try {
@@ -95,6 +98,7 @@ class Webhook extends Component
$this->settings->server_reachable_webhook_notifications = $this->serverReachableWebhookNotifications;
$this->settings->server_unreachable_webhook_notifications = $this->serverUnreachableWebhookNotifications;
$this->settings->server_patch_webhook_notifications = $this->serverPatchWebhookNotifications;
$this->settings->traefik_outdated_webhook_notifications = $this->traefikOutdatedWebhookNotifications;
$this->settings->save();
refreshSession();
@@ -115,6 +119,7 @@ class Webhook extends Component
$this->serverReachableWebhookNotifications = $this->settings->server_reachable_webhook_notifications;
$this->serverUnreachableWebhookNotifications = $this->settings->server_unreachable_webhook_notifications;
$this->serverPatchWebhookNotifications = $this->settings->server_patch_webhook_notifications;
$this->traefikOutdatedWebhookNotifications = $this->settings->traefik_outdated_webhook_notifications;
}
}
@@ -37,6 +37,12 @@ class Advanced extends Component
#[Validate(['boolean'])]
public bool $disableBuildCache = false;
#[Validate(['boolean'])]
public bool $injectBuildArgsToDockerfile = true;
#[Validate(['boolean'])]
public bool $includeSourceCommitInBuild = false;
#[Validate(['boolean'])]
public bool $isLogDrainEnabled = false;
@@ -110,6 +116,8 @@ class Advanced extends Component
$this->application->settings->is_raw_compose_deployment_enabled = $this->isRawComposeDeploymentEnabled;
$this->application->settings->connect_to_docker_network = $this->isConnectToDockerNetworkEnabled;
$this->application->settings->disable_build_cache = $this->disableBuildCache;
$this->application->settings->inject_build_args_to_dockerfile = $this->injectBuildArgsToDockerfile;
$this->application->settings->include_source_commit_in_build = $this->includeSourceCommitInBuild;
$this->application->settings->save();
} else {
$this->isForceHttpsEnabled = $this->application->isForceHttpsEnabled();
@@ -134,6 +142,8 @@ class Advanced extends Component
$this->isRawComposeDeploymentEnabled = $this->application->settings->is_raw_compose_deployment_enabled;
$this->isConnectToDockerNetworkEnabled = $this->application->settings->connect_to_docker_network;
$this->disableBuildCache = $this->application->settings->disable_build_cache;
$this->injectBuildArgsToDockerfile = $this->application->settings->inject_build_args_to_dockerfile ?? true;
$this->includeSourceCommitInBuild = $this->application->settings->include_source_commit_in_build ?? false;
}
}
+56 -14
View File
@@ -641,8 +641,6 @@ class General extends Component
$this->application->settings->is_static = false;
$this->application->settings->save();
} else {
$this->portsExposes = '3000';
$this->application->ports_exposes = '3000';
$this->resetDefaultLabels(false);
}
if ($this->buildPack === 'dockercompose') {
@@ -655,18 +653,6 @@ class General extends Component
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
// User doesn't have update permission, just continue without saving
}
} else {
// Clear Docker Compose specific data when switching away from dockercompose
if ($this->application->getOriginal('build_pack') === 'dockercompose') {
$this->application->docker_compose_domains = null;
$this->application->docker_compose_raw = null;
// Remove SERVICE_FQDN_* and SERVICE_URL_* environment variables
$this->application->environment_variables()->where('key', 'LIKE', 'SERVICE_FQDN_%')->delete();
$this->application->environment_variables()->where('key', 'LIKE', 'SERVICE_URL_%')->delete();
$this->application->environment_variables_preview()->where('key', 'LIKE', 'SERVICE_FQDN_%')->delete();
$this->application->environment_variables_preview()->where('key', 'LIKE', 'SERVICE_URL_%')->delete();
}
}
if ($this->buildPack === 'static') {
$this->portsExposes = '80';
@@ -1000,4 +986,60 @@ class General extends Component
}
}
}
public function getDetectedPortInfoProperty(): ?array
{
$detectedPort = $this->application->detectPortFromEnvironment();
if (! $detectedPort) {
return null;
}
$portsExposesArray = $this->application->ports_exposes_array;
$isMatch = in_array($detectedPort, $portsExposesArray);
$isEmpty = empty($portsExposesArray);
return [
'port' => $detectedPort,
'matches' => $isMatch,
'isEmpty' => $isEmpty,
];
}
public function getDockerComposeBuildCommandPreviewProperty(): string
{
if (! $this->dockerComposeCustomBuildCommand) {
return '';
}
// Normalize baseDirectory to prevent double slashes (e.g., when baseDirectory is '/')
$normalizedBase = $this->baseDirectory === '/' ? '' : rtrim($this->baseDirectory, '/');
// Use relative path for clarity in preview (e.g., ./backend/docker-compose.yaml)
// Actual deployment uses absolute path: /artifacts/{deployment_uuid}{base_directory}{docker_compose_location}
// Build-time env path references ApplicationDeploymentJob::BUILD_TIME_ENV_PATH as source of truth
return injectDockerComposeFlags(
$this->dockerComposeCustomBuildCommand,
".{$normalizedBase}{$this->dockerComposeLocation}",
\App\Jobs\ApplicationDeploymentJob::BUILD_TIME_ENV_PATH
);
}
public function getDockerComposeStartCommandPreviewProperty(): string
{
if (! $this->dockerComposeCustomStartCommand) {
return '';
}
// Normalize baseDirectory to prevent double slashes (e.g., when baseDirectory is '/')
$normalizedBase = $this->baseDirectory === '/' ? '' : rtrim($this->baseDirectory, '/');
// Use relative path for clarity in preview (e.g., ./backend/docker-compose.yaml)
// Placeholder {workdir}/.env shows it's the workdir .env file (runtime env, not build-time)
return injectDockerComposeFlags(
$this->dockerComposeCustomStartCommand,
".{$normalizedBase}{$this->dockerComposeLocation}",
'{workdir}/.env'
);
}
}
+16 -1
View File
@@ -101,11 +101,18 @@ class Heading extends Component
force_rebuild: $force_rebuild,
);
if ($result['status'] === 'skipped') {
$this->dispatch('success', 'Deployment skipped', $result['message']);
$this->dispatch('error', 'Deployment skipped', $result['message']);
return;
}
// Reset restart count on successful deployment
$this->application->update([
'restart_count' => 0,
'last_restart_at' => null,
'last_restart_type' => null,
]);
return $this->redirectRoute('project.application.deployment.show', [
'project_uuid' => $this->parameters['project_uuid'],
'application_uuid' => $this->parameters['application_uuid'],
@@ -137,6 +144,7 @@ class Heading extends Component
return;
}
$this->setDeploymentUuid();
$result = queue_application_deployment(
application: $this->application,
@@ -149,6 +157,13 @@ class Heading extends Component
return;
}
// Reset restart count on manual restart
$this->application->update([
'restart_count' => 0,
'last_restart_at' => now(),
'last_restart_type' => 'manual',
]);
return $this->redirectRoute('project.application.deployment.show', [
'project_uuid' => $this->parameters['project_uuid'],
'application_uuid' => $this->parameters['application_uuid'],
+20 -1
View File
@@ -79,7 +79,7 @@ class BackupEdit extends Component
#[Validate(['required', 'boolean'])]
public bool $dumpAll = false;
#[Validate(['required', 'int', 'min:1', 'max:36000'])]
#[Validate(['required', 'int', 'min:60', 'max:36000'])]
public int $timeout = 3600;
public function mount()
@@ -107,6 +107,25 @@ class BackupEdit extends Component
$this->backup->save_s3 = $this->saveS3;
$this->backup->disable_local_backup = $this->disableLocalBackup;
$this->backup->s3_storage_id = $this->s3StorageId;
// Validate databases_to_backup to prevent command injection
if (filled($this->databasesToBackup)) {
$databases = str($this->databasesToBackup)->explode(',');
foreach ($databases as $index => $db) {
$dbName = trim($db);
try {
validateShellSafePath($dbName, 'database name');
} catch (\Exception $e) {
// Provide specific error message indicating which database failed validation
$position = $index + 1;
throw new \Exception(
"Database #{$position} ('{$dbName}') validation failed: ".
$e->getMessage()
);
}
}
}
$this->backup->databases_to_backup = $this->databasesToBackup;
$this->backup->dump_all = $this->dumpAll;
$this->backup->timeout = $this->timeout;
+402 -47
View File
@@ -2,6 +2,7 @@
namespace App\Livewire\Project\Database;
use App\Models\S3Storage;
use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
@@ -12,6 +13,92 @@ class Import extends Component
{
use AuthorizesRequests;
/**
* Validate that a string is safe for use as an S3 bucket name.
* Allows alphanumerics, dots, dashes, and underscores.
*/
private function validateBucketName(string $bucket): bool
{
return preg_match('/^[a-zA-Z0-9.\-_]+$/', $bucket) === 1;
}
/**
* Validate that a string is safe for use as an S3 path.
* Allows alphanumerics, dots, dashes, underscores, slashes, and common file characters.
*/
private function validateS3Path(string $path): bool
{
// Must not be empty
if (empty($path)) {
return false;
}
// Must not contain dangerous shell metacharacters or command injection patterns
$dangerousPatterns = [
'..', // Directory traversal
'$(', // Command substitution
'`', // Backtick command substitution
'|', // Pipe
';', // Command separator
'&', // Background/AND
'>', // Redirect
'<', // Redirect
"\n", // Newline
"\r", // Carriage return
"\0", // Null byte
"'", // Single quote
'"', // Double quote
'\\', // Backslash
];
foreach ($dangerousPatterns as $pattern) {
if (str_contains($path, $pattern)) {
return false;
}
}
// Allow alphanumerics, dots, dashes, underscores, slashes, spaces, plus, equals, at
return preg_match('/^[a-zA-Z0-9.\-_\/\s+@=]+$/', $path) === 1;
}
/**
* Validate that a string is safe for use as a file path on the server.
*/
private function validateServerPath(string $path): bool
{
// Must be an absolute path
if (! str_starts_with($path, '/')) {
return false;
}
// Must not contain dangerous shell metacharacters or command injection patterns
$dangerousPatterns = [
'..', // Directory traversal
'$(', // Command substitution
'`', // Backtick command substitution
'|', // Pipe
';', // Command separator
'&', // Background/AND
'>', // Redirect
'<', // Redirect
"\n", // Newline
"\r", // Carriage return
"\0", // Null byte
"'", // Single quote
'"', // Double quote
'\\', // Backslash
];
foreach ($dangerousPatterns as $pattern) {
if (str_contains($path, $pattern)) {
return false;
}
}
// Allow alphanumerics, dots, dashes, underscores, slashes, and spaces
return preg_match('/^[a-zA-Z0-9.\-_\/\s]+$/', $path) === 1;
}
public bool $unsupported = false;
public $resource;
@@ -46,6 +133,8 @@ class Import extends Component
public string $customLocation = '';
public ?int $activityId = null;
public string $postgresqlRestoreCommand = 'pg_restore -U $POSTGRES_USER -d $POSTGRES_DB';
public string $mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE';
@@ -54,22 +143,35 @@ class Import extends Component
public string $mongodbRestoreCommand = 'mongorestore --authenticationDatabase=admin --username $MONGO_INITDB_ROOT_USERNAME --password $MONGO_INITDB_ROOT_PASSWORD --uri mongodb://localhost:27017 --gzip --archive=';
// S3 Restore properties
public $availableS3Storages = [];
public ?int $s3StorageId = null;
public string $s3Path = '';
public ?int $s3FileSize = null;
public function getListeners()
{
$userId = Auth::id();
return [
"echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
'slideOverClosed' => 'resetActivityId',
];
}
public function resetActivityId()
{
$this->activityId = null;
}
public function mount()
{
if (isDev()) {
$this->customLocation = '/data/coolify/pg-dump-all-1736245863.gz';
}
$this->parameters = get_route_parameters();
$this->getContainers();
$this->loadAvailableS3Storages();
}
public function updatedDumpAll($value)
@@ -152,8 +254,16 @@ EOD;
public function checkFile()
{
if (filled($this->customLocation)) {
// Validate the custom location to prevent command injection
if (! $this->validateServerPath($this->customLocation)) {
$this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
return;
}
try {
$result = instant_remote_process(["ls -l {$this->customLocation}"], $this->server, throwError: false);
$escapedPath = escapeshellarg($this->customLocation);
$result = instant_remote_process(["ls -l {$escapedPath}"], $this->server, throwError: false);
if (blank($result)) {
$this->dispatch('error', 'The file does not exist or has been deleted.');
@@ -179,59 +289,35 @@ EOD;
try {
$this->importRunning = true;
$this->importCommands = [];
if (filled($this->customLocation)) {
$backupFileName = '/tmp/restore_'.$this->resource->uuid;
$this->importCommands[] = "docker cp {$this->customLocation} {$this->container}:{$backupFileName}";
$tmpPath = $backupFileName;
} else {
$backupFileName = "upload/{$this->resource->uuid}/restore";
$path = Storage::path($backupFileName);
if (! Storage::exists($backupFileName)) {
$this->dispatch('error', 'The file does not exist or has been deleted.');
$backupFileName = "upload/{$this->resource->uuid}/restore";
return;
}
// Check if an uploaded file exists first (takes priority over custom location)
if (Storage::exists($backupFileName)) {
$path = Storage::path($backupFileName);
$tmpPath = '/tmp/'.basename($backupFileName).'_'.$this->resource->uuid;
instant_scp($path, $tmpPath, $this->server);
Storage::delete($backupFileName);
$this->importCommands[] = "docker cp {$tmpPath} {$this->container}:{$tmpPath}";
} elseif (filled($this->customLocation)) {
// Validate the custom location to prevent command injection
if (! $this->validateServerPath($this->customLocation)) {
$this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters.');
return;
}
$tmpPath = '/tmp/restore_'.$this->resource->uuid;
$escapedCustomLocation = escapeshellarg($this->customLocation);
$this->importCommands[] = "docker cp {$escapedCustomLocation} {$this->container}:{$tmpPath}";
} else {
$this->dispatch('error', 'The file does not exist or has been deleted.');
return;
}
// Copy the restore command to a script file
$scriptPath = "/tmp/restore_{$this->resource->uuid}.sh";
switch ($this->resource->getMorphClass()) {
case \App\Models\StandaloneMariadb::class:
$restoreCommand = $this->mariadbRestoreCommand;
if ($this->dumpAll) {
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mariadb -u root -p\$MARIADB_ROOT_PASSWORD";
} else {
$restoreCommand .= " < {$tmpPath}";
}
break;
case \App\Models\StandaloneMysql::class:
$restoreCommand = $this->mysqlRestoreCommand;
if ($this->dumpAll) {
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mysql -u root -p\$MYSQL_ROOT_PASSWORD";
} else {
$restoreCommand .= " < {$tmpPath}";
}
break;
case \App\Models\StandalonePostgresql::class:
$restoreCommand = $this->postgresqlRestoreCommand;
if ($this->dumpAll) {
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | psql -U \$POSTGRES_USER postgres";
} else {
$restoreCommand .= " {$tmpPath}";
}
break;
case \App\Models\StandaloneMongodb::class:
$restoreCommand = $this->mongodbRestoreCommand;
if ($this->dumpAll === false) {
$restoreCommand .= "{$tmpPath}";
}
break;
}
$restoreCommand = $this->buildRestoreCommand($tmpPath);
$restoreCommandBase64 = base64_encode($restoreCommand);
$this->importCommands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$scriptPath}";
@@ -248,7 +334,13 @@ EOD;
'container' => $this->container,
'serverId' => $this->server->id,
]);
// Track the activity ID
$this->activityId = $activity->id;
// Dispatch activity to the monitor and open slide-over
$this->dispatch('activityMonitor', $activity->id);
$this->dispatch('databaserestore');
}
} catch (\Throwable $e) {
return handleError($e, $this);
@@ -257,4 +349,267 @@ EOD;
$this->importCommands = [];
}
}
public function loadAvailableS3Storages()
{
try {
$this->availableS3Storages = S3Storage::ownedByCurrentTeam(['id', 'name', 'description'])
->where('is_usable', true)
->get();
} catch (\Throwable $e) {
$this->availableS3Storages = collect();
}
}
public function updatedS3Path($value)
{
// Reset validation state when path changes
$this->s3FileSize = null;
// Ensure path starts with a slash
if ($value !== null && $value !== '') {
$this->s3Path = str($value)->trim()->start('/')->value();
}
}
public function updatedS3StorageId()
{
// Reset validation state when storage changes
$this->s3FileSize = null;
}
public function checkS3File()
{
if (! $this->s3StorageId) {
$this->dispatch('error', 'Please select an S3 storage.');
return;
}
if (blank($this->s3Path)) {
$this->dispatch('error', 'Please provide an S3 path.');
return;
}
// Clean the path (remove leading slash if present)
$cleanPath = ltrim($this->s3Path, '/');
// Validate the S3 path early to prevent command injection in subsequent operations
if (! $this->validateS3Path($cleanPath)) {
$this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
return;
}
try {
$s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId);
// Validate bucket name early
if (! $this->validateBucketName($s3Storage->bucket)) {
$this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.');
return;
}
// Test connection
$s3Storage->testConnection();
// Build S3 disk configuration
$disk = Storage::build([
'driver' => 's3',
'region' => $s3Storage->region,
'key' => $s3Storage->key,
'secret' => $s3Storage->secret,
'bucket' => $s3Storage->bucket,
'endpoint' => $s3Storage->endpoint,
'use_path_style_endpoint' => true,
]);
// Check if file exists
if (! $disk->exists($cleanPath)) {
$this->dispatch('error', 'File not found in S3. Please check the path.');
return;
}
// Get file size
$this->s3FileSize = $disk->size($cleanPath);
$this->dispatch('success', 'File found in S3. Size: '.formatBytes($this->s3FileSize));
} catch (\Throwable $e) {
$this->s3FileSize = null;
return handleError($e, $this);
}
}
public function restoreFromS3()
{
$this->authorize('update', $this->resource);
if (! $this->s3StorageId || blank($this->s3Path)) {
$this->dispatch('error', 'Please select S3 storage and provide a path first.');
return;
}
if (is_null($this->s3FileSize)) {
$this->dispatch('error', 'Please check the file first by clicking "Check File".');
return;
}
try {
$this->importRunning = true;
$s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId);
$key = $s3Storage->key;
$secret = $s3Storage->secret;
$bucket = $s3Storage->bucket;
$endpoint = $s3Storage->endpoint;
// Validate bucket name to prevent command injection
if (! $this->validateBucketName($bucket)) {
$this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.');
return;
}
// Clean the S3 path
$cleanPath = ltrim($this->s3Path, '/');
// Validate the S3 path to prevent command injection
if (! $this->validateS3Path($cleanPath)) {
$this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
return;
}
// Get helper image
$helperImage = config('constants.coolify.helper_image');
$latestVersion = getHelperVersion();
$fullImageName = "{$helperImage}:{$latestVersion}";
// Get the database destination network
$destinationNetwork = $this->resource->destination->network ?? 'coolify';
// Generate unique names for this operation
$containerName = "s3-restore-{$this->resource->uuid}";
$helperTmpPath = '/tmp/'.basename($cleanPath);
$serverTmpPath = "/tmp/s3-restore-{$this->resource->uuid}-".basename($cleanPath);
$containerTmpPath = "/tmp/restore_{$this->resource->uuid}-".basename($cleanPath);
$scriptPath = "/tmp/restore_{$this->resource->uuid}.sh";
// Prepare all commands in sequence
$commands = [];
// 1. Clean up any existing helper container and temp files from previous runs
$commands[] = "docker rm -f {$containerName} 2>/dev/null || true";
$commands[] = "rm -f {$serverTmpPath} 2>/dev/null || true";
$commands[] = "docker exec {$this->container} rm -f {$containerTmpPath} {$scriptPath} 2>/dev/null || true";
// 2. Start helper container on the database network
$commands[] = "docker run -d --network {$destinationNetwork} --name {$containerName} {$fullImageName} sleep 3600";
// 3. Configure S3 access in helper container
$escapedEndpoint = escapeshellarg($endpoint);
$escapedKey = escapeshellarg($key);
$escapedSecret = escapeshellarg($secret);
$commands[] = "docker exec {$containerName} mc alias set s3temp {$escapedEndpoint} {$escapedKey} {$escapedSecret}";
// 4. Check file exists in S3 (bucket and path already validated above)
$escapedBucket = escapeshellarg($bucket);
$escapedCleanPath = escapeshellarg($cleanPath);
$escapedS3Source = escapeshellarg("s3temp/{$bucket}/{$cleanPath}");
$commands[] = "docker exec {$containerName} mc stat {$escapedS3Source}";
// 5. Download from S3 to helper container (progress shown by default)
$escapedHelperTmpPath = escapeshellarg($helperTmpPath);
$commands[] = "docker exec {$containerName} mc cp {$escapedS3Source} {$escapedHelperTmpPath}";
// 6. Copy from helper to server, then immediately to database container
$commands[] = "docker cp {$containerName}:{$helperTmpPath} {$serverTmpPath}";
$commands[] = "docker cp {$serverTmpPath} {$this->container}:{$containerTmpPath}";
// 7. Cleanup helper container and server temp file immediately (no longer needed)
$commands[] = "docker rm -f {$containerName} 2>/dev/null || true";
$commands[] = "rm -f {$serverTmpPath} 2>/dev/null || true";
// 8. Build and execute restore command inside database container
$restoreCommand = $this->buildRestoreCommand($containerTmpPath);
$restoreCommandBase64 = base64_encode($restoreCommand);
$commands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$scriptPath}";
$commands[] = "chmod +x {$scriptPath}";
$commands[] = "docker cp {$scriptPath} {$this->container}:{$scriptPath}";
// 9. Execute restore and cleanup temp files immediately after completion
$commands[] = "docker exec {$this->container} sh -c '{$scriptPath} && rm -f {$containerTmpPath} {$scriptPath}'";
$commands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'";
// Execute all commands with cleanup event (as safety net for edge cases)
$activity = remote_process($commands, $this->server, ignore_errors: true, callEventOnFinish: 'S3RestoreJobFinished', callEventData: [
'containerName' => $containerName,
'serverTmpPath' => $serverTmpPath,
'scriptPath' => $scriptPath,
'containerTmpPath' => $containerTmpPath,
'container' => $this->container,
'serverId' => $this->server->id,
]);
// Track the activity ID
$this->activityId = $activity->id;
// Dispatch activity to the monitor and open slide-over
$this->dispatch('activityMonitor', $activity->id);
$this->dispatch('databaserestore');
$this->dispatch('info', 'Restoring database from S3. Progress will be shown in the activity monitor...');
} catch (\Throwable $e) {
$this->importRunning = false;
return handleError($e, $this);
}
}
public function buildRestoreCommand(string $tmpPath): string
{
switch ($this->resource->getMorphClass()) {
case \App\Models\StandaloneMariadb::class:
$restoreCommand = $this->mariadbRestoreCommand;
if ($this->dumpAll) {
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mariadb -u root -p\$MARIADB_ROOT_PASSWORD";
} else {
$restoreCommand .= " < {$tmpPath}";
}
break;
case \App\Models\StandaloneMysql::class:
$restoreCommand = $this->mysqlRestoreCommand;
if ($this->dumpAll) {
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mysql -u root -p\$MYSQL_ROOT_PASSWORD";
} else {
$restoreCommand .= " < {$tmpPath}";
}
break;
case \App\Models\StandalonePostgresql::class:
$restoreCommand = $this->postgresqlRestoreCommand;
if ($this->dumpAll) {
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | psql -U \$POSTGRES_USER postgres";
} else {
$restoreCommand .= " {$tmpPath}";
}
break;
case \App\Models\StandaloneMongodb::class:
$restoreCommand = $this->mongodbRestoreCommand;
if ($this->dumpAll === false) {
$restoreCommand .= "{$tmpPath}";
}
break;
default:
$restoreCommand = '';
}
return $restoreCommand;
}
}
@@ -328,12 +328,15 @@ class General extends Component
$configuration_dir = database_configuration_dir().'/'.$container_name;
if ($oldScript && $oldScript['filename'] !== $script['filename']) {
$old_file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$oldScript['filename']}";
$delete_command = "rm -f $old_file_path";
try {
// Validate and escape filename to prevent command injection
validateShellSafePath($oldScript['filename'], 'init script filename');
$old_file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$oldScript['filename']}";
$escapedOldPath = escapeshellarg($old_file_path);
$delete_command = "rm -f {$escapedOldPath}";
instant_remote_process([$delete_command], $this->server);
} catch (Exception $e) {
$this->dispatch('error', 'Failed to remove old init script from server: '.$e->getMessage());
$this->dispatch('error', $e->getMessage());
return;
}
@@ -370,13 +373,17 @@ class General extends Component
if ($found) {
$container_name = $this->database->uuid;
$configuration_dir = database_configuration_dir().'/'.$container_name;
$file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$script['filename']}";
$command = "rm -f $file_path";
try {
// Validate and escape filename to prevent command injection
validateShellSafePath($script['filename'], 'init script filename');
$file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$script['filename']}";
$escapedPath = escapeshellarg($file_path);
$command = "rm -f {$escapedPath}";
instant_remote_process([$command], $this->server);
} catch (Exception $e) {
$this->dispatch('error', 'Failed to remove init script from server: '.$e->getMessage());
$this->dispatch('error', $e->getMessage());
return;
}
@@ -405,6 +412,16 @@ class General extends Component
'new_filename' => 'required|string',
'new_content' => 'required|string',
]);
try {
// Validate filename to prevent command injection
validateShellSafePath($this->new_filename, 'init script filename');
} catch (Exception $e) {
$this->dispatch('error', $e->getMessage());
return;
}
$found = collect($this->initScripts)->firstWhere('filename', $this->new_filename);
if ($found) {
$this->dispatch('error', 'Filename already exists.');
+1 -1
View File
@@ -81,7 +81,7 @@ class Create extends Component
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
];
if ($oneClickServiceName === 'cloudflared' || $oneClickServiceName === 'pgadmin') {
if ($oneClickServiceName === 'pgadmin' || $oneClickServiceName === 'postgresus') {
data_set($service_payload, 'connect_to_docker_network', true);
}
$service = Service::create($service_payload);
+2 -3
View File
@@ -39,7 +39,7 @@ class EditDomain extends Component
{
$this->application = ServiceApplication::ownedByCurrentTeam()->findOrFail($this->applicationId);
$this->authorize('view', $this->application);
$this->requiredPort = $this->application->service->getRequiredPort();
$this->requiredPort = $this->application->getRequiredPort();
$this->syncData();
}
@@ -113,8 +113,7 @@ class EditDomain extends Component
// Check for required port
if (! $this->forceRemovePort) {
$service = $this->application->service;
$requiredPort = $service->getRequiredPort();
$requiredPort = $this->application->getRequiredPort();
if ($requiredPort !== null) {
// Check if all FQDNs have a port
@@ -135,7 +135,7 @@ class ServiceApplicationView extends Component
try {
$this->parameters = get_route_parameters();
$this->authorize('view', $this->application);
$this->requiredPort = $this->application->service->getRequiredPort();
$this->requiredPort = $this->application->getRequiredPort();
$this->syncData();
} catch (\Throwable $e) {
return handleError($e, $this);
@@ -268,8 +268,7 @@ class ServiceApplicationView extends Component
// Check for required port
if (! $this->forceRemovePort) {
$service = $this->application->service;
$requiredPort = $service->getRequiredPort();
$requiredPort = $this->application->getRequiredPort();
if ($requiredPort !== null) {
// Check if all FQDNs have a port
+16 -6
View File
@@ -5,6 +5,7 @@ namespace App\Livewire\Project\Service;
use App\Models\Service;
use App\Support\ValidationPatterns;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
class StackForm extends Component
@@ -22,7 +23,7 @@ class StackForm extends Component
public string $dockerComposeRaw;
public string $dockerCompose;
public ?string $dockerCompose = null;
public ?bool $connectToDockerNetwork = null;
@@ -30,7 +31,7 @@ class StackForm extends Component
{
$baseRules = [
'dockerComposeRaw' => 'required',
'dockerCompose' => 'required',
'dockerCompose' => 'nullable',
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
'connectToDockerNetwork' => 'nullable',
@@ -140,18 +141,27 @@ class StackForm extends Component
$this->validate();
$this->syncData(true);
// Validate for command injection BEFORE saving to database
// Validate for command injection BEFORE any database operations
validateDockerComposeForInjection($this->service->docker_compose_raw);
$this->service->save();
$this->service->saveExtraFields($this->fields);
$this->service->parse();
// Use transaction to ensure atomicity - if parse fails, save is rolled back
DB::transaction(function () {
$this->service->save();
$this->service->saveExtraFields($this->fields);
$this->service->parse();
});
// Refresh and write files after a successful commit
$this->service->refresh();
$this->service->saveComposeConfigs();
$this->dispatch('refreshEnvs');
$this->dispatch('refreshServices');
$notify && $this->dispatch('success', 'Service saved.');
} catch (\Throwable $e) {
// On error, refresh from database to restore clean state
$this->service->refresh();
$this->syncData(false);
return handleError($e, $this);
} finally {
if (is_null($this->service->config_hash)) {
+4
View File
@@ -179,6 +179,10 @@ class Storage extends Component
$this->file_storage_directory_destination = trim($this->file_storage_directory_destination);
$this->file_storage_directory_destination = str($this->file_storage_directory_destination)->start('/')->value();
// Validate paths to prevent command injection
validateShellSafePath($this->file_storage_directory_source, 'storage source path');
validateShellSafePath($this->file_storage_directory_destination, 'storage destination path');
\App\Models\LocalFileVolume::create([
'fs_path' => $this->file_storage_directory_source,
'mount_path' => $this->file_storage_directory_destination,
@@ -2,8 +2,11 @@
namespace App\Livewire\Project\Shared\EnvironmentVariable;
use App\Models\Environment;
use App\Models\Project;
use App\Traits\EnvironmentVariableAnalyzer;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Computed;
use Livewire\Component;
class Add extends Component
@@ -56,6 +59,72 @@ class Add extends Component
$this->problematicVariables = self::getProblematicVariablesForFrontend();
}
#[Computed]
public function availableSharedVariables(): array
{
$team = currentTeam();
$result = [
'team' => [],
'project' => [],
'environment' => [],
];
// Early return if no team
if (! $team) {
return $result;
}
// Check if user can view team variables
try {
$this->authorize('view', $team);
$result['team'] = $team->environment_variables()
->pluck('key')
->toArray();
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
// User not authorized to view team variables
}
// Get project variables if we have a project_uuid in route
$projectUuid = data_get($this->parameters, 'project_uuid');
if ($projectUuid) {
$project = Project::where('team_id', $team->id)
->where('uuid', $projectUuid)
->first();
if ($project) {
try {
$this->authorize('view', $project);
$result['project'] = $project->environment_variables()
->pluck('key')
->toArray();
// Get environment variables if we have an environment_uuid in route
$environmentUuid = data_get($this->parameters, 'environment_uuid');
if ($environmentUuid) {
$environment = $project->environments()
->where('uuid', $environmentUuid)
->first();
if ($environment) {
try {
$this->authorize('view', $environment);
$result['environment'] = $environment->environment_variables()
->pluck('key')
->toArray();
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
// User not authorized to view environment variables
}
}
}
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
// User not authorized to view project variables
}
}
}
return $result;
}
public function submit()
{
$this->validate();
@@ -2,11 +2,14 @@
namespace App\Livewire\Project\Shared\EnvironmentVariable;
use App\Models\Environment;
use App\Models\EnvironmentVariable as ModelsEnvironmentVariable;
use App\Models\Project;
use App\Models\SharedEnvironmentVariable;
use App\Traits\EnvironmentVariableAnalyzer;
use App\Traits\EnvironmentVariableProtection;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Computed;
use Livewire\Component;
class Show extends Component
@@ -184,6 +187,7 @@ class Show extends Component
$this->serialize();
$this->syncData(true);
$this->syncData(false);
$this->dispatch('success', 'Environment variable updated.');
$this->dispatch('envsUpdated');
$this->dispatch('configurationChanged');
@@ -192,6 +196,72 @@ class Show extends Component
}
}
#[Computed]
public function availableSharedVariables(): array
{
$team = currentTeam();
$result = [
'team' => [],
'project' => [],
'environment' => [],
];
// Early return if no team
if (! $team) {
return $result;
}
// Check if user can view team variables
try {
$this->authorize('view', $team);
$result['team'] = $team->environment_variables()
->pluck('key')
->toArray();
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
// User not authorized to view team variables
}
// Get project variables if we have a project_uuid in route
$projectUuid = data_get($this->parameters, 'project_uuid');
if ($projectUuid) {
$project = Project::where('team_id', $team->id)
->where('uuid', $projectUuid)
->first();
if ($project) {
try {
$this->authorize('view', $project);
$result['project'] = $project->environment_variables()
->pluck('key')
->toArray();
// Get environment variables if we have an environment_uuid in route
$environmentUuid = data_get($this->parameters, 'environment_uuid');
if ($environmentUuid) {
$environment = $project->environments()
->where('uuid', $environmentUuid)
->first();
if ($environment) {
try {
$this->authorize('view', $environment);
$result['environment'] = $environment->environment_variables()
->pluck('key')
->toArray();
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
// User not authorized to view environment variables
}
}
}
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
// User not authorized to view project variables
}
}
}
return $result;
}
public function delete()
{
try {
@@ -34,11 +34,14 @@ class Add extends Component
public ?string $container = '';
public int $timeout = 300;
protected $rules = [
'name' => 'required|string',
'command' => 'required|string',
'frequency' => 'required|string',
'container' => 'nullable|string',
'timeout' => 'required|integer|min:60|max:3600',
];
protected $validationAttributes = [
@@ -46,6 +49,7 @@ class Add extends Component
'command' => 'command',
'frequency' => 'frequency',
'container' => 'container',
'timeout' => 'timeout',
];
public function mount()
@@ -103,6 +107,7 @@ class Add extends Component
$task->command = $this->command;
$task->frequency = $this->frequency;
$task->container = $this->container;
$task->timeout = $this->timeout;
$task->team_id = currentTeam()->id;
switch ($this->type) {
@@ -130,5 +135,6 @@ class Add extends Component
$this->command = '';
$this->frequency = '';
$this->container = '';
$this->timeout = 300;
}
}
@@ -40,6 +40,9 @@ class Show extends Component
#[Validate(['string', 'nullable'])]
public ?string $container = null;
#[Validate(['integer', 'required', 'min:60', 'max:3600'])]
public $timeout = 300;
#[Locked]
public ?string $application_uuid;
@@ -99,6 +102,7 @@ class Show extends Component
$this->task->command = str($this->command)->trim()->value();
$this->task->frequency = str($this->frequency)->trim()->value();
$this->task->container = str($this->container)->trim()->value();
$this->task->timeout = (int) $this->timeout;
$this->task->save();
} else {
$this->isEnabled = $this->task->enabled;
@@ -106,6 +110,7 @@ class Show extends Component
$this->command = $this->task->command;
$this->frequency = $this->task->frequency;
$this->container = $this->task->container;
$this->timeout = $this->task->timeout ?? 300;
}
}
+41 -7
View File
@@ -5,7 +5,8 @@ namespace App\Livewire\Server;
use App\Actions\Proxy\CheckProxy;
use App\Actions\Proxy\StartProxy;
use App\Actions\Proxy\StopProxy;
use App\Jobs\RestartProxyJob;
use App\Enums\ProxyTypes;
use App\Jobs\CheckTraefikVersionForServerJob;
use App\Models\Server;
use App\Services\ProxyDashboardCacheService;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
@@ -61,7 +62,18 @@ class Navbar extends Component
{
try {
$this->authorize('manageProxy', $this->server);
RestartProxyJob::dispatch($this->server);
StopProxy::run($this->server, restarting: true);
$this->server->proxy->force_stop = false;
$this->server->save();
$activity = StartProxy::run($this->server, force: true, restarting: true);
$this->dispatch('activityMonitor', $activity->id);
// Check Traefik version after restart to provide immediate feedback
if ($this->server->proxyType() === ProxyTypes::TRAEFIK->value) {
CheckTraefikVersionForServerJob::dispatch($this->server, get_traefik_versions());
}
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -118,19 +130,25 @@ class Navbar extends Component
public function showNotification()
{
$previousStatus = $this->proxyStatus;
$this->server->refresh();
$this->proxyStatus = $this->server->proxy->status ?? 'unknown';
switch ($this->proxyStatus) {
case 'running':
$this->loadProxyConfiguration();
$this->dispatch('success', 'Proxy is running.');
break;
case 'restarting':
$this->dispatch('info', 'Initiating proxy restart.');
// Only show "Proxy is running" notification when transitioning from a stopped/error state
// Don't show during normal start/restart flows (starting, restarting, stopping)
if (in_array($previousStatus, ['exited', 'stopped', 'unknown', null])) {
$this->dispatch('success', 'Proxy is running.');
}
break;
case 'exited':
$this->dispatch('info', 'Proxy has exited.');
// Only show "Proxy has exited" notification when transitioning from running state
// Don't show during normal stop/restart flows (stopping, restarting)
if (in_array($previousStatus, ['running'])) {
$this->dispatch('info', 'Proxy has exited.');
}
break;
case 'stopping':
$this->dispatch('info', 'Proxy is stopping.');
@@ -154,6 +172,22 @@ class Navbar extends Component
$this->server->load('settings');
}
/**
* Check if Traefik has any outdated version info (patch or minor upgrade).
* This shows a warning indicator in the navbar.
*/
public function getHasTraefikOutdatedProperty(): bool
{
if ($this->server->proxyType() !== ProxyTypes::TRAEFIK->value) {
return false;
}
// Check if server has outdated info stored
$outdatedInfo = $this->server->traefik_outdated_info;
return ! empty($outdatedInfo) && isset($outdatedInfo['type']);
}
public function render()
{
return view('livewire.server.navbar');
+164 -2
View File
@@ -4,6 +4,7 @@ namespace App\Livewire\Server;
use App\Actions\Proxy\GetProxyConfiguration;
use App\Actions\Proxy\SaveProxyConfiguration;
use App\Enums\ProxyTypes;
use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
@@ -24,6 +25,12 @@ class Proxy extends Component
public bool $generateExactLabels = false;
/**
* Cache the versions.json file data in memory for this component instance.
* This avoids multiple file reads during a single request/render cycle.
*/
protected ?array $cachedVersionsFile = null;
public function getListeners()
{
$teamId = auth()->user()->currentTeam()->id;
@@ -55,9 +62,37 @@ class Proxy extends Component
}
}
public function getConfigurationFilePathProperty()
/**
* Get Traefik versions from cached data with in-memory optimization.
* Returns array like: ['v3.5' => '3.5.6', 'v3.6' => '3.6.2']
*
* This method adds an in-memory cache layer on top of the global
* get_traefik_versions() helper to avoid multiple calls during
* a single component lifecycle/render.
*/
protected function getTraefikVersions(): ?array
{
return $this->server->proxyPath().'docker-compose.yml';
// In-memory cache for this component instance (per-request)
if ($this->cachedVersionsFile !== null) {
return data_get($this->cachedVersionsFile, 'traefik');
}
// Load from global cached helper (Redis + filesystem)
$versionsData = get_versions_data();
$this->cachedVersionsFile = $versionsData;
if (! $versionsData) {
return null;
}
$traefikVersions = data_get($versionsData, 'traefik');
return is_array($traefikVersions) ? $traefikVersions : null;
}
public function getConfigurationFilePathProperty(): string
{
return rtrim($this->server->proxyPath(), '/') . '/docker-compose.yml';
}
public function changeProxy()
@@ -144,4 +179,131 @@ class Proxy extends Component
return handleError($e, $this);
}
}
/**
* Get the latest Traefik version for this server's current branch.
*
* This compares the server's detected version against available versions
* in versions.json to determine the latest patch for the current branch,
* or the newest available version if no current version is detected.
*/
public function getLatestTraefikVersionProperty(): ?string
{
try {
$traefikVersions = $this->getTraefikVersions();
if (! $traefikVersions) {
return null;
}
// Get this server's current version
$currentVersion = $this->server->detected_traefik_version;
// If we have a current version, try to find matching branch
if ($currentVersion && $currentVersion !== 'latest') {
$current = ltrim($currentVersion, 'v');
if (preg_match('/^(\d+\.\d+)/', $current, $matches)) {
$branch = "v{$matches[1]}";
if (isset($traefikVersions[$branch])) {
$version = $traefikVersions[$branch];
return str_starts_with($version, 'v') ? $version : "v{$version}";
}
}
}
// Return the newest available version
$newestVersion = collect($traefikVersions)
->map(fn ($v) => ltrim($v, 'v'))
->sortBy(fn ($v) => $v, SORT_NATURAL)
->last();
return $newestVersion ? "v{$newestVersion}" : null;
} catch (\Throwable $e) {
return null;
}
}
public function getIsTraefikOutdatedProperty(): bool
{
if ($this->server->proxyType() !== ProxyTypes::TRAEFIK->value) {
return false;
}
$currentVersion = $this->server->detected_traefik_version;
if (! $currentVersion || $currentVersion === 'latest') {
return false;
}
$latestVersion = $this->latestTraefikVersion;
if (! $latestVersion) {
return false;
}
// Compare versions (strip 'v' prefix)
$current = ltrim($currentVersion, 'v');
$latest = ltrim($latestVersion, 'v');
return version_compare($current, $latest, '<');
}
/**
* Check if a newer Traefik branch (minor version) is available for this server.
* Returns the branch identifier (e.g., "v3.6") if a newer branch exists.
*/
public function getNewerTraefikBranchAvailableProperty(): ?string
{
try {
if ($this->server->proxyType() !== ProxyTypes::TRAEFIK->value) {
return null;
}
// Get this server's current version
$currentVersion = $this->server->detected_traefik_version;
if (! $currentVersion || $currentVersion === 'latest') {
return null;
}
// Check if we have outdated info stored for this server (faster than computing)
$outdatedInfo = $this->server->traefik_outdated_info;
if ($outdatedInfo && isset($outdatedInfo['type']) && $outdatedInfo['type'] === 'minor_upgrade') {
// Use the upgrade_target field if available (e.g., "v3.6")
if (isset($outdatedInfo['upgrade_target'])) {
return str_starts_with($outdatedInfo['upgrade_target'], 'v')
? $outdatedInfo['upgrade_target']
: "v{$outdatedInfo['upgrade_target']}";
}
}
// Fallback: compute from cached versions data
$traefikVersions = $this->getTraefikVersions();
if (! $traefikVersions) {
return null;
}
// Extract current branch (e.g., "3.5" from "3.5.6")
$current = ltrim($currentVersion, 'v');
if (! preg_match('/^(\d+\.\d+)/', $current, $matches)) {
return null;
}
$currentBranch = $matches[1];
// Find the newest branch that's greater than current
$newestBranch = null;
foreach ($traefikVersions as $branch => $version) {
$branchNum = ltrim($branch, 'v');
if (version_compare($branchNum, $currentBranch, '>')) {
if (! $newestBranch || version_compare($branchNum, $newestBranch, '>')) {
$newestBranch = $branchNum;
}
}
}
return $newestBranch ? "v{$newestBranch}" : null;
} catch (\Throwable $e) {
return null;
}
}
}
@@ -25,13 +25,25 @@ class DynamicConfigurationNavbar extends Component
$this->authorize('update', $this->server);
$proxy_path = $this->server->proxyPath();
$proxy_type = $this->server->proxyType();
// Decode filename: pipes are used to encode dots for Livewire property binding
// (e.g., 'my|service.yaml' -> 'my.service.yaml')
// This must happen BEFORE validation because validateShellSafePath() correctly
// rejects pipe characters as dangerous shell metacharacters
$file = str_replace('|', '.', $fileName);
// Validate filename to prevent command injection
validateShellSafePath($file, 'proxy configuration filename');
if ($proxy_type === 'CADDY' && $file === 'Caddyfile') {
$this->dispatch('error', 'Cannot delete Caddyfile.');
return;
}
instant_remote_process(["rm -f {$proxy_path}/dynamic/{$file}"], $this->server);
$fullPath = "{$proxy_path}/dynamic/{$file}";
$escapedPath = escapeshellarg($fullPath);
instant_remote_process(["rm -f {$escapedPath}"], $this->server);
if ($proxy_type === 'CADDY') {
$this->server->reloadCaddy();
}
@@ -41,6 +41,10 @@ class NewDynamicConfiguration extends Component
'fileName' => 'required',
'value' => 'required',
]);
// Validate filename to prevent command injection
validateShellSafePath($this->fileName, 'proxy configuration filename');
if (data_get($this->parameters, 'server_uuid')) {
$this->server = Server::ownedByCurrentTeam()->whereUuid(data_get($this->parameters, 'server_uuid'))->first();
}
@@ -65,8 +69,10 @@ class NewDynamicConfiguration extends Component
}
$proxy_path = $this->server->proxyPath();
$file = "{$proxy_path}/dynamic/{$this->fileName}";
$escapedFile = escapeshellarg($file);
if ($this->newFile) {
$exists = instant_remote_process(["test -f $file && echo 1 || echo 0"], $this->server);
$exists = instant_remote_process(["test -f {$escapedFile} && echo 1 || echo 0"], $this->server);
if ($exists == 1) {
$this->dispatch('error', 'File already exists');
@@ -80,7 +86,7 @@ class NewDynamicConfiguration extends Component
}
$base64_value = base64_encode($this->value);
instant_remote_process([
"echo '{$base64_value}' | base64 -d | tee {$file} > /dev/null",
"echo '{$base64_value}' | base64 -d | tee {$escapedFile} > /dev/null",
], $this->server);
if ($proxy_type === 'CADDY') {
$this->server->reloadCaddy();
+49 -1
View File
@@ -25,6 +25,8 @@ class ValidateAndInstall extends Component
public $supported_os_type = null;
public $prerequisites_installed = null;
public $docker_installed = null;
public $docker_compose_installed = null;
@@ -33,12 +35,15 @@ class ValidateAndInstall extends Component
public $error = null;
public string $installationStep = 'Prerequisites';
public bool $ask = false;
protected $listeners = [
'init',
'validateConnection',
'validateOS',
'validatePrerequisites',
'validateDockerEngine',
'validateDockerVersion',
'refresh' => '$refresh',
@@ -48,6 +53,7 @@ class ValidateAndInstall extends Component
{
$this->uptime = null;
$this->supported_os_type = null;
$this->prerequisites_installed = null;
$this->docker_installed = null;
$this->docker_version = null;
$this->docker_compose_installed = null;
@@ -69,6 +75,7 @@ class ValidateAndInstall extends Component
$this->authorize('update', $this->server);
$this->uptime = null;
$this->supported_os_type = null;
$this->prerequisites_installed = null;
$this->docker_installed = null;
$this->docker_compose_installed = null;
$this->docker_version = null;
@@ -103,6 +110,43 @@ class ValidateAndInstall extends Component
return;
}
$this->dispatch('validatePrerequisites');
}
public function validatePrerequisites()
{
$validationResult = $this->server->validatePrerequisites();
$this->prerequisites_installed = $validationResult['success'];
if (! $validationResult['success']) {
if ($this->install) {
if ($this->number_of_tries == $this->max_tries) {
$missingCommands = implode(', ', $validationResult['missing']);
$this->error = "Prerequisites ({$missingCommands}) could not be installed. Please install them manually before continuing.";
$this->server->update([
'validation_logs' => $this->error,
]);
return;
} else {
if ($this->number_of_tries <= $this->max_tries) {
$this->installationStep = 'Prerequisites';
$activity = $this->server->installPrerequisites();
$this->number_of_tries++;
$this->dispatch('activityMonitor', $activity->id, 'init', $this->number_of_tries, "{$this->installationStep} Installation Logs");
}
return;
}
} else {
$missingCommands = implode(', ', $validationResult['missing']);
$this->error = "Prerequisites ({$missingCommands}) are not installed. Please install them before continuing.";
$this->server->update([
'validation_logs' => $this->error,
]);
return;
}
}
$this->dispatch('validateDockerEngine');
}
@@ -121,9 +165,10 @@ class ValidateAndInstall extends Component
return;
} else {
if ($this->number_of_tries <= $this->max_tries) {
$this->installationStep = 'Docker';
$activity = $this->server->installDocker();
$this->number_of_tries++;
$this->dispatch('activityMonitor', $activity->id, 'init', $this->number_of_tries);
$this->dispatch('activityMonitor', $activity->id, 'init', $this->number_of_tries, "{$this->installationStep} Installation Logs");
}
return;
@@ -161,6 +206,9 @@ class ValidateAndInstall extends Component
if (! $proxyShouldRun) {
return;
}
// Ensure networks exist BEFORE dispatching async proxy startup
// This prevents race condition where proxy tries to start before networks are created
instant_remote_process(ensureProxyNetworksExist($this->server)->toArray(), $this->server, false);
StartProxy::dispatch($this->server);
} else {
$requiredDockerVersion = str(config('constants.docker.minimum_required_version'))->before('.');
+35
View File
@@ -44,6 +44,8 @@ class Index extends Component
public bool $forceSaveDomains = false;
public $buildActivityId = null;
public function render()
{
return view('livewire.settings.index');
@@ -151,4 +153,37 @@ class Index extends Component
return handleError($e, $this);
}
}
public function buildHelperImage()
{
try {
if (! isDev()) {
$this->dispatch('error', 'Building helper image is only available in development mode.');
return;
}
$version = $this->dev_helper_version ?: config('constants.coolify.helper_version');
if (empty($version)) {
$this->dispatch('error', 'Please specify a version to build.');
return;
}
$buildCommand = "docker build -t ghcr.io/coollabsio/coolify-helper:{$version} -f docker/coolify-helper/Dockerfile .";
$activity = remote_process(
command: [$buildCommand],
server: $this->server,
type: 'build-helper-image'
);
$this->buildActivityId = $activity->id;
$this->dispatch('activityMonitor', $activity->id);
$this->dispatch('success', "Building coolify-helper:{$version}...");
} catch (\Exception $e) {
return handleError($e, $this);
}
}
}
@@ -5,6 +5,7 @@ namespace App\Livewire\SharedVariables\Environment;
use App\Models\Application;
use App\Models\Project;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
class Show extends Component
@@ -19,7 +20,11 @@ class Show extends Component
public array $parameters;
protected $listeners = ['refreshEnvs' => '$refresh', 'saveKey', 'environmentVariableDeleted' => '$refresh'];
public string $view = 'normal';
public ?string $variables = null;
protected $listeners = ['refreshEnvs' => 'refreshEnvs', 'saveKey', 'environmentVariableDeleted' => 'refreshEnvs'];
public function saveKey($data)
{
@@ -39,6 +44,7 @@ class Show extends Component
'team_id' => currentTeam()->id,
]);
$this->environment->refresh();
$this->getDevView();
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -49,6 +55,120 @@ class Show extends Component
$this->parameters = get_route_parameters();
$this->project = Project::ownedByCurrentTeam()->where('uuid', request()->route('project_uuid'))->firstOrFail();
$this->environment = $this->project->environments()->where('uuid', request()->route('environment_uuid'))->firstOrFail();
$this->getDevView();
}
public function switch()
{
$this->authorize('view', $this->environment);
$this->view = $this->view === 'normal' ? 'dev' : 'normal';
$this->getDevView();
}
public function getDevView()
{
$this->variables = $this->formatEnvironmentVariables($this->environment->environment_variables->sortBy('key'));
}
private function formatEnvironmentVariables($variables)
{
return $variables->map(function ($item) {
if ($item->is_shown_once) {
return "$item->key=(Locked Secret, delete and add again to change)";
}
if ($item->is_multiline) {
return "$item->key=(Multiline environment variable, edit in normal view)";
}
return "$item->key=$item->value";
})->join("\n");
}
public function submit()
{
try {
$this->authorize('update', $this->environment);
$this->handleBulkSubmit();
$this->getDevView();
} catch (\Throwable $e) {
return handleError($e, $this);
} finally {
$this->refreshEnvs();
}
}
private function handleBulkSubmit()
{
$variables = parseEnvFormatToArray($this->variables);
$changesMade = false;
DB::transaction(function () use ($variables, &$changesMade) {
// Delete removed variables
$deletedCount = $this->deleteRemovedVariables($variables);
if ($deletedCount > 0) {
$changesMade = true;
}
// Update or create variables
$updatedCount = $this->updateOrCreateVariables($variables);
if ($updatedCount > 0) {
$changesMade = true;
}
});
// Only dispatch success after transaction has committed
if ($changesMade) {
$this->dispatch('success', 'Environment variables updated.');
}
}
private function deleteRemovedVariables($variables)
{
$variablesToDelete = $this->environment->environment_variables()->whereNotIn('key', array_keys($variables))->get();
if ($variablesToDelete->isEmpty()) {
return 0;
}
$this->environment->environment_variables()->whereNotIn('key', array_keys($variables))->delete();
return $variablesToDelete->count();
}
private function updateOrCreateVariables($variables)
{
$count = 0;
foreach ($variables as $key => $value) {
$found = $this->environment->environment_variables()->where('key', $key)->first();
if ($found) {
if (! $found->is_shown_once && ! $found->is_multiline) {
if ($found->value !== $value) {
$found->value = $value;
$found->save();
$count++;
}
}
} else {
$this->environment->environment_variables()->create([
'key' => $key,
'value' => $value,
'is_multiline' => false,
'is_literal' => false,
'type' => 'environment',
'team_id' => currentTeam()->id,
]);
$count++;
}
}
return $count;
}
public function refreshEnvs()
{
$this->environment->refresh();
$this->getDevView();
}
public function render()
+115 -1
View File
@@ -4,6 +4,7 @@ namespace App\Livewire\SharedVariables\Project;
use App\Models\Project;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
class Show extends Component
@@ -12,7 +13,11 @@ class Show extends Component
public Project $project;
protected $listeners = ['refreshEnvs' => '$refresh', 'saveKey' => 'saveKey', 'environmentVariableDeleted' => '$refresh'];
public string $view = 'normal';
public ?string $variables = null;
protected $listeners = ['refreshEnvs' => 'refreshEnvs', 'saveKey' => 'saveKey', 'environmentVariableDeleted' => 'refreshEnvs'];
public function saveKey($data)
{
@@ -32,6 +37,7 @@ class Show extends Component
'team_id' => currentTeam()->id,
]);
$this->project->refresh();
$this->getDevView();
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -46,6 +52,114 @@ class Show extends Component
return redirect()->route('dashboard');
}
$this->project = $project;
$this->getDevView();
}
public function switch()
{
$this->authorize('view', $this->project);
$this->view = $this->view === 'normal' ? 'dev' : 'normal';
$this->getDevView();
}
public function getDevView()
{
$this->variables = $this->formatEnvironmentVariables($this->project->environment_variables->sortBy('key'));
}
private function formatEnvironmentVariables($variables)
{
return $variables->map(function ($item) {
if ($item->is_shown_once) {
return "$item->key=(Locked Secret, delete and add again to change)";
}
if ($item->is_multiline) {
return "$item->key=(Multiline environment variable, edit in normal view)";
}
return "$item->key=$item->value";
})->join("\n");
}
public function submit()
{
try {
$this->authorize('update', $this->project);
$this->handleBulkSubmit();
$this->getDevView();
} catch (\Throwable $e) {
return handleError($e, $this);
} finally {
$this->refreshEnvs();
}
}
private function handleBulkSubmit()
{
$variables = parseEnvFormatToArray($this->variables);
$changesMade = DB::transaction(function () use ($variables) {
// Delete removed variables
$deletedCount = $this->deleteRemovedVariables($variables);
// Update or create variables
$updatedCount = $this->updateOrCreateVariables($variables);
return $deletedCount > 0 || $updatedCount > 0;
});
if ($changesMade) {
$this->dispatch('success', 'Environment variables updated.');
}
}
private function deleteRemovedVariables($variables)
{
$variablesToDelete = $this->project->environment_variables()->whereNotIn('key', array_keys($variables))->get();
if ($variablesToDelete->isEmpty()) {
return 0;
}
$this->project->environment_variables()->whereNotIn('key', array_keys($variables))->delete();
return $variablesToDelete->count();
}
private function updateOrCreateVariables($variables)
{
$count = 0;
foreach ($variables as $key => $value) {
$found = $this->project->environment_variables()->where('key', $key)->first();
if ($found) {
if (! $found->is_shown_once && ! $found->is_multiline) {
if ($found->value !== $value) {
$found->value = $value;
$found->save();
$count++;
}
}
} else {
$this->project->environment_variables()->create([
'key' => $key,
'value' => $value,
'is_multiline' => false,
'is_literal' => false,
'type' => 'project',
'team_id' => currentTeam()->id,
]);
$count++;
}
}
return $count;
}
public function refreshEnvs()
{
$this->project->refresh();
$this->getDevView();
}
public function render()
+120 -1
View File
@@ -4,6 +4,7 @@ namespace App\Livewire\SharedVariables\Team;
use App\Models\Team;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
class Index extends Component
@@ -12,7 +13,11 @@ class Index extends Component
public Team $team;
protected $listeners = ['refreshEnvs' => '$refresh', 'saveKey' => 'saveKey', 'environmentVariableDeleted' => '$refresh'];
public string $view = 'normal';
public ?string $variables = null;
protected $listeners = ['refreshEnvs' => 'refreshEnvs', 'saveKey' => 'saveKey', 'environmentVariableDeleted' => 'refreshEnvs'];
public function saveKey($data)
{
@@ -32,6 +37,7 @@ class Index extends Component
'team_id' => currentTeam()->id,
]);
$this->team->refresh();
$this->getDevView();
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -40,6 +46,119 @@ class Index extends Component
public function mount()
{
$this->team = currentTeam();
$this->getDevView();
}
public function switch()
{
$this->authorize('view', $this->team);
$this->view = $this->view === 'normal' ? 'dev' : 'normal';
$this->getDevView();
}
public function getDevView()
{
$this->variables = $this->formatEnvironmentVariables($this->team->environment_variables->sortBy('key'));
}
private function formatEnvironmentVariables($variables)
{
return $variables->map(function ($item) {
if ($item->is_shown_once) {
return "$item->key=(Locked Secret, delete and add again to change)";
}
if ($item->is_multiline) {
return "$item->key=(Multiline environment variable, edit in normal view)";
}
return "$item->key=$item->value";
})->join("\n");
}
public function submit()
{
try {
$this->authorize('update', $this->team);
$this->handleBulkSubmit();
$this->getDevView();
} catch (\Throwable $e) {
return handleError($e, $this);
} finally {
$this->refreshEnvs();
}
}
private function handleBulkSubmit()
{
$variables = parseEnvFormatToArray($this->variables);
$changesMade = false;
DB::transaction(function () use ($variables, &$changesMade) {
// Delete removed variables
$deletedCount = $this->deleteRemovedVariables($variables);
if ($deletedCount > 0) {
$changesMade = true;
}
// Update or create variables
$updatedCount = $this->updateOrCreateVariables($variables);
if ($updatedCount > 0) {
$changesMade = true;
}
});
if ($changesMade) {
$this->dispatch('success', 'Environment variables updated.');
}
}
private function deleteRemovedVariables($variables)
{
$variablesToDelete = $this->team->environment_variables()->whereNotIn('key', array_keys($variables))->get();
if ($variablesToDelete->isEmpty()) {
return 0;
}
$this->team->environment_variables()->whereNotIn('key', array_keys($variables))->delete();
return $variablesToDelete->count();
}
private function updateOrCreateVariables($variables)
{
$count = 0;
foreach ($variables as $key => $value) {
$found = $this->team->environment_variables()->where('key', $key)->first();
if ($found) {
if (! $found->is_shown_once && ! $found->is_multiline) {
if ($found->value !== $value) {
$found->value = $value;
$found->save();
$count++;
}
}
} else {
$this->team->environment_variables()->create([
'key' => $key,
'value' => $value,
'is_multiline' => false,
'is_literal' => false,
'type' => 'team',
'team_id' => currentTeam()->id,
]);
$count++;
}
}
return $count;
}
public function refreshEnvs()
{
$this->team->refresh();
$this->getDevView();
}
public function render()
+8 -1
View File
@@ -120,9 +120,16 @@ class Form extends Component
$this->storage->testConnection(shouldSave: true);
// Update component property to reflect the new validation status
$this->isUsable = $this->storage->is_usable;
return $this->dispatch('success', 'Connection is working.', 'Tested with "ListObjectsV2" action.');
} catch (\Throwable $e) {
$this->dispatch('error', 'Failed to create storage.', $e->getMessage());
// Refresh model and sync to get the latest state
$this->storage->refresh();
$this->isUsable = $this->storage->is_usable;
$this->dispatch('error', 'Failed to test connection.', $e->getMessage());
}
}
+4
View File
@@ -3,10 +3,13 @@
namespace App\Livewire\Storage;
use App\Models\S3Storage;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class Show extends Component
{
use AuthorizesRequests;
public $storage = null;
public function mount()
@@ -15,6 +18,7 @@ class Show extends Component
if (! $this->storage) {
abort(404);
}
$this->authorize('view', $this->storage);
}
public function render()
+66 -11
View File
@@ -121,6 +121,8 @@ class Application extends BaseModel
protected $casts = [
'http_basic_auth_password' => 'encrypted',
'restart_count' => 'integer',
'last_restart_at' => 'datetime',
];
protected static function booted()
@@ -174,6 +176,39 @@ class Application extends BaseModel
if (count($payload) > 0) {
$application->forceFill($payload);
}
// Buildpack switching cleanup logic
if ($application->isDirty('build_pack')) {
$originalBuildPack = $application->getOriginal('build_pack');
// Clear Docker Compose specific data when switching away from dockercompose
if ($originalBuildPack === 'dockercompose') {
$application->docker_compose_domains = null;
$application->docker_compose_raw = null;
// Remove SERVICE_FQDN_* and SERVICE_URL_* environment variables
$application->environment_variables()
->where(function ($q) {
$q->where('key', 'LIKE', 'SERVICE_FQDN_%')
->orWhere('key', 'LIKE', 'SERVICE_URL_%');
})
->delete();
$application->environment_variables_preview()
->where(function ($q) {
$q->where('key', 'LIKE', 'SERVICE_FQDN_%')
->orWhere('key', 'LIKE', 'SERVICE_URL_%');
})
->delete();
}
// Clear Dockerfile specific data when switching away from dockerfile
if ($originalBuildPack === 'dockerfile') {
$application->dockerfile = null;
$application->dockerfile_location = null;
$application->dockerfile_target_build = null;
$application->custom_healthcheck_found = false;
}
}
});
static::created(function ($application) {
ApplicationSetting::create([
@@ -634,21 +669,23 @@ class Application extends BaseModel
{
return Attribute::make(
get: function () {
if (! $this->relationLoaded('additional_servers') || $this->additional_servers->count() === 0) {
return $this->destination?->server?->isFunctional() ?? false;
// Check main server infrastructure health
$main_server_functional = $this->destination?->server?->isFunctional() ?? false;
if (! $main_server_functional) {
return false;
}
$additional_servers_status = $this->additional_servers->pluck('pivot.status');
$main_server_status = $this->destination?->server?->isFunctional() ?? false;
foreach ($additional_servers_status as $status) {
$server_status = str($status)->before(':')->value();
if ($server_status !== 'running') {
return false;
// Check additional servers infrastructure health (not container status!)
if ($this->relationLoaded('additional_servers') && $this->additional_servers->count() > 0) {
foreach ($this->additional_servers as $server) {
if (! $server->isFunctional()) {
return false; // Real server infrastructure problem
}
}
}
return $main_server_status;
return true;
}
);
}
@@ -772,6 +809,24 @@ class Application extends BaseModel
return $this->settings->is_static ? [80] : $this->ports_exposes_array;
}
public function detectPortFromEnvironment(?bool $isPreview = false): ?int
{
$envVars = $isPreview
? $this->environment_variables_preview
: $this->environment_variables;
$portVar = $envVars->firstWhere('key', 'PORT');
if ($portVar && $portVar->real_value) {
$portValue = trim($portVar->real_value);
if (is_numeric($portValue)) {
return (int) $portValue;
}
}
return null;
}
public function environment_variables()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
@@ -980,7 +1035,7 @@ class Application extends BaseModel
public function isConfigurationChanged(bool $save = false)
{
$newConfigHash = base64_encode($this->fqdn.$this->git_repository.$this->git_branch.$this->git_commit_sha.$this->build_pack.$this->static_image.$this->install_command.$this->build_command.$this->start_command.$this->ports_exposes.$this->ports_mappings.$this->custom_network_aliases.$this->base_directory.$this->publish_directory.$this->dockerfile.$this->dockerfile_location.$this->custom_labels.$this->custom_docker_run_options.$this->dockerfile_target_build.$this->redirect.$this->custom_nginx_configuration.$this->settings->use_build_secrets);
$newConfigHash = base64_encode($this->fqdn.$this->git_repository.$this->git_branch.$this->git_commit_sha.$this->build_pack.$this->static_image.$this->install_command.$this->build_command.$this->start_command.$this->ports_exposes.$this->ports_mappings.$this->custom_network_aliases.$this->base_directory.$this->publish_directory.$this->dockerfile.$this->dockerfile_location.$this->custom_labels.$this->custom_docker_run_options.$this->dockerfile_target_build.$this->redirect.$this->custom_nginx_configuration.$this->settings->use_build_secrets.$this->settings->inject_build_args_to_dockerfile.$this->settings->include_source_commit_in_build);
if ($this->pull_request_id === 0 || $this->pull_request_id === null) {
$newConfigHash .= json_encode($this->environment_variables()->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->sort());
} else {
+2
View File
@@ -15,6 +15,8 @@ class ApplicationSetting extends Model
'is_container_label_escape_enabled' => 'boolean',
'is_container_label_readonly_enabled' => 'boolean',
'use_build_secrets' => 'boolean',
'inject_build_args_to_dockerfile' => 'boolean',
'include_source_commit_in_build' => 'boolean',
'is_auto_deploy_enabled' => 'boolean',
'is_force_https_enabled' => 'boolean',
'is_debug_enabled' => 'boolean',
@@ -29,6 +29,7 @@ class DiscordNotificationSettings extends Model
'server_reachable_discord_notifications',
'server_unreachable_discord_notifications',
'server_patch_discord_notifications',
'traefik_outdated_discord_notifications',
'discord_ping_enabled',
];
@@ -48,6 +49,7 @@ class DiscordNotificationSettings extends Model
'server_reachable_discord_notifications' => 'boolean',
'server_unreachable_discord_notifications' => 'boolean',
'server_patch_discord_notifications' => 'boolean',
'traefik_outdated_discord_notifications' => 'boolean',
'discord_ping_enabled' => 'boolean',
];
+2
View File
@@ -36,6 +36,7 @@ class EmailNotificationSettings extends Model
'scheduled_task_failure_email_notifications',
'server_disk_usage_email_notifications',
'server_patch_email_notifications',
'traefik_outdated_email_notifications',
];
protected $casts = [
@@ -63,6 +64,7 @@ class EmailNotificationSettings extends Model
'scheduled_task_failure_email_notifications' => 'boolean',
'server_disk_usage_email_notifications' => 'boolean',
'server_patch_email_notifications' => 'boolean',
'traefik_outdated_email_notifications' => 'boolean',
];
public function team()
+3 -3
View File
@@ -190,11 +190,11 @@ class EnvironmentVariable extends BaseModel
return $environment_variable;
}
foreach ($sharedEnvsFound as $sharedEnv) {
$type = str($sharedEnv)->match('/(.*?)\./');
$type = str($sharedEnv)->trim()->match('/(.*?)\./');
if (! collect(SHARED_VARIABLE_TYPES)->contains($type)) {
continue;
}
$variable = str($sharedEnv)->match('/\.(.*)/');
$variable = str($sharedEnv)->trim()->match('/\.(.*)/');
if ($type->value() === 'environment') {
$id = $resource->environment->id;
} elseif ($type->value() === 'project') {
@@ -231,7 +231,7 @@ class EnvironmentVariable extends BaseModel
$environment_variable = trim($environment_variable);
$type = str($environment_variable)->after('{{')->before('.')->value;
if (str($environment_variable)->startsWith('{{'.$type) && str($environment_variable)->endsWith('}}')) {
return encrypt((string) str($environment_variable)->replace(' ', ''));
return encrypt($environment_variable);
}
return encrypt($environment_variable);
-9
View File
@@ -2,7 +2,6 @@
namespace App\Models;
use App\Jobs\PullHelperImageJob;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Spatie\Url\Url;
@@ -35,14 +34,6 @@ class InstanceSettings extends Model
protected static function booted(): void
{
static::updated(function ($settings) {
if ($settings->wasChanged('helper_version')) {
Server::chunkById(100, function ($servers) {
foreach ($servers as $server) {
PullHelperImageJob::dispatch($server);
}
});
}
// Clear trusted hosts cache when FQDN changes
if ($settings->wasChanged('fqdn')) {
\Cache::forget('instance_settings_fqdn_host');
+33 -18
View File
@@ -61,9 +61,14 @@ class LocalFileVolume extends BaseModel
$path = $path->after('.');
$path = $workdir.$path;
}
$isFile = instant_remote_process(["test -f $path && echo OK || echo NOK"], $server);
// Validate and escape path to prevent command injection
validateShellSafePath($path, 'storage path');
$escapedPath = escapeshellarg($path);
$isFile = instant_remote_process(["test -f {$escapedPath} && echo OK || echo NOK"], $server);
if ($isFile === 'OK') {
$content = instant_remote_process(["cat $path"], $server, false);
$content = instant_remote_process(["cat {$escapedPath}"], $server, false);
// Check if content contains binary data by looking for null bytes or non-printable characters
if (str_contains($content, "\0") || preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $content)) {
$content = '[binary file]';
@@ -91,14 +96,19 @@ class LocalFileVolume extends BaseModel
$path = $path->after('.');
$path = $workdir.$path;
}
$isFile = instant_remote_process(["test -f $path && echo OK || echo NOK"], $server);
$isDir = instant_remote_process(["test -d $path && echo OK || echo NOK"], $server);
// Validate and escape path to prevent command injection
validateShellSafePath($path, 'storage path');
$escapedPath = escapeshellarg($path);
$isFile = instant_remote_process(["test -f {$escapedPath} && echo OK || echo NOK"], $server);
$isDir = instant_remote_process(["test -d {$escapedPath} && echo OK || echo NOK"], $server);
if ($path && $path != '/' && $path != '.' && $path != '..') {
if ($isFile === 'OK') {
$commands->push("rm -rf $path > /dev/null 2>&1 || true");
$commands->push("rm -rf {$escapedPath} > /dev/null 2>&1 || true");
} elseif ($isDir === 'OK') {
$commands->push("rm -rf $path > /dev/null 2>&1 || true");
$commands->push("rmdir $path > /dev/null 2>&1 || true");
$commands->push("rm -rf {$escapedPath} > /dev/null 2>&1 || true");
$commands->push("rmdir {$escapedPath} > /dev/null 2>&1 || true");
}
}
if ($commands->count() > 0) {
@@ -135,10 +145,15 @@ class LocalFileVolume extends BaseModel
$path = $path->after('.');
$path = $workdir.$path;
}
$isFile = instant_remote_process(["test -f $path && echo OK || echo NOK"], $server);
$isDir = instant_remote_process(["test -d $path && echo OK || echo NOK"], $server);
// Validate and escape path to prevent command injection
validateShellSafePath($path, 'storage path');
$escapedPath = escapeshellarg($path);
$isFile = instant_remote_process(["test -f {$escapedPath} && echo OK || echo NOK"], $server);
$isDir = instant_remote_process(["test -d {$escapedPath} && echo OK || echo NOK"], $server);
if ($isFile === 'OK' && $this->is_directory) {
$content = instant_remote_process(["cat $path"], $server, false);
$content = instant_remote_process(["cat {$escapedPath}"], $server, false);
$this->is_directory = false;
$this->content = $content;
$this->save();
@@ -151,8 +166,8 @@ class LocalFileVolume extends BaseModel
throw new \Exception('The following file is a directory on the server, but you are trying to mark it as a file. <br><br>Please delete the directory on the server or mark it as directory.');
}
instant_remote_process([
"rm -fr $path",
"touch $path",
"rm -fr {$escapedPath}",
"touch {$escapedPath}",
], $server, false);
FileStorageChanged::dispatch(data_get($server, 'team_id'));
}
@@ -161,19 +176,19 @@ class LocalFileVolume extends BaseModel
$chown = data_get($this, 'chown');
if ($content) {
$content = base64_encode($content);
$commands->push("echo '$content' | base64 -d | tee $path > /dev/null");
$commands->push("echo '$content' | base64 -d | tee {$escapedPath} > /dev/null");
} else {
$commands->push("touch $path");
$commands->push("touch {$escapedPath}");
}
$commands->push("chmod +x $path");
$commands->push("chmod +x {$escapedPath}");
if ($chown) {
$commands->push("chown $chown $path");
$commands->push("chown $chown {$escapedPath}");
}
if ($chmod) {
$commands->push("chmod $chmod $path");
$commands->push("chmod $chmod {$escapedPath}");
}
} elseif ($isDir === 'NOK' && $this->is_directory) {
$commands->push("mkdir -p $path > /dev/null 2>&1 || true");
$commands->push("mkdir -p {$escapedPath} > /dev/null 2>&1 || true");
}
return instant_remote_process($commands, $server);
@@ -30,6 +30,7 @@ class PushoverNotificationSettings extends Model
'server_reachable_pushover_notifications',
'server_unreachable_pushover_notifications',
'server_patch_pushover_notifications',
'traefik_outdated_pushover_notifications',
];
protected $casts = [
@@ -49,6 +50,7 @@ class PushoverNotificationSettings extends Model
'server_reachable_pushover_notifications' => 'boolean',
'server_unreachable_pushover_notifications' => 'boolean',
'server_patch_pushover_notifications' => 'boolean',
'traefik_outdated_pushover_notifications' => 'boolean',
];
public function team()
+14
View File
@@ -3,6 +3,7 @@
namespace App\Models;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Facades\Storage;
@@ -41,6 +42,19 @@ class S3Storage extends BaseModel
return "{$this->endpoint}/{$this->bucket}";
}
protected function path(): Attribute
{
return Attribute::make(
set: function (?string $value) {
if ($value === null || $value === '') {
return null;
}
return str($value)->trim()->start('/')->value();
}
);
}
public function testConnection(bool $shouldSave = false)
{
try {
+8
View File
@@ -12,6 +12,14 @@ class ScheduledTask extends BaseModel
protected $guarded = [];
protected function casts(): array
{
return [
'enabled' => 'boolean',
'timeout' => 'integer',
];
}
public function service()
{
return $this->belongsTo(Service::class);
+10
View File
@@ -8,6 +8,16 @@ class ScheduledTaskExecution extends BaseModel
{
protected $guarded = [];
protected function casts(): array
{
return [
'started_at' => 'datetime',
'finished_at' => 'datetime',
'retry_count' => 'integer',
'duration' => 'decimal:2',
];
}
public function scheduledTask(): BelongsTo
{
return $this->belongsTo(ScheduledTask::class);
+70
View File
@@ -4,7 +4,9 @@ namespace App\Models;
use App\Actions\Proxy\StartProxy;
use App\Actions\Server\InstallDocker;
use App\Actions\Server\InstallPrerequisites;
use App\Actions\Server\StartSentinel;
use App\Actions\Server\ValidatePrerequisites;
use App\Enums\ProxyTypes;
use App\Events\ServerReachabilityChanged;
use App\Helpers\SslHelper;
@@ -31,6 +33,51 @@ use Spatie\Url\Url;
use Symfony\Component\Yaml\Yaml;
use Visus\Cuid2\Cuid2;
/**
* @property array{
* current: string,
* latest: string,
* type: 'patch_update'|'minor_upgrade',
* checked_at: string,
* newer_branch_target?: string,
* newer_branch_latest?: string,
* upgrade_target?: string
* }|null $traefik_outdated_info Traefik version tracking information.
*
* This JSON column stores information about outdated Traefik proxy versions on this server.
* The structure varies depending on the type of update available:
*
* **For patch updates** (e.g., 3.5.0 → 3.5.2):
* ```php
* [
* 'current' => '3.5.0', // Current version (without 'v' prefix)
* 'latest' => '3.5.2', // Latest patch version available
* 'type' => 'patch_update', // Update type identifier
* 'checked_at' => '2025-11-14T10:00:00Z', // ISO8601 timestamp
* 'newer_branch_target' => 'v3.6', // (Optional) Available major/minor version
* 'newer_branch_latest' => '3.6.2' // (Optional) Latest version in that branch
* ]
* ```
*
* **For minor/major upgrades** (e.g., 3.5.6 → 3.6.2):
* ```php
* [
* 'current' => '3.5.6', // Current version
* 'latest' => '3.6.2', // Latest version in target branch
* 'type' => 'minor_upgrade', // Update type identifier
* 'upgrade_target' => 'v3.6', // Target branch (with 'v' prefix)
* 'checked_at' => '2025-11-14T10:00:00Z' // ISO8601 timestamp
* ]
* ```
*
* **Null value**: Set to null when:
* - Server is fully up-to-date with the latest version
* - Traefik image uses the 'latest' tag (no fixed version tracking)
* - No Traefik version detected on the server
*
* @see \App\Jobs\CheckTraefikVersionForServerJob Where this data is populated
* @see \App\Livewire\Server\Proxy Where this data is read and displayed
*/
#[OA\Schema(
description: 'Server model',
type: 'object',
@@ -142,6 +189,7 @@ class Server extends BaseModel
protected $casts = [
'proxy' => SchemalessAttributes::class,
'traefik_outdated_info' => 'array',
'logdrain_axiom_api_key' => 'encrypted',
'logdrain_newrelic_license_key' => 'encrypted',
'delete_unused_volumes' => 'boolean',
@@ -167,6 +215,8 @@ class Server extends BaseModel
'hetzner_server_id',
'hetzner_server_status',
'is_validating',
'detected_traefik_version',
'traefik_outdated_info',
];
protected $guarded = [];
@@ -522,6 +572,11 @@ $schema://$host {
return $this->proxy->modelScope();
}
public function scopeWhereProxyType(Builder $query, string $proxyType): Builder
{
return $query->where('proxy->type', $proxyType);
}
public function isLocalhost()
{
return $this->ip === 'host.docker.internal' || $this->id === 0;
@@ -1131,6 +1186,21 @@ $schema://$host {
return InstallDocker::run($this);
}
/**
* Validate that required commands are available on the server.
*
* @return array{success: bool, missing: array<string>, found: array<string>}
*/
public function validatePrerequisites(): array
{
return ValidatePrerequisites::run($this);
}
public function installPrerequisites()
{
return InstallPrerequisites::run($this);
}
public function validateDockerEngine($throwError = false)
{
$dockerBinary = instant_remote_process(['command -v docker'], $this, false, no_sudo: true);
+108 -56
View File
@@ -3,6 +3,7 @@
namespace App\Models;
use App\Enums\ProcessStatus;
use App\Services\ContainerStatusAggregator;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
@@ -173,6 +174,21 @@ class Service extends BaseModel
instant_remote_process(["docker network rm {$this->uuid}"], $server, false);
}
/**
* Calculate the service's aggregate status from its applications and databases.
*
* This method aggregates status from Eloquent model relationships (not Docker containers).
* It differs from the CalculatesExcludedStatus trait which works with Docker container objects
* during container inspection. This accessor runs on-demand for UI display and works with
* already-stored status strings from the database.
*
* Status format: "{status}:{health}" or "{status}:{health}:excluded"
* - Status values: running, exited, degraded, starting, paused, restarting
* - Health values: healthy, unhealthy, unknown
* - :excluded suffix: Indicates all containers are excluded from health monitoring
*
* @return string The aggregate status in format "status:health" or "status:health:excluded"
*/
public function getStatusAttribute()
{
if ($this->isStarting()) {
@@ -182,71 +198,102 @@ class Service extends BaseModel
$applications = $this->applications;
$databases = $this->databases;
$complexStatus = null;
$complexHealth = null;
[$complexStatus, $complexHealth, $hasNonExcluded] = $this->aggregateResourceStatuses(
$applications,
$databases,
excludedOnly: false
);
foreach ($applications as $application) {
if ($application->exclude_from_status) {
continue;
// If all services are excluded from status checks, calculate status from excluded containers
// but mark it with :excluded to indicate monitoring is disabled
if (! $hasNonExcluded && ($complexStatus === null && $complexHealth === null)) {
[$excludedStatus, $excludedHealth] = $this->aggregateResourceStatuses(
$applications,
$databases,
excludedOnly: true
);
// Return status with :excluded suffix to indicate monitoring is disabled
if ($excludedStatus && $excludedHealth) {
return "{$excludedStatus}:{$excludedHealth}:excluded";
}
$status = str($application->status)->before('(')->trim();
$health = str($application->status)->between('(', ')')->trim();
if ($complexStatus === 'degraded') {
continue;
}
if ($status->startsWith('running')) {
if ($complexStatus === 'exited') {
$complexStatus = 'degraded';
} else {
$complexStatus = 'running';
}
} elseif ($status->startsWith('restarting')) {
$complexStatus = 'degraded';
} elseif ($status->startsWith('exited')) {
$complexStatus = 'exited';
}
if ($health->value() === 'healthy') {
if ($complexHealth === 'unhealthy') {
continue;
}
$complexHealth = 'healthy';
} else {
$complexHealth = 'unhealthy';
// If no status was calculated at all (no containers exist), return unknown
if ($excludedStatus === null && $excludedHealth === null) {
return 'unknown:unknown:excluded';
}
return 'exited';
}
foreach ($databases as $database) {
if ($database->exclude_from_status) {
continue;
}
$status = str($database->status)->before('(')->trim();
$health = str($database->status)->between('(', ')')->trim();
if ($complexStatus === 'degraded') {
continue;
}
if ($status->startsWith('running')) {
if ($complexStatus === 'exited') {
$complexStatus = 'degraded';
} else {
$complexStatus = 'running';
}
} elseif ($status->startsWith('restarting')) {
$complexStatus = 'degraded';
} elseif ($status->startsWith('exited')) {
$complexStatus = 'exited';
}
if ($health->value() === 'healthy') {
if ($complexHealth === 'unhealthy') {
continue;
}
$complexHealth = 'healthy';
} else {
$complexHealth = 'unhealthy';
}
// If health is null/empty, return just the status without trailing colon
if ($complexHealth === null || $complexHealth === '') {
return $complexStatus;
}
return "{$complexStatus}:{$complexHealth}";
}
/**
* Aggregate status and health from collections of applications and databases.
*
* This helper method consolidates status aggregation logic using ContainerStatusAggregator.
* It processes container status strings stored in the database (not live Docker data).
*
* @param \Illuminate\Database\Eloquent\Collection $applications Collection of Application models
* @param \Illuminate\Database\Eloquent\Collection $databases Collection of Database models
* @param bool $excludedOnly If true, only process excluded containers; if false, only process non-excluded
* @return array{0: string|null, 1: string|null, 2?: bool} [status, health, hasNonExcluded (only when excludedOnly=false)]
*/
private function aggregateResourceStatuses($applications, $databases, bool $excludedOnly = false): array
{
$hasNonExcluded = false;
$statusStrings = collect();
// Process both applications and databases using the same logic
$resources = $applications->concat($databases);
foreach ($resources as $resource) {
$isExcluded = $resource->exclude_from_status || str($resource->status)->contains(':excluded');
// Filter based on excludedOnly flag
if ($excludedOnly && ! $isExcluded) {
continue;
}
if (! $excludedOnly && $isExcluded) {
continue;
}
if (! $excludedOnly) {
$hasNonExcluded = true;
}
// Strip :excluded suffix before aggregation (it's in the 3rd part of "status:health:excluded")
$status = str($resource->status)->before(':excluded')->toString();
$statusStrings->push($status);
}
// If no status strings collected, return nulls
if ($statusStrings->isEmpty()) {
return $excludedOnly ? [null, null] : [null, null, $hasNonExcluded];
}
// Use ContainerStatusAggregator service for state machine logic
$aggregator = new ContainerStatusAggregator;
$aggregatedStatus = $aggregator->aggregateFromStrings($statusStrings);
// Parse the aggregated "status:health" string
$parts = explode(':', $aggregatedStatus);
$status = $parts[0] ?? null;
$health = $parts[1] ?? null;
if ($excludedOnly) {
return [$status, $health];
}
return [$status, $health, $hasNonExcluded];
}
public function extraFields()
{
$fields = collect([]);
@@ -1287,6 +1334,11 @@ class Service extends BaseModel
public function saveComposeConfigs()
{
// Guard against null or empty docker_compose
if (! $this->docker_compose) {
return;
}
$workdir = $this->workdir();
instant_remote_process([
+79
View File
@@ -109,6 +109,11 @@ class ServiceApplication extends BaseModel
return $this->morphMany(LocalFileVolume::class, 'resource');
}
public function environment_variables()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable');
}
public function fqdns(): Attribute
{
return Attribute::make(
@@ -174,4 +179,78 @@ class ServiceApplication extends BaseModel
{
return false;
}
/**
* Get the required port for this service application.
* Extracts port from SERVICE_URL_* or SERVICE_FQDN_* environment variables
* stored at the Service level, filtering by normalized container name.
* Falls back to service-level port if no port-specific variable is found.
*/
public function getRequiredPort(): ?int
{
try {
// Parse the Docker Compose to find SERVICE_URL/SERVICE_FQDN variables DIRECTLY DECLARED
// for this specific service container (not just referenced from other containers)
$dockerComposeRaw = data_get($this->service, 'docker_compose_raw');
if (! $dockerComposeRaw) {
// Fall back to service-level port if no compose file
return $this->service->getRequiredPort();
}
$dockerCompose = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw);
$serviceConfig = data_get($dockerCompose, "services.{$this->name}");
if (! $serviceConfig) {
return $this->service->getRequiredPort();
}
$environment = data_get($serviceConfig, 'environment', []);
// Extract SERVICE_URL and SERVICE_FQDN variables DIRECTLY DECLARED in this service's environment
// (not variables that are merely referenced with ${VAR} syntax)
$portFound = null;
foreach ($environment as $key => $value) {
if (is_int($key) && is_string($value)) {
// List-style: "- SERVICE_URL_APP_3000" or "- SERVICE_URL_APP_3000=value"
// Extract variable name (before '=' if present)
$envVarName = str($value)->before('=')->trim();
// Only process direct declarations
if ($envVarName->startsWith('SERVICE_FQDN_') || $envVarName->startsWith('SERVICE_URL_')) {
// Parse to check if it has a port suffix
$parsed = parseServiceEnvironmentVariable($envVarName->value());
if ($parsed['has_port'] && $parsed['port']) {
// Found a port-specific variable for this service
$portFound = (int) $parsed['port'];
break;
}
}
} elseif (is_string($key)) {
// Map-style: "SERVICE_URL_APP_3000: value" or "SERVICE_FQDN_DB: localhost"
$envVarName = str($key);
// Only process direct declarations
if ($envVarName->startsWith('SERVICE_FQDN_') || $envVarName->startsWith('SERVICE_URL_')) {
// Parse to check if it has a port suffix
$parsed = parseServiceEnvironmentVariable($envVarName->value());
if ($parsed['has_port'] && $parsed['port']) {
// Found a port-specific variable for this service
$portFound = (int) $parsed['port'];
break;
}
}
}
}
// If a port was found in the template, return it
if ($portFound !== null) {
return $portFound;
}
// No port-specific variables found for this service, return null
// (DO NOT fall back to service-level port, as that applies to all services)
return null;
} catch (\Throwable $e) {
return null;
}
}
}
+4
View File
@@ -84,6 +84,10 @@ class ServiceDatabase extends BaseModel
$image = str($this->image)->before(':');
if ($image->contains('supabase/postgres')) {
$finalImage = 'supabase/postgres';
} elseif ($image->contains('timescale')) {
$finalImage = 'postgresql';
} elseif ($image->contains('pgvector')) {
$finalImage = 'postgresql';
} elseif ($image->contains('postgres') || $image->contains('postgis')) {
$finalImage = 'postgresql';
} else {
+2
View File
@@ -29,6 +29,7 @@ class SlackNotificationSettings extends Model
'server_reachable_slack_notifications',
'server_unreachable_slack_notifications',
'server_patch_slack_notifications',
'traefik_outdated_slack_notifications',
];
protected $casts = [
@@ -47,6 +48,7 @@ class SlackNotificationSettings extends Model
'server_reachable_slack_notifications' => 'boolean',
'server_unreachable_slack_notifications' => 'boolean',
'server_patch_slack_notifications' => 'boolean',
'traefik_outdated_slack_notifications' => 'boolean',
];
public function team()
+3 -1
View File
@@ -49,7 +49,9 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen
protected static function booted()
{
static::created(function ($team) {
$team->emailNotificationSettings()->create();
$team->emailNotificationSettings()->create([
'use_instance_email_settings' => isDev(),
]);
$team->discordNotificationSettings()->create();
$team->slackNotificationSettings()->create();
$team->telegramNotificationSettings()->create();
@@ -30,6 +30,7 @@ class TelegramNotificationSettings extends Model
'server_reachable_telegram_notifications',
'server_unreachable_telegram_notifications',
'server_patch_telegram_notifications',
'traefik_outdated_telegram_notifications',
'telegram_notifications_deployment_success_thread_id',
'telegram_notifications_deployment_failure_thread_id',
@@ -43,6 +44,7 @@ class TelegramNotificationSettings extends Model
'telegram_notifications_server_reachable_thread_id',
'telegram_notifications_server_unreachable_thread_id',
'telegram_notifications_server_patch_thread_id',
'telegram_notifications_traefik_outdated_thread_id',
];
protected $casts = [
@@ -62,6 +64,7 @@ class TelegramNotificationSettings extends Model
'server_reachable_telegram_notifications' => 'boolean',
'server_unreachable_telegram_notifications' => 'boolean',
'server_patch_telegram_notifications' => 'boolean',
'traefik_outdated_telegram_notifications' => 'boolean',
'telegram_notifications_deployment_success_thread_id' => 'encrypted',
'telegram_notifications_deployment_failure_thread_id' => 'encrypted',
@@ -75,6 +78,7 @@ class TelegramNotificationSettings extends Model
'telegram_notifications_server_reachable_thread_id' => 'encrypted',
'telegram_notifications_server_unreachable_thread_id' => 'encrypted',
'telegram_notifications_server_patch_thread_id' => 'encrypted',
'telegram_notifications_traefik_outdated_thread_id' => 'encrypted',
];
public function team()
+6 -2
View File
@@ -24,11 +24,13 @@ class WebhookNotificationSettings extends Model
'backup_failure_webhook_notifications',
'scheduled_task_success_webhook_notifications',
'scheduled_task_failure_webhook_notifications',
'docker_cleanup_webhook_notifications',
'docker_cleanup_success_webhook_notifications',
'docker_cleanup_failure_webhook_notifications',
'server_disk_usage_webhook_notifications',
'server_reachable_webhook_notifications',
'server_unreachable_webhook_notifications',
'server_patch_webhook_notifications',
'traefik_outdated_webhook_notifications',
];
protected function casts(): array
@@ -44,11 +46,13 @@ class WebhookNotificationSettings extends Model
'backup_failure_webhook_notifications' => 'boolean',
'scheduled_task_success_webhook_notifications' => 'boolean',
'scheduled_task_failure_webhook_notifications' => 'boolean',
'docker_cleanup_webhook_notifications' => 'boolean',
'docker_cleanup_success_webhook_notifications' => 'boolean',
'docker_cleanup_failure_webhook_notifications' => 'boolean',
'server_disk_usage_webhook_notifications' => 'boolean',
'server_reachable_webhook_notifications' => 'boolean',
'server_unreachable_webhook_notifications' => 'boolean',
'server_patch_webhook_notifications' => 'boolean',
'traefik_outdated_webhook_notifications' => 'boolean',
];
}
@@ -101,6 +101,38 @@ class EmailChannel
$mailer->send($email);
}
} catch (\Resend\Exceptions\ErrorException $e) {
// Map HTTP status codes to user-friendly messages
$userMessage = match ($e->getErrorCode()) {
403 => 'Invalid Resend API key. Please verify your API key in the Resend dashboard and update it in settings.',
401 => 'Your Resend API key has restricted permissions. Please use an API key with Full Access permissions.',
429 => 'Resend rate limit exceeded. Please try again in a few minutes.',
400 => 'Email validation failed: '.$e->getErrorMessage(),
default => 'Failed to send email via Resend: '.$e->getErrorMessage(),
};
// Log detailed error for admin debugging (redact sensitive data)
$emailSettings = $notifiable->emailNotificationSettings ?? instanceSettings();
data_set($emailSettings, 'smtp_password', '********');
data_set($emailSettings, 'resend_api_key', '********');
send_internal_notification(sprintf(
"Resend Error\nStatus Code: %s\nMessage: %s\nNotification: %s\nEmail Settings:\n%s",
$e->getErrorCode(),
$e->getErrorMessage(),
get_class($notification),
json_encode($emailSettings, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
));
// Don't report expected errors (invalid keys, validation) to Sentry
if (in_array($e->getErrorCode(), [403, 401, 400])) {
throw NonReportableException::fromException(new \Exception($userMessage, $e->getCode(), $e));
}
throw new \Exception($userMessage, $e->getCode(), $e);
} catch (\Resend\Exceptions\TransporterException $e) {
send_internal_notification("Resend Transport Error: {$e->getMessage()}");
throw new \Exception('Unable to connect to Resend API. Please check your internet connection and try again.');
} catch (\Throwable $e) {
// Check if this is a Resend domain verification error on cloud instances
if (isCloud() && str_contains($e->getMessage(), 'domain is not verified')) {
@@ -0,0 +1,262 @@
<?php
namespace App\Notifications\Server;
use App\Notifications\CustomEmailNotification;
use App\Notifications\Dto\DiscordMessage;
use App\Notifications\Dto\PushoverMessage;
use App\Notifications\Dto\SlackMessage;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Collection;
class TraefikVersionOutdated extends CustomEmailNotification
{
public function __construct(public Collection $servers)
{
$this->onQueue('high');
}
public function via(object $notifiable): array
{
return $notifiable->getEnabledChannels('traefik_outdated');
}
private function formatVersion(string $version): string
{
// Add 'v' prefix if not present for consistent display
return str_starts_with($version, 'v') ? $version : "v{$version}";
}
private function getUpgradeTarget(array $info): string
{
// For minor upgrades, use the upgrade_target field (e.g., "v3.6")
if (($info['type'] ?? 'patch_update') === 'minor_upgrade' && isset($info['upgrade_target'])) {
return $this->formatVersion($info['upgrade_target']);
}
// For patch updates, show the full version
return $this->formatVersion($info['latest'] ?? 'unknown');
}
public function toMail($notifiable = null): MailMessage
{
$mail = new MailMessage;
$count = $this->servers->count();
$mail->subject("Coolify: Traefik proxy outdated on {$count} server(s)");
$mail->view('emails.traefik-version-outdated', [
'servers' => $this->servers,
'count' => $count,
]);
return $mail;
}
public function toDiscord(): DiscordMessage
{
$count = $this->servers->count();
$hasUpgrades = $this->servers->contains(fn ($s) => ($s->outdatedInfo['type'] ?? 'patch_update') === 'minor_upgrade' ||
isset($s->outdatedInfo['newer_branch_target'])
);
$description = "**{$count} server(s)** running outdated Traefik proxy. Update recommended for security and features.\n\n";
$description .= "**Affected servers:**\n";
foreach ($this->servers as $server) {
$info = $server->outdatedInfo ?? [];
$current = $this->formatVersion($info['current'] ?? 'unknown');
$latest = $this->formatVersion($info['latest'] ?? 'unknown');
$upgradeTarget = $this->getUpgradeTarget($info);
$isPatch = ($info['type'] ?? 'patch_update') === 'patch_update';
$hasNewerBranch = isset($info['newer_branch_target']);
if ($isPatch && $hasNewerBranch) {
$newerBranchTarget = $info['newer_branch_target'];
$newerBranchLatest = $this->formatVersion($info['newer_branch_latest']);
$description .= "• {$server->name}: {$current} → {$upgradeTarget} (patch update available)\n";
$description .= " ↳ Also available: {$newerBranchTarget} (latest patch: {$newerBranchLatest}) - new minor version\n";
} elseif ($isPatch) {
$description .= "• {$server->name}: {$current} → {$upgradeTarget} (patch update available)\n";
} else {
$description .= "• {$server->name}: {$current} (latest patch: {$latest}) → {$upgradeTarget} (new minor version available)\n";
}
}
$description .= "\n⚠️ It is recommended to test before switching the production version.";
if ($hasUpgrades) {
$description .= "\n\n📖 **For minor version upgrades**: Read the Traefik changelog before upgrading to understand breaking changes and new features.";
}
return new DiscordMessage(
title: ':warning: Coolify: Traefik proxy outdated',
description: $description,
color: DiscordMessage::warningColor(),
);
}
public function toTelegram(): array
{
$count = $this->servers->count();
$hasUpgrades = $this->servers->contains(fn ($s) => ($s->outdatedInfo['type'] ?? 'patch_update') === 'minor_upgrade' ||
isset($s->outdatedInfo['newer_branch_target'])
);
$message = "⚠️ Coolify: Traefik proxy outdated on {$count} server(s)!\n\n";
$message .= "Update recommended for security and features.\n";
$message .= "📊 Affected servers:\n";
foreach ($this->servers as $server) {
$info = $server->outdatedInfo ?? [];
$current = $this->formatVersion($info['current'] ?? 'unknown');
$latest = $this->formatVersion($info['latest'] ?? 'unknown');
$upgradeTarget = $this->getUpgradeTarget($info);
$isPatch = ($info['type'] ?? 'patch_update') === 'patch_update';
$hasNewerBranch = isset($info['newer_branch_target']);
if ($isPatch && $hasNewerBranch) {
$newerBranchTarget = $info['newer_branch_target'];
$newerBranchLatest = $this->formatVersion($info['newer_branch_latest']);
$message .= "• {$server->name}: {$current} → {$upgradeTarget} (patch update available)\n";
$message .= " ↳ Also available: {$newerBranchTarget} (latest patch: {$newerBranchLatest}) - new minor version\n";
} elseif ($isPatch) {
$message .= "• {$server->name}: {$current} → {$upgradeTarget} (patch update available)\n";
} else {
$message .= "• {$server->name}: {$current} (latest patch: {$latest}) → {$upgradeTarget} (new minor version available)\n";
}
}
$message .= "\n⚠️ It is recommended to test before switching the production version.";
if ($hasUpgrades) {
$message .= "\n\n📖 For minor version upgrades: Read the Traefik changelog before upgrading to understand breaking changes and new features.";
}
return [
'message' => $message,
'buttons' => [],
];
}
public function toPushover(): PushoverMessage
{
$count = $this->servers->count();
$hasUpgrades = $this->servers->contains(fn ($s) => ($s->outdatedInfo['type'] ?? 'patch_update') === 'minor_upgrade' ||
isset($s->outdatedInfo['newer_branch_target'])
);
$message = "Traefik proxy outdated on {$count} server(s)!\n";
$message .= "Affected servers:\n";
foreach ($this->servers as $server) {
$info = $server->outdatedInfo ?? [];
$current = $this->formatVersion($info['current'] ?? 'unknown');
$latest = $this->formatVersion($info['latest'] ?? 'unknown');
$upgradeTarget = $this->getUpgradeTarget($info);
$isPatch = ($info['type'] ?? 'patch_update') === 'patch_update';
$hasNewerBranch = isset($info['newer_branch_target']);
if ($isPatch && $hasNewerBranch) {
$newerBranchTarget = $info['newer_branch_target'];
$newerBranchLatest = $this->formatVersion($info['newer_branch_latest']);
$message .= "• {$server->name}: {$current} → {$upgradeTarget} (patch update available)\n";
$message .= " Also: {$newerBranchTarget} (latest: {$newerBranchLatest}) - new minor version\n";
} elseif ($isPatch) {
$message .= "• {$server->name}: {$current} → {$upgradeTarget} (patch update available)\n";
} else {
$message .= "• {$server->name}: {$current} (latest patch: {$latest}) → {$upgradeTarget} (new minor version available)\n";
}
}
$message .= "\nIt is recommended to test before switching the production version.";
if ($hasUpgrades) {
$message .= "\n\nFor minor version upgrades: Read the Traefik changelog before upgrading.";
}
return new PushoverMessage(
title: 'Traefik proxy outdated',
level: 'warning',
message: $message,
);
}
public function toSlack(): SlackMessage
{
$count = $this->servers->count();
$hasUpgrades = $this->servers->contains(fn ($s) => ($s->outdatedInfo['type'] ?? 'patch_update') === 'minor_upgrade' ||
isset($s->outdatedInfo['newer_branch_target'])
);
$description = "Traefik proxy outdated on {$count} server(s)!\n";
$description .= "*Affected servers:*\n";
foreach ($this->servers as $server) {
$info = $server->outdatedInfo ?? [];
$current = $this->formatVersion($info['current'] ?? 'unknown');
$latest = $this->formatVersion($info['latest'] ?? 'unknown');
$upgradeTarget = $this->getUpgradeTarget($info);
$isPatch = ($info['type'] ?? 'patch_update') === 'patch_update';
$hasNewerBranch = isset($info['newer_branch_target']);
if ($isPatch && $hasNewerBranch) {
$newerBranchTarget = $info['newer_branch_target'];
$newerBranchLatest = $this->formatVersion($info['newer_branch_latest']);
$description .= "• `{$server->name}`: {$current} → {$upgradeTarget} (patch update available)\n";
$description .= " ↳ Also available: {$newerBranchTarget} (latest patch: {$newerBranchLatest}) - new minor version\n";
} elseif ($isPatch) {
$description .= "• `{$server->name}`: {$current} → {$upgradeTarget} (patch update available)\n";
} else {
$description .= "• `{$server->name}`: {$current} (latest patch: {$latest}) → {$upgradeTarget} (new minor version available)\n";
}
}
$description .= "\n:warning: It is recommended to test before switching the production version.";
if ($hasUpgrades) {
$description .= "\n\n:book: For minor version upgrades: Read the Traefik changelog before upgrading to understand breaking changes and new features.";
}
return new SlackMessage(
title: 'Coolify: Traefik proxy outdated',
description: $description,
color: SlackMessage::warningColor()
);
}
public function toWebhook(): array
{
$servers = $this->servers->map(function ($server) {
$info = $server->outdatedInfo ?? [];
$webhookData = [
'name' => $server->name,
'uuid' => $server->uuid,
'current_version' => $info['current'] ?? 'unknown',
'latest_version' => $info['latest'] ?? 'unknown',
'update_type' => $info['type'] ?? 'patch_update',
];
// For minor upgrades, include the upgrade target (e.g., "v3.6")
if (($info['type'] ?? 'patch_update') === 'minor_upgrade' && isset($info['upgrade_target'])) {
$webhookData['upgrade_target'] = $info['upgrade_target'];
}
// Include newer branch info if available
if (isset($info['newer_branch_target'])) {
$webhookData['newer_branch_target'] = $info['newer_branch_target'];
$webhookData['newer_branch_latest'] = $info['newer_branch_latest'];
}
return $webhookData;
})->toArray();
return [
'success' => false,
'message' => 'Traefik proxy outdated',
'event' => 'traefik_version_outdated',
'affected_servers_count' => $this->servers->count(),
'servers' => $servers,
];
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace App\Policies;
use App\Models\InstanceSettings;
use App\Models\User;
class InstanceSettingsPolicy
{
/**
* Determine whether the user can view the instance settings.
*/
public function view(User $user, InstanceSettings $settings): bool
{
return isInstanceAdmin();
}
/**
* Determine whether the user can update the instance settings.
*/
public function update(User $user, InstanceSettings $settings): bool
{
return isInstanceAdmin();
}
}
+3
View File
@@ -50,6 +50,9 @@ class AuthServiceProvider extends ServiceProvider
// API Token policy
\Laravel\Sanctum\PersonalAccessToken::class => \App\Policies\ApiTokenPolicy::class,
// Instance settings policy
\App\Models\InstanceSettings::class => \App\Policies\InstanceSettingsPolicy::class,
// Team policy
\App\Models\Team::class => \App\Policies\TeamPolicy::class,
+251
View File
@@ -0,0 +1,251 @@
<?php
namespace App\Services;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
/**
* Container Status Aggregator Service
*
* Centralized service for aggregating container statuses into a single status string.
* Uses a priority-based state machine to determine the overall status from multiple containers.
*
* Output Format: Colon-separated (e.g., "running:healthy", "degraded:unhealthy")
* This format is used throughout the backend for consistency and machine-readability.
* UI components transform this to human-readable format (e.g., "Running (Healthy)").
*
* State Priority (highest to lowest):
* 1. Restarting → degraded:unhealthy
* 2. Crash Loop (exited with restarts) → degraded:unhealthy
* 3. Mixed (running + exited) → degraded:unhealthy
* 4. Running → running:healthy/unhealthy/unknown
* 5. Dead/Removing → degraded:unhealthy
* 6. Paused → paused:unknown
* 7. Starting/Created → starting:unknown
* 8. Exited → exited
*/
class ContainerStatusAggregator
{
/**
* Aggregate container statuses from status strings into a single status.
*
* @param Collection $containerStatuses Collection of status strings (e.g., "running (healthy)", "running:healthy")
* @param int $maxRestartCount Maximum restart count across containers (for crash loop detection)
* @return string Aggregated status in colon format (e.g., "running:healthy")
*/
public function aggregateFromStrings(Collection $containerStatuses, int $maxRestartCount = 0): string
{
// Validate maxRestartCount parameter
if ($maxRestartCount < 0) {
Log::warning('Negative maxRestartCount corrected to 0', [
'original_value' => $maxRestartCount,
]);
$maxRestartCount = 0;
}
if ($maxRestartCount > 1000) {
Log::warning('High maxRestartCount detected', [
'maxRestartCount' => $maxRestartCount,
'containers' => $containerStatuses->count(),
]);
}
if ($containerStatuses->isEmpty()) {
return 'exited';
}
// Initialize state flags
$hasRunning = false;
$hasRestarting = false;
$hasUnhealthy = false;
$hasUnknown = false;
$hasExited = false;
$hasStarting = false;
$hasPaused = false;
$hasDead = false;
// Parse each status string and set flags
foreach ($containerStatuses as $status) {
if (str($status)->contains('restarting')) {
$hasRestarting = true;
} elseif (str($status)->contains('running')) {
$hasRunning = true;
if (str($status)->contains('unhealthy')) {
$hasUnhealthy = true;
}
if (str($status)->contains('unknown')) {
$hasUnknown = true;
}
} elseif (str($status)->contains('exited')) {
$hasExited = true;
} elseif (str($status)->contains('created') || str($status)->contains('starting')) {
$hasStarting = true;
} elseif (str($status)->contains('paused')) {
$hasPaused = true;
} elseif (str($status)->contains('dead') || str($status)->contains('removing')) {
$hasDead = true;
}
}
// Priority-based status resolution
return $this->resolveStatus(
$hasRunning,
$hasRestarting,
$hasUnhealthy,
$hasUnknown,
$hasExited,
$hasStarting,
$hasPaused,
$hasDead,
$maxRestartCount
);
}
/**
* Aggregate container statuses from Docker container objects.
*
* @param Collection $containers Collection of Docker container objects with State property
* @param int $maxRestartCount Maximum restart count across containers (for crash loop detection)
* @return string Aggregated status in colon format (e.g., "running:healthy")
*/
public function aggregateFromContainers(Collection $containers, int $maxRestartCount = 0): string
{
// Validate maxRestartCount parameter
if ($maxRestartCount < 0) {
Log::warning('Negative maxRestartCount corrected to 0', [
'original_value' => $maxRestartCount,
]);
$maxRestartCount = 0;
}
if ($maxRestartCount > 1000) {
Log::warning('High maxRestartCount detected', [
'maxRestartCount' => $maxRestartCount,
'containers' => $containers->count(),
]);
}
if ($containers->isEmpty()) {
return 'exited';
}
// Initialize state flags
$hasRunning = false;
$hasRestarting = false;
$hasUnhealthy = false;
$hasUnknown = false;
$hasExited = false;
$hasStarting = false;
$hasPaused = false;
$hasDead = false;
// Parse each container object and set flags
foreach ($containers as $container) {
$state = data_get($container, 'State.Status', 'exited');
$health = data_get($container, 'State.Health.Status');
if ($state === 'restarting') {
$hasRestarting = true;
} elseif ($state === 'running') {
$hasRunning = true;
if ($health === 'unhealthy') {
$hasUnhealthy = true;
} elseif (is_null($health) || $health === 'starting') {
$hasUnknown = true;
}
} elseif ($state === 'exited') {
$hasExited = true;
} elseif ($state === 'created' || $state === 'starting') {
$hasStarting = true;
} elseif ($state === 'paused') {
$hasPaused = true;
} elseif ($state === 'dead' || $state === 'removing') {
$hasDead = true;
}
}
// Priority-based status resolution
return $this->resolveStatus(
$hasRunning,
$hasRestarting,
$hasUnhealthy,
$hasUnknown,
$hasExited,
$hasStarting,
$hasPaused,
$hasDead,
$maxRestartCount
);
}
/**
* Resolve the aggregated status based on state flags (priority-based state machine).
*
* @param bool $hasRunning Has at least one running container
* @param bool $hasRestarting Has at least one restarting container
* @param bool $hasUnhealthy Has at least one unhealthy container
* @param bool $hasUnknown Has at least one container with unknown health
* @param bool $hasExited Has at least one exited container
* @param bool $hasStarting Has at least one starting/created container
* @param bool $hasPaused Has at least one paused container
* @param bool $hasDead Has at least one dead/removing container
* @param int $maxRestartCount Maximum restart count (for crash loop detection)
* @return string Status in colon format (e.g., "running:healthy")
*/
private function resolveStatus(
bool $hasRunning,
bool $hasRestarting,
bool $hasUnhealthy,
bool $hasUnknown,
bool $hasExited,
bool $hasStarting,
bool $hasPaused,
bool $hasDead,
int $maxRestartCount
): string {
// Priority 1: Restarting containers (degraded state)
if ($hasRestarting) {
return 'degraded:unhealthy';
}
// Priority 2: Crash loop detection (exited with restart count > 0)
if ($hasExited && $maxRestartCount > 0) {
return 'degraded:unhealthy';
}
// Priority 3: Mixed state (some running, some exited = degraded)
if ($hasRunning && $hasExited) {
return 'degraded:unhealthy';
}
// Priority 4: Running containers (check health status)
if ($hasRunning) {
if ($hasUnhealthy) {
return 'running:unhealthy';
} elseif ($hasUnknown) {
return 'running:unknown';
} else {
return 'running:healthy';
}
}
// Priority 5: Dead or removing containers
if ($hasDead) {
return 'degraded:unhealthy';
}
// Priority 6: Paused containers
if ($hasPaused) {
return 'paused:unknown';
}
// Priority 7: Starting/created containers
if ($hasStarting) {
return 'starting:unknown';
}
// Priority 8: All containers exited (no restart count = truly stopped)
return 'exited';
}
}
Loaded 100 of 318 files, more files were not shown because too many files have changed in this diff. Show more