Merge branch 'next' into port-detection-lol
This commit is contained in:
commit
45ab79f292
31 changed files with 882 additions and 65 deletions
|
|
@ -10,6 +10,7 @@
|
|||
use App\Models\ServiceDatabase;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class GetContainersStatus
|
||||
|
|
@ -28,6 +29,8 @@ class GetContainersStatus
|
|||
|
||||
protected ?Collection $applicationContainerStatuses;
|
||||
|
||||
protected ?Collection $applicationContainerRestartCounts;
|
||||
|
||||
public function handle(Server $server, ?Collection $containers = null, ?Collection $containerReplicates = null)
|
||||
{
|
||||
$this->containers = $containers;
|
||||
|
|
@ -136,6 +139,18 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
|
|||
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.
|
||||
}
|
||||
|
|
@ -291,7 +306,24 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
|
|||
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,22 +372,56 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
|
|||
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()]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
|
|
@ -413,6 +479,11 @@ private function aggregateApplicationStatus($application, Collection $containerS
|
|||
return 'degraded (unhealthy)';
|
||||
}
|
||||
|
||||
// If container is exited but has restart count > 0, it's in a crash loop
|
||||
if ($hasExited && $maxRestartCount > 0) {
|
||||
return 'degraded (unhealthy)';
|
||||
}
|
||||
|
||||
if ($hasRunning && $hasExited) {
|
||||
return 'degraded (unhealthy)';
|
||||
}
|
||||
|
|
@ -421,7 +492,7 @@ private function aggregateApplicationStatus($application, Collection $containerS
|
|||
return $hasUnhealthy ? 'running (unhealthy)' : 'running (healthy)';
|
||||
}
|
||||
|
||||
// All containers are exited
|
||||
// All containers are exited with no restart count - truly stopped
|
||||
return 'exited (unhealthy)';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,18 +20,23 @@ public function handle(Service $service, bool $pullLatestImages = false, bool $s
|
|||
}
|
||||
$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', []);
|
||||
|
|
|
|||
|
|
@ -222,9 +222,14 @@ private function cleanup_stucked_resources()
|
|||
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 @@ private function cleanup_stucked_resources()
|
|||
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 @@ private function cleanup_stucked_resources()
|
|||
foreach ($serviceDatabases as $service) {
|
||||
if (! data_get($service, 'service')) {
|
||||
echo 'ServiceDatabase without service: '.$service->name.'\n';
|
||||
DeleteResourceJob::dispatch($service);
|
||||
$service->forceDelete();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ public function handle()
|
|||
$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', [
|
||||
|
|
|
|||
|
|
@ -246,6 +246,40 @@ public function manual(Request $request)
|
|||
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 @@ public function normal(Request $request)
|
|||
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) {
|
||||
|
|
|
|||
|
|
@ -341,20 +341,42 @@ public function handle(): void
|
|||
$this->fail($e);
|
||||
throw $e;
|
||||
} finally {
|
||||
$this->application_deployment_queue->update([
|
||||
'finished_at' => Carbon::now()->toImmutable(),
|
||||
]);
|
||||
|
||||
if ($this->use_build_server) {
|
||||
$this->server = $this->build_server;
|
||||
} else {
|
||||
$this->write_deployment_configurations();
|
||||
// Wrap cleanup operations in try-catch to prevent exceptions from interfering
|
||||
// with Laravel's job failure handling and status updates
|
||||
try {
|
||||
$this->application_deployment_queue->update([
|
||||
'finished_at' => Carbon::now()->toImmutable(),
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
// Log but don't fail - finished_at is not critical
|
||||
\Log::warning('Failed to update finished_at for deployment '.$this->deployment_uuid.': '.$e->getMessage());
|
||||
}
|
||||
|
||||
$this->application_deployment_queue->addLogEntry("Gracefully shutting down build container: {$this->deployment_uuid}");
|
||||
$this->graceful_shutdown_container($this->deployment_uuid);
|
||||
try {
|
||||
if ($this->use_build_server) {
|
||||
$this->server = $this->build_server;
|
||||
} else {
|
||||
$this->write_deployment_configurations();
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
// Log but don't fail - configuration writing errors shouldn't prevent status updates
|
||||
$this->application_deployment_queue->addLogEntry('Warning: Failed to write deployment configurations: '.$e->getMessage(), 'stderr');
|
||||
}
|
||||
|
||||
ServiceStatusChanged::dispatch(data_get($this->application, 'environment.project.team.id'));
|
||||
try {
|
||||
$this->application_deployment_queue->addLogEntry("Gracefully shutting down build container: {$this->deployment_uuid}");
|
||||
$this->graceful_shutdown_container($this->deployment_uuid);
|
||||
} catch (Exception $e) {
|
||||
// Log but don't fail - container cleanup errors are expected when container is already gone
|
||||
\Log::warning('Failed to shutdown container '.$this->deployment_uuid.': '.$e->getMessage());
|
||||
}
|
||||
|
||||
try {
|
||||
ServiceStatusChanged::dispatch(data_get($this->application, 'environment.project.team.id'));
|
||||
} catch (Exception $e) {
|
||||
// Log but don't fail - event dispatch errors shouldn't prevent status updates
|
||||
\Log::warning('Failed to dispatch ServiceStatusChanged for deployment '.$this->deployment_uuid.': '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1146,6 +1168,18 @@ private function generate_runtime_environment_variables()
|
|||
foreach ($runtime_environment_variables as $env) {
|
||||
$envs->push($env->key.'='.$env->real_value);
|
||||
}
|
||||
|
||||
// Check for PORT environment variable mismatch with ports_exposes
|
||||
if ($this->build_pack !== 'dockercompose') {
|
||||
$detectedPort = $this->application->detectPortFromEnvironment(false);
|
||||
if ($detectedPort && ! empty($ports) && ! in_array($detectedPort, $ports)) {
|
||||
$this->application_deployment_queue->addLogEntry(
|
||||
"Warning: PORT environment variable ({$detectedPort}) does not match configured ports_exposes: ".implode(',', $ports).'. It could case "bad gateway" or "no server" errors. Check the "General" page to fix it.',
|
||||
'stderr'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Add PORT if not exists, use the first port as default
|
||||
if ($this->build_pack !== 'dockercompose') {
|
||||
if ($this->application->environment_variables->where('key', 'PORT')->isEmpty()) {
|
||||
|
|
@ -3029,6 +3063,11 @@ private function stop_running_container(bool $force = false)
|
|||
|
||||
private function start_by_compose_file()
|
||||
{
|
||||
// Ensure .env file exists before docker compose tries to load it (defensive programming)
|
||||
$this->execute_remote_command(
|
||||
["touch {$this->configuration_dir}/.env", 'hidden' => true],
|
||||
);
|
||||
|
||||
if ($this->application->build_pack === 'dockerimage') {
|
||||
$this->application_deployment_queue->addLogEntry('Pulling latest images from the registry.');
|
||||
$this->execute_remote_command(
|
||||
|
|
@ -3798,10 +3837,8 @@ private function failDeployment(): void
|
|||
public function failed(Throwable $exception): void
|
||||
{
|
||||
$this->failDeployment();
|
||||
$this->application_deployment_queue->addLogEntry('Oops something is not okay, are you okay? 😢', 'stderr');
|
||||
if (str($exception->getMessage())->isNotEmpty()) {
|
||||
$this->application_deployment_queue->addLogEntry($exception->getMessage(), 'stderr');
|
||||
}
|
||||
$errorMessage = $exception->getMessage() ?: 'Unknown error occurred';
|
||||
$this->application_deployment_queue->addLogEntry("Deployment failed: {$errorMessage}", 'stderr');
|
||||
|
||||
if ($this->application->build_pack !== 'dockercompose') {
|
||||
$code = $exception->getCode();
|
||||
|
|
|
|||
|
|
@ -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 @@ public function __construct(public Server $server) {}
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -124,6 +124,42 @@ private function deleteApplicationPreview()
|
|||
$this->resource->delete();
|
||||
}
|
||||
|
||||
// Cancel any active deployments for this PR (same logic as API cancel_deployment)
|
||||
$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;
|
||||
$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.');
|
||||
} 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);
|
||||
|
|
@ -133,7 +169,7 @@ private function deleteApplicationPreview()
|
|||
}
|
||||
} 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 +192,6 @@ private function stopPreviewContainers(array $containers, $server, int $timeout
|
|||
"docker stop --time=$timeout $containerList",
|
||||
"docker rm -f $containerList",
|
||||
];
|
||||
|
||||
instant_remote_process(
|
||||
command: $commands,
|
||||
server: $server,
|
||||
|
|
|
|||
|
|
@ -1000,4 +1000,23 @@ private function updateServiceEnvironmentVariables()
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,11 +101,18 @@ public function deploy(bool $force_rebuild = false)
|
|||
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 @@ public function restart()
|
|||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->setDeploymentUuid();
|
||||
$result = queue_application_deployment(
|
||||
application: $this->application,
|
||||
|
|
@ -149,6 +157,13 @@ public function restart()
|
|||
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'],
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
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 @@ protected function rules(): array
|
|||
{
|
||||
$baseRules = [
|
||||
'dockerComposeRaw' => 'required',
|
||||
'dockerCompose' => 'required',
|
||||
'dockerCompose' => 'nullable',
|
||||
'name' => ValidationPatterns::nameRules(),
|
||||
'description' => ValidationPatterns::descriptionRules(),
|
||||
'connectToDockerNetwork' => 'nullable',
|
||||
|
|
@ -140,18 +141,27 @@ public function submit($notify = true)
|
|||
$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)) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -772,6 +774,24 @@ public function main_port()
|
|||
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')
|
||||
|
|
|
|||
|
|
@ -1287,6 +1287,11 @@ public function workdir()
|
|||
|
||||
public function saveComposeConfigs()
|
||||
{
|
||||
// Guard against null or empty docker_compose
|
||||
if (! $this->docker_compose) {
|
||||
return;
|
||||
}
|
||||
|
||||
$workdir = $this->workdir();
|
||||
|
||||
instant_remote_process([
|
||||
|
|
|
|||
|
|
@ -219,9 +219,22 @@ private function executeCommandWithProcess($command, $hidden, $customType, $appe
|
|||
$process_result = $process->wait();
|
||||
if ($process_result->exitCode() !== 0) {
|
||||
if (! $ignore_errors) {
|
||||
// Check if deployment was cancelled while command was running
|
||||
if (isset($this->application_deployment_queue)) {
|
||||
$this->application_deployment_queue->refresh();
|
||||
if ($this->application_deployment_queue->status === \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value) {
|
||||
throw new \RuntimeException('Deployment cancelled by user', 69420);
|
||||
}
|
||||
}
|
||||
|
||||
// Don't immediately set to FAILED - let the retry logic handle it
|
||||
// This prevents premature status changes during retryable SSH errors
|
||||
throw new \RuntimeException($process_result->errorOutput());
|
||||
$error = $process_result->errorOutput();
|
||||
if (empty($error)) {
|
||||
$error = $process_result->output() ?: 'Command failed with no error output';
|
||||
}
|
||||
$redactedCommand = $this->redact_sensitive_info($command);
|
||||
throw new \RuntimeException("Command execution failed (exit code {$process_result->exitCode()}): {$redactedCommand}\nError: {$error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,24 +17,44 @@ function getCurrentApplicationContainerStatus(Server $server, int $id, ?int $pul
|
|||
if (! $server->isSwarm()) {
|
||||
$containers = instant_remote_process(["docker ps -a --filter='label=coolify.applicationId={$id}' --format '{{json .}}' "], $server);
|
||||
$containers = format_docker_command_output_to_json($containers);
|
||||
|
||||
$containers = $containers->map(function ($container) use ($pullRequestId, $includePullrequests) {
|
||||
$labels = data_get($container, 'Labels');
|
||||
if (! str($labels)->contains('coolify.pullRequestId=')) {
|
||||
data_set($container, 'Labels', $labels.",coolify.pullRequestId={$pullRequestId}");
|
||||
$containerName = data_get($container, 'Names');
|
||||
$hasPrLabel = str($labels)->contains('coolify.pullRequestId=');
|
||||
$prLabelValue = null;
|
||||
|
||||
if ($hasPrLabel) {
|
||||
preg_match('/coolify\.pullRequestId=(\d+)/', $labels, $matches);
|
||||
$prLabelValue = $matches[1] ?? null;
|
||||
}
|
||||
|
||||
// Treat pullRequestId=0 or missing label as base deployment (convention: 0 = no PR)
|
||||
$isBaseDeploy = ! $hasPrLabel || (int) $prLabelValue === 0;
|
||||
|
||||
// If we're looking for a specific PR and this is a base deployment, exclude it
|
||||
if ($pullRequestId !== null && $pullRequestId !== 0 && $isBaseDeploy) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If this is a base deployment, include it when not filtering for PRs
|
||||
if ($isBaseDeploy) {
|
||||
return $container;
|
||||
}
|
||||
|
||||
if ($includePullrequests) {
|
||||
return $container;
|
||||
}
|
||||
if (str($labels)->contains("coolify.pullRequestId=$pullRequestId")) {
|
||||
if ($pullRequestId !== null && $pullRequestId !== 0 && str($labels)->contains("coolify.pullRequestId={$pullRequestId}")) {
|
||||
return $container;
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
return $containers->filter();
|
||||
$filtered = $containers->filter();
|
||||
|
||||
return $filtered;
|
||||
}
|
||||
|
||||
return $containers;
|
||||
|
|
|
|||
|
|
@ -59,11 +59,13 @@ function validateDockerComposeForInjection(string $composeYaml): void
|
|||
if (isset($volume['source'])) {
|
||||
$source = $volume['source'];
|
||||
if (is_string($source)) {
|
||||
// Allow simple env vars and env vars with defaults (validated in parseDockerVolumeString)
|
||||
// Allow env vars and env vars with defaults (validated in parseDockerVolumeString)
|
||||
// Also allow env vars followed by safe path concatenation (e.g., ${VAR}/path)
|
||||
$isSimpleEnvVar = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}$/', $source);
|
||||
$isEnvVarWithDefault = preg_match('/^\$\{[^}]+:-[^}]*\}$/', $source);
|
||||
$isEnvVarWithPath = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}[\/\w\.\-]*$/', $source);
|
||||
|
||||
if (! $isSimpleEnvVar && ! $isEnvVarWithDefault) {
|
||||
if (! $isSimpleEnvVar && ! $isEnvVarWithDefault && ! $isEnvVarWithPath) {
|
||||
try {
|
||||
validateShellSafePath($source, 'volume source');
|
||||
} catch (\Exception $e) {
|
||||
|
|
@ -310,15 +312,17 @@ function parseDockerVolumeString(string $volumeString): array
|
|||
// Validate source path for command injection attempts
|
||||
// We validate the final source value after environment variable processing
|
||||
if ($source !== null) {
|
||||
// Allow simple environment variables like ${VAR_NAME} or ${VAR}
|
||||
// but validate everything else for shell metacharacters
|
||||
// Allow environment variables like ${VAR_NAME} or ${VAR}
|
||||
// Also allow env vars followed by safe path concatenation (e.g., ${VAR}/path)
|
||||
$sourceStr = is_string($source) ? $source : $source;
|
||||
|
||||
// Skip validation for simple environment variable references
|
||||
// Pattern: ${WORD_CHARS} with no special characters inside
|
||||
// Pattern 1: ${WORD_CHARS} with no special characters inside
|
||||
// Pattern 2: ${WORD_CHARS}/path/to/file (env var with path concatenation)
|
||||
$isSimpleEnvVar = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}$/', $sourceStr);
|
||||
$isEnvVarWithPath = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}[\/\w\.\-]*$/', $sourceStr);
|
||||
|
||||
if (! $isSimpleEnvVar) {
|
||||
if (! $isSimpleEnvVar && ! $isEnvVarWithPath) {
|
||||
try {
|
||||
validateShellSafePath($sourceStr, 'volume source');
|
||||
} catch (\Exception $e) {
|
||||
|
|
@ -716,9 +720,12 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
// Validate source and target for command injection (array/long syntax)
|
||||
if ($source !== null && ! empty($source->value())) {
|
||||
$sourceValue = $source->value();
|
||||
// Allow simple environment variable references
|
||||
// Allow environment variable references and env vars with path concatenation
|
||||
$isSimpleEnvVar = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}$/', $sourceValue);
|
||||
if (! $isSimpleEnvVar) {
|
||||
$isEnvVarWithDefault = preg_match('/^\$\{[^}]+:-[^}]*\}$/', $sourceValue);
|
||||
$isEnvVarWithPath = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}[\/\w\.\-]*$/', $sourceValue);
|
||||
|
||||
if (! $isSimpleEnvVar && ! $isEnvVarWithDefault && ! $isEnvVarWithPath) {
|
||||
try {
|
||||
validateShellSafePath($sourceValue, 'volume source');
|
||||
} catch (\Exception $e) {
|
||||
|
|
@ -1298,6 +1305,15 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
if ($depends_on->count() > 0) {
|
||||
$payload['depends_on'] = $depends_on;
|
||||
}
|
||||
// Auto-inject .env file so Coolify environment variables are available inside containers
|
||||
// This makes Applications behave consistently with manual .env file usage
|
||||
$existingEnvFiles = data_get($service, 'env_file');
|
||||
$envFiles = collect(is_null($existingEnvFiles) ? [] : (is_array($existingEnvFiles) ? $existingEnvFiles : [$existingEnvFiles]))
|
||||
->push('.env')
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
$payload['env_file'] = $envFiles;
|
||||
if ($isPullRequest) {
|
||||
$serviceName = addPreviewDeploymentSuffix($serviceName, $pullRequestId);
|
||||
}
|
||||
|
|
@ -1824,9 +1840,12 @@ function serviceParser(Service $resource): Collection
|
|||
// Validate source and target for command injection (array/long syntax)
|
||||
if ($source !== null && ! empty($source->value())) {
|
||||
$sourceValue = $source->value();
|
||||
// Allow simple environment variable references
|
||||
// Allow environment variable references and env vars with path concatenation
|
||||
$isSimpleEnvVar = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}$/', $sourceValue);
|
||||
if (! $isSimpleEnvVar) {
|
||||
$isEnvVarWithDefault = preg_match('/^\$\{[^}]+:-[^}]*\}$/', $sourceValue);
|
||||
$isEnvVarWithPath = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}[\/\w\.\-]*$/', $sourceValue);
|
||||
|
||||
if (! $isSimpleEnvVar && ! $isEnvVarWithDefault && ! $isEnvVarWithPath) {
|
||||
try {
|
||||
validateShellSafePath($sourceValue, 'volume source');
|
||||
} catch (\Exception $e) {
|
||||
|
|
@ -2281,6 +2300,15 @@ function serviceParser(Service $resource): Collection
|
|||
if ($depends_on->count() > 0) {
|
||||
$payload['depends_on'] = $depends_on;
|
||||
}
|
||||
// Auto-inject .env file so Coolify environment variables are available inside containers
|
||||
// This makes Services behave consistently with Applications
|
||||
$existingEnvFiles = data_get($service, 'env_file');
|
||||
$envFiles = collect(is_null($existingEnvFiles) ? [] : (is_array($existingEnvFiles) ? $existingEnvFiles : [$existingEnvFiles]))
|
||||
->push('.env')
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
$payload['env_file'] = $envFiles;
|
||||
|
||||
$parsedServices->put($serviceName, $payload);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('applications', function (Blueprint $table) {
|
||||
$table->integer('restart_count')->default(0)->after('status');
|
||||
$table->timestamp('last_restart_at')->nullable()->after('restart_count');
|
||||
$table->string('last_restart_type', 10)->nullable()->after('last_restart_at');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('applications', function (Blueprint $table) {
|
||||
$table->dropColumn(['restart_count', 'last_restart_at', 'last_restart_type']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -13,6 +13,9 @@ charset utf-8;
|
|||
# Set max upload to 2048M
|
||||
client_max_body_size 2048M;
|
||||
|
||||
# Set client body buffer to handle Sentinel payloads in memory
|
||||
client_body_buffer_size 256k;
|
||||
|
||||
# Healthchecks: Set /healthcheck to be the healthcheck URL
|
||||
location /healthcheck {
|
||||
access_log off;
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@ charset utf-8;
|
|||
# Set max upload to 2048M
|
||||
client_max_body_size 2048M;
|
||||
|
||||
# Set client body buffer to handle Sentinel payloads in memory
|
||||
client_body_buffer_size 256k;
|
||||
|
||||
# Healthchecks: Set /healthcheck to be the healthcheck URL
|
||||
location /healthcheck {
|
||||
access_log off;
|
||||
|
|
|
|||
1
public/svgs/postgresus.svg
Normal file
1
public/svgs/postgresus.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?> <svg xmlns="http://www.w3.org/2000/svg" width="30" height="30" viewBox="0 0 30 30" fill="none"><path d="M18.1899 24.7431C17.4603 24.7737 16.6261 24.3423 16.1453 23.6749C15.9745 23.438 15.6161 23.4548 15.4621 23.7026C15.1857 24.1478 14.9389 24.5259 14.8066 24.751C14.4739 25.3197 14.5242 25.4223 14.7918 25.8636C15.2923 26.689 16.8374 27.9675 19.0113 27.999C22.3807 28.0474 25.2269 26.2506 26.303 21.9058C29.0811 22.0322 29.5767 20.9018 29.5866 19.8415C29.5965 18.795 29.1542 18.2796 27.8866 17.9232C27.4739 17.8067 26.9902 17.7061 26.4689 17.4948C26.3198 16.2281 25.9496 15.0257 25.376 13.8933C28.1433 2.78289 16.4839 -0.631985 12.0048 4.22426C11.3818 3.42756 9.81016 2.00395 7.14065 2C4.12857 1.99606 1 4.47798 1 8.23346C1 9.79626 1.93492 12.1331 2.56083 14.1332C3.86103 18.2875 4.6992 19.4683 6.52362 19.801C7.98376 20.0675 9.1645 19.3972 10.0471 18.2796C11.3233 18.4028 10.4726 19.5371 16.4099 19.2234C17.6765 19.1168 18.7694 19.564 19.5937 20.498C20.8071 21.8732 20.4566 24.6474 18.1899 24.7421V24.7431ZM17.8483 13.0423C17.2174 12.9801 16.707 12.4697 16.6448 11.8389C16.5599 10.9859 17.2708 10.2761 18.1237 10.36C18.7546 10.4222 19.265 10.9326 19.3272 11.5634C19.4111 12.4154 18.7013 13.1262 17.8483 13.0423ZM20.578 18.178C19.9403 17.5392 19.8524 16.7149 20.3519 16.1788C20.9186 15.5706 21.7242 15.85 22.1428 16.3061C23.4331 17.712 24.9209 18.6193 27.854 19.337C28.4651 19.487 28.4157 20.3716 27.7908 20.4476C26.4798 20.6076 24.3355 20.3065 22.8882 19.6934C22.0115 19.3222 21.1763 18.7762 20.578 18.177V18.178Z" fill="#1677FF"></path><path d="M17.0439 19.2156C17.0439 19.2156 17.037 19.2156 17.0321 19.2156C18.0648 19.2738 18.9029 19.7161 19.594 20.498C20.8073 21.8732 20.4568 24.6474 18.1901 24.7421C17.4606 24.7727 16.6263 24.3413 16.1456 23.6739C17.7202 26.6505 21.8281 26.0818 22.2694 23.3432C22.6288 21.114 20.0304 18.5699 17.0439 19.2136V19.2156ZM10 18C7.24751 15.8875 7.91886 10.4824 10.7779 6.4742C10.3317 5.85322 9.00779 4.787 7.32553 4.74751C4.61357 4.68433 2.68055 6.99842 3.66286 10.206C3.9768 6.91846 7.20805 6.33105 8.7363 8.17324C6.76477 12.1479 7.27817 16.1766 10 18C15 19.2194 12.2436 19.21 10 18Z" fill="#1E56E2"></path><path d="M10 18H12L13 19H12L11 18.5L10 18Z" fill="#1677FF"></path></svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
|
|
@ -12,6 +12,13 @@
|
|||
@else
|
||||
<x-status.stopped :status="$resource->status" />
|
||||
@endif
|
||||
@if (isset($resource->restart_count) && $resource->restart_count > 0 && !str($resource->status)->startsWith('exited'))
|
||||
<div class="flex items-center pl-2">
|
||||
<span class="text-xs dark:text-warning" title="Container has restarted {{ $resource->restart_count }} time{{ $resource->restart_count > 1 ? 's' : '' }}. Last restart: {{ $resource->last_restart_at?->diffForHumans() }}">
|
||||
({{ $resource->restart_count }}x restarts)
|
||||
</span>
|
||||
</div>
|
||||
@endif
|
||||
@if (!str($resource->status)->contains('exited') && $showRefreshButton)
|
||||
<button wire:loading.remove.delay.shortest wire:target="manualCheckStatus" title="Refresh Status" wire:click='manualCheckStatus'
|
||||
class="mx-1 dark:hover:fill-white fill-black dark:fill-warning">
|
||||
|
|
|
|||
|
|
@ -369,6 +369,39 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"
|
|||
@endif
|
||||
@if ($application->build_pack !== 'dockercompose')
|
||||
<h3 class="pt-8">Network</h3>
|
||||
@if ($this->detectedPortInfo)
|
||||
@if ($this->detectedPortInfo['isEmpty'])
|
||||
<div class="flex items-start gap-2 p-4 mb-4 text-sm rounded-lg bg-yellow-50 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-300 border border-yellow-200 dark:border-yellow-800">
|
||||
<svg class="w-5 h-5 shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<div>
|
||||
<span class="font-semibold">PORT environment variable detected ({{ $this->detectedPortInfo['port'] }})</span>
|
||||
<p class="mt-1">Your Ports Exposes field is empty. Consider setting it to <strong>{{ $this->detectedPortInfo['port'] }}</strong> to ensure the proxy routes traffic correctly.</p>
|
||||
</div>
|
||||
</div>
|
||||
@elseif (!$this->detectedPortInfo['matches'])
|
||||
<div class="flex items-start gap-2 p-4 mb-4 text-sm rounded-lg bg-yellow-50 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-300 border border-yellow-200 dark:border-yellow-800">
|
||||
<svg class="w-5 h-5 shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<div>
|
||||
<span class="font-semibold">PORT mismatch detected</span>
|
||||
<p class="mt-1">Your PORT environment variable is set to <strong>{{ $this->detectedPortInfo['port'] }}</strong>, but it's not in your Ports Exposes configuration. Ensure they match for proper proxy routing.</p>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="flex items-start gap-2 p-4 mb-4 text-sm rounded-lg bg-blue-50 dark:bg-blue-900/20 text-blue-800 dark:text-blue-300 border border-blue-200 dark:border-blue-800">
|
||||
<svg class="w-5 h-5 shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<div>
|
||||
<span class="font-semibold">PORT environment variable configured</span>
|
||||
<p class="mt-1">Your PORT environment variable ({{ $this->detectedPortInfo['port'] }}) matches your Ports Exposes configuration.</p>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
<div class="flex flex-col gap-2 xl:flex-row">
|
||||
@if ($application->settings->is_static || $application->build_pack === 'static')
|
||||
<x-forms.input id="portsExposes" label="Ports Exposes" readonly
|
||||
|
|
|
|||
|
|
@ -12,7 +12,14 @@
|
|||
</a>
|
||||
<a class="{{ request()->routeIs('project.application.logs') ? 'dark:text-white' : '' }}"
|
||||
href="{{ route('project.application.logs', $parameters) }}">
|
||||
Logs
|
||||
<div class="flex items-center gap-1">
|
||||
Logs
|
||||
@if ($application->restart_count > 0 && !str($application->status)->startsWith('exited'))
|
||||
<svg class="w-4 h-4 dark:text-warning" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" title="Container has restarted {{ $application->restart_count }} time{{ $application->restart_count > 1 ? 's' : '' }}">
|
||||
<path d="M12 2L1 21h22L12 2zm0 4l7.53 13H4.47L12 6zm-1 5v4h2v-4h-2zm0 5v2h2v-2h-2z"/>
|
||||
</svg>
|
||||
@endif
|
||||
</div>
|
||||
</a>
|
||||
@if (!$application->destination->server->isSwarm())
|
||||
@can('canAccessTerminal')
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ services:
|
|||
openpanel-worker:
|
||||
image: lindesvard/openpanel-worker:latest
|
||||
environment:
|
||||
- DISABLE_BULLBOARD=${DISABLE_BULLBOARD:-1}
|
||||
- NODE_ENV=production
|
||||
- NEXT_PUBLIC_SELF_HOSTED=true
|
||||
- SERVICE_URL_OPBULLBOARD
|
||||
|
|
|
|||
20
templates/compose/postgresus.yaml
Normal file
20
templates/compose/postgresus.yaml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# documentation: https://postgresus.com/#guide
|
||||
# slogan: Postgresus is a free, open source and self-hosted tool to backup PostgreSQL.
|
||||
# category: devtools
|
||||
# tags: postgres,backup
|
||||
# logo: svgs/postgresus.svg
|
||||
# port: 4005
|
||||
|
||||
services:
|
||||
postgresus:
|
||||
image: rostislavdugin/postgresus:7fb59bb5d02fbaf856b0bcfc7a0786575818b96f # Released on 30 Sep, 2025
|
||||
environment:
|
||||
- SERVICE_URL_POSTGRESUS_4005
|
||||
volumes:
|
||||
- postgresus-data:/postgresus-data
|
||||
healthcheck:
|
||||
test:
|
||||
["CMD", "wget", "-qO-", "http://localhost:4005/api/v1/system/health"]
|
||||
interval: 5s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
156
tests/Unit/ApplicationPortDetectionTest.php
Normal file
156
tests/Unit/ApplicationPortDetectionTest.php
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Unit tests for PORT environment variable detection feature.
|
||||
*
|
||||
* Tests verify that the Application model can correctly detect PORT environment
|
||||
* variables and provide information to the UI about matches and mismatches with
|
||||
* the configured ports_exposes field.
|
||||
*/
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use Illuminate\Support\Collection;
|
||||
use Mockery;
|
||||
|
||||
beforeEach(function () {
|
||||
// Clean up Mockery after each test
|
||||
Mockery::close();
|
||||
});
|
||||
|
||||
it('detects PORT environment variable when present', function () {
|
||||
// Create a mock Application instance
|
||||
$application = Mockery::mock(Application::class)->makePartial();
|
||||
|
||||
// Mock environment variables collection with PORT set to 3000
|
||||
$portEnvVar = Mockery::mock(EnvironmentVariable::class);
|
||||
$portEnvVar->shouldReceive('getAttribute')->with('real_value')->andReturn('3000');
|
||||
|
||||
$envVars = new Collection([$portEnvVar]);
|
||||
$application->shouldReceive('getAttribute')
|
||||
->with('environment_variables')
|
||||
->andReturn($envVars);
|
||||
|
||||
// Mock the firstWhere method to return our PORT env var
|
||||
$envVars = Mockery::mock(Collection::class);
|
||||
$envVars->shouldReceive('firstWhere')->with('key', 'PORT')->andReturn($portEnvVar);
|
||||
$application->shouldReceive('getAttribute')
|
||||
->with('environment_variables')
|
||||
->andReturn($envVars);
|
||||
|
||||
// Call the method we're testing
|
||||
$detectedPort = $application->detectPortFromEnvironment();
|
||||
|
||||
expect($detectedPort)->toBe(3000);
|
||||
});
|
||||
|
||||
it('returns null when PORT environment variable is not set', function () {
|
||||
$application = Mockery::mock(Application::class)->makePartial();
|
||||
|
||||
// Mock environment variables collection without PORT
|
||||
$envVars = Mockery::mock(Collection::class);
|
||||
$envVars->shouldReceive('firstWhere')->with('key', 'PORT')->andReturn(null);
|
||||
$application->shouldReceive('getAttribute')
|
||||
->with('environment_variables')
|
||||
->andReturn($envVars);
|
||||
|
||||
$detectedPort = $application->detectPortFromEnvironment();
|
||||
|
||||
expect($detectedPort)->toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when PORT value is not numeric', function () {
|
||||
$application = Mockery::mock(Application::class)->makePartial();
|
||||
|
||||
// Mock environment variables with non-numeric PORT value
|
||||
$portEnvVar = Mockery::mock(EnvironmentVariable::class);
|
||||
$portEnvVar->shouldReceive('getAttribute')->with('real_value')->andReturn('invalid-port');
|
||||
|
||||
$envVars = Mockery::mock(Collection::class);
|
||||
$envVars->shouldReceive('firstWhere')->with('key', 'PORT')->andReturn($portEnvVar);
|
||||
$application->shouldReceive('getAttribute')
|
||||
->with('environment_variables')
|
||||
->andReturn($envVars);
|
||||
|
||||
$detectedPort = $application->detectPortFromEnvironment();
|
||||
|
||||
expect($detectedPort)->toBeNull();
|
||||
});
|
||||
|
||||
it('handles PORT value with whitespace', function () {
|
||||
$application = Mockery::mock(Application::class)->makePartial();
|
||||
|
||||
// Mock environment variables with PORT value that has whitespace
|
||||
$portEnvVar = Mockery::mock(EnvironmentVariable::class);
|
||||
$portEnvVar->shouldReceive('getAttribute')->with('real_value')->andReturn(' 8080 ');
|
||||
|
||||
$envVars = Mockery::mock(Collection::class);
|
||||
$envVars->shouldReceive('firstWhere')->with('key', 'PORT')->andReturn($portEnvVar);
|
||||
$application->shouldReceive('getAttribute')
|
||||
->with('environment_variables')
|
||||
->andReturn($envVars);
|
||||
|
||||
$detectedPort = $application->detectPortFromEnvironment();
|
||||
|
||||
expect($detectedPort)->toBe(8080);
|
||||
});
|
||||
|
||||
it('detects PORT from preview environment variables when isPreview is true', function () {
|
||||
$application = Mockery::mock(Application::class)->makePartial();
|
||||
|
||||
// Mock preview environment variables with PORT
|
||||
$portEnvVar = Mockery::mock(EnvironmentVariable::class);
|
||||
$portEnvVar->shouldReceive('getAttribute')->with('real_value')->andReturn('4000');
|
||||
|
||||
$envVars = Mockery::mock(Collection::class);
|
||||
$envVars->shouldReceive('firstWhere')->with('key', 'PORT')->andReturn($portEnvVar);
|
||||
$application->shouldReceive('getAttribute')
|
||||
->with('environment_variables_preview')
|
||||
->andReturn($envVars);
|
||||
|
||||
$detectedPort = $application->detectPortFromEnvironment(true);
|
||||
|
||||
expect($detectedPort)->toBe(4000);
|
||||
});
|
||||
|
||||
it('verifies ports_exposes array conversion logic', function () {
|
||||
// Test the logic that converts comma-separated ports to array
|
||||
$portsExposesString = '3000,3001,8080';
|
||||
$expectedArray = [3000, 3001, 8080];
|
||||
|
||||
// This simulates what portsExposesArray accessor does
|
||||
$result = is_null($portsExposesString)
|
||||
? []
|
||||
: explode(',', $portsExposesString);
|
||||
|
||||
// Convert to integers for comparison
|
||||
$result = array_map('intval', $result);
|
||||
|
||||
expect($result)->toBe($expectedArray);
|
||||
});
|
||||
|
||||
it('verifies PORT matches detection logic', function () {
|
||||
$detectedPort = 3000;
|
||||
$portsExposesArray = [3000, 3001];
|
||||
|
||||
$isMatch = in_array($detectedPort, $portsExposesArray);
|
||||
|
||||
expect($isMatch)->toBeTrue();
|
||||
});
|
||||
|
||||
it('verifies PORT mismatch detection logic', function () {
|
||||
$detectedPort = 8080;
|
||||
$portsExposesArray = [3000, 3001];
|
||||
|
||||
$isMatch = in_array($detectedPort, $portsExposesArray);
|
||||
|
||||
expect($isMatch)->toBeFalse();
|
||||
});
|
||||
|
||||
it('verifies empty ports_exposes detection logic', function () {
|
||||
$portsExposesArray = [];
|
||||
|
||||
$isEmpty = empty($portsExposesArray);
|
||||
|
||||
expect($isEmpty)->toBeTrue();
|
||||
});
|
||||
82
tests/Unit/RestartCountTrackingTest.php
Normal file
82
tests/Unit/RestartCountTrackingTest.php
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Server;
|
||||
|
||||
beforeEach(function () {
|
||||
// Mock server
|
||||
$this->server = Mockery::mock(Server::class);
|
||||
$this->server->shouldReceive('isFunctional')->andReturn(true);
|
||||
$this->server->shouldReceive('isSwarm')->andReturn(false);
|
||||
$this->server->shouldReceive('applications')->andReturn(collect());
|
||||
|
||||
// Mock application
|
||||
$this->application = Mockery::mock(Application::class);
|
||||
$this->application->shouldReceive('getAttribute')->with('id')->andReturn(1);
|
||||
$this->application->shouldReceive('getAttribute')->with('name')->andReturn('test-app');
|
||||
$this->application->shouldReceive('getAttribute')->with('restart_count')->andReturn(0);
|
||||
$this->application->shouldReceive('getAttribute')->with('uuid')->andReturn('test-uuid');
|
||||
$this->application->shouldReceive('getAttribute')->with('environment')->andReturn(null);
|
||||
});
|
||||
|
||||
it('extracts restart count from container data', function () {
|
||||
$containerData = [
|
||||
'RestartCount' => 5,
|
||||
'State' => [
|
||||
'Status' => 'running',
|
||||
'Health' => ['Status' => 'healthy'],
|
||||
],
|
||||
'Config' => [
|
||||
'Labels' => [
|
||||
'coolify.applicationId' => '1',
|
||||
'com.docker.compose.service' => 'web',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$restartCount = data_get($containerData, 'RestartCount', 0);
|
||||
|
||||
expect($restartCount)->toBe(5);
|
||||
});
|
||||
|
||||
it('defaults to zero when restart count is missing', function () {
|
||||
$containerData = [
|
||||
'State' => [
|
||||
'Status' => 'running',
|
||||
],
|
||||
'Config' => [
|
||||
'Labels' => [],
|
||||
],
|
||||
];
|
||||
|
||||
$restartCount = data_get($containerData, 'RestartCount', 0);
|
||||
|
||||
expect($restartCount)->toBe(0);
|
||||
});
|
||||
|
||||
it('detects restart count increase', function () {
|
||||
$previousRestartCount = 2;
|
||||
$currentRestartCount = 5;
|
||||
|
||||
expect($currentRestartCount)->toBeGreaterThan($previousRestartCount);
|
||||
});
|
||||
|
||||
it('identifies maximum restart count from multiple containers', function () {
|
||||
$containerRestartCounts = collect([
|
||||
'web' => 3,
|
||||
'worker' => 5,
|
||||
'scheduler' => 1,
|
||||
]);
|
||||
|
||||
$maxRestartCount = $containerRestartCounts->max();
|
||||
|
||||
expect($maxRestartCount)->toBe(5);
|
||||
});
|
||||
|
||||
it('handles empty restart counts collection', function () {
|
||||
$containerRestartCounts = collect([]);
|
||||
|
||||
$maxRestartCount = $containerRestartCounts->max() ?? 0;
|
||||
|
||||
expect($maxRestartCount)->toBe(0);
|
||||
});
|
||||
|
|
@ -194,6 +194,36 @@
|
|||
->not->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('array-format with environment variable and path concatenation', function () {
|
||||
// This is the reported issue #7127 - ${VAR}/path should be allowed
|
||||
$dockerComposeYaml = <<<'YAML'
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
volumes:
|
||||
- type: bind
|
||||
source: '${VOLUMES_PATH}/mysql'
|
||||
target: /var/lib/mysql
|
||||
- type: bind
|
||||
source: '${DATA_PATH}/config'
|
||||
target: /etc/config
|
||||
- type: bind
|
||||
source: '${VOLUME_PATH}/app_data'
|
||||
target: /app/data
|
||||
YAML;
|
||||
|
||||
$parsed = Yaml::parse($dockerComposeYaml);
|
||||
|
||||
// Verify all three volumes have the correct source format
|
||||
expect($parsed['services']['web']['volumes'][0]['source'])->toBe('${VOLUMES_PATH}/mysql');
|
||||
expect($parsed['services']['web']['volumes'][1]['source'])->toBe('${DATA_PATH}/config');
|
||||
expect($parsed['services']['web']['volumes'][2]['source'])->toBe('${VOLUME_PATH}/app_data');
|
||||
|
||||
// The validation should allow this - the reported bug was that it was blocked
|
||||
expect(fn () => validateDockerComposeForInjection($dockerComposeYaml))
|
||||
->not->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('array-format with malicious environment variable default', function () {
|
||||
$dockerComposeYaml = <<<'YAML'
|
||||
services:
|
||||
|
|
|
|||
|
|
@ -94,6 +94,27 @@
|
|||
}
|
||||
});
|
||||
|
||||
test('parseDockerVolumeString accepts environment variables with path concatenation', function () {
|
||||
$volumes = [
|
||||
'${VOLUMES_PATH}/mysql:/var/lib/mysql',
|
||||
'${DATA_PATH}/config:/etc/config',
|
||||
'${VOLUME_PATH}/app_data:/app',
|
||||
'${MY_VAR_123}/deep/nested/path:/data',
|
||||
'${VAR}/path:/app',
|
||||
'${VAR}_suffix:/app',
|
||||
'${VAR}-suffix:/app',
|
||||
'${VAR}.ext:/app',
|
||||
'${VOLUMES_PATH}/mysql:/var/lib/mysql:ro',
|
||||
'${DATA_PATH}/config:/etc/config:rw',
|
||||
];
|
||||
|
||||
foreach ($volumes as $volume) {
|
||||
$result = parseDockerVolumeString($volume);
|
||||
expect($result)->toBeArray();
|
||||
expect($result['source'])->not->toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test('parseDockerVolumeString rejects environment variables with command injection in default', function () {
|
||||
$maliciousVolumes = [
|
||||
'${VAR:-`whoami`}:/app',
|
||||
|
|
|
|||
Loading…
Reference in a new issue