Merge branch 'next' into improve-scheduled-tasks

This commit is contained in:
Andras Bacsai 2025-11-10 14:21:03 +01:00 committed by GitHub
commit 71c89d9ba8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 870 additions and 52 deletions

View file

@ -28,6 +28,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 +138,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 +305,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,7 +371,37 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
continue;
}
$aggregatedStatus = $this->aggregateApplicationStatus($application, $containerStatuses);
// Track restart counts first
$maxRestartCount = 0;
if (isset($this->applicationContainerRestartCounts) && $this->applicationContainerRestartCounts->has($applicationId)) {
$containerRestartCounts = $this->applicationContainerRestartCounts->get($applicationId);
$maxRestartCount = $containerRestartCounts->max() ?? 0;
$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) {
@ -355,7 +416,7 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
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 +474,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 +487,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)';
}
}

View file

@ -22,6 +22,10 @@ public function handle(Service $service, bool $pullLatestImages = false, bool $s
$service->isConfigurationChanged(save: true);
$commands[] = 'cd '.$service->workdir();
$commands[] = "echo 'Saved configuration files to {$service->workdir()}.'";
// Ensure .env file exists 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 .env';
if ($pullLatestImages) {
$commands[] = "echo 'Pulling images.'";
$commands[] = 'docker compose pull';

View file

@ -246,6 +246,50 @@ 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.');
}
// Kill running process if process ID exists
if ($activeDeployment->current_process_id) {
try {
$processKillCommand = "kill -9 {$activeDeployment->current_process_id}";
instant_remote_process([$processKillCommand], $server);
} catch (\Throwable $e) {
// Process might already be gone
}
}
} catch (\Throwable $e) {
// Silently handle errors during deployment cancellation
}
}
DeleteResourceJob::dispatch($found);
$return_payloads->push([
'application' => $application->name,
@ -481,6 +525,51 @@ 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.');
}
// Kill running process if process ID exists
if ($activeDeployment->current_process_id) {
try {
$processKillCommand = "kill -9 {$activeDeployment->current_process_id}";
instant_remote_process([$processKillCommand], $server);
} catch (\Throwable $e) {
// Process might already be gone
}
}
} 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) {

View file

@ -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,15 @@ 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)) {
ray()->orange("PORT environment variable ({$detectedPort}) does not match configured ports_exposes: ".implode(',', $ports));
}
}
// 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 +3060,12 @@ 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->workdir}/.env", 'hidden' => true],
["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 +3835,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();

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 @@ 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);
}
}

View file

@ -124,6 +124,51 @@ 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.');
}
// Kill running process if process ID exists
if ($activeDeployment->current_process_id) {
try {
$processKillCommand = "kill -9 {$activeDeployment->current_process_id}";
instant_remote_process([$processKillCommand], $server);
} catch (\Throwable $e) {
// Process might already be gone
}
}
} 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 +178,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 +201,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,

View file

@ -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,
];
}
}

View file

@ -94,6 +94,14 @@ public function deploy(bool $force_rebuild = false)
return;
}
// Reset restart count on deployment
$this->application->update([
'restart_count' => 0,
'last_restart_at' => null,
'last_restart_type' => null,
]);
$this->setDeploymentUuid();
$result = queue_application_deployment(
application: $this->application,
@ -137,6 +145,14 @@ public function restart()
return;
}
// Reset restart count on manual restart
$this->application->update([
'restart_count' => 0,
'last_restart_at' => now(),
'last_restart_type' => 'manual',
]);
$this->setDeploymentUuid();
$result = queue_application_deployment(
application: $this->application,

View file

@ -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,26 @@ 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();
$this->service->refresh();
$this->service->saveComposeConfigs();
// 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();
$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)) {

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()
@ -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')

View file

@ -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([

View file

@ -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}");
}
}
}

View file

@ -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;

View file

@ -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) {
@ -711,9 +715,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) {
@ -1293,6 +1300,9 @@ 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
$payload['env_file'] = ['.env'];
if ($isPullRequest) {
$serviceName = addPreviewDeploymentSuffix($serviceName, $pullRequestId);
}
@ -1812,9 +1822,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) {
@ -2269,6 +2282,9 @@ 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
$payload['env_file'] = ['.env'];
$parsedServices->put($serviceName, $payload);
}

View file

@ -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']);
});
}
};

View file

@ -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;

View file

@ -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;

View 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

View file

@ -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">

View file

@ -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

View file

@ -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')

View file

@ -17,7 +17,7 @@ services:
- CONVEX_RELEASE_VERSION_DEV=${CONVEX_RELEASE_VERSION_DEV:-}
- ACTIONS_USER_TIMEOUT_SECS=${ACTIONS_USER_TIMEOUT_SECS:-}
# URL of the Convex API as accessed by the client/frontend.
- CONVEX_CLOUD_ORIGIN=${SERVICE_URL_CONVEX}
- CONVEX_CLOUD_ORIGIN=${SERVICE_URL_DASHBOARD}
# URL of Convex HTTP actions as accessed by the client/frontend.
- CONVEX_SITE_ORIGIN=${SERVICE_URL_BACKEND}
- DATABASE_URL=${DATABASE_URL:-}
@ -49,7 +49,7 @@ services:
dashboard:
image: ghcr.io/get-convex/convex-dashboard:33cef775a8a6228cbacee4a09ac2c4073d62ed13
environment:
- SERVICE_URL_CONVEX_6791
- SERVICE_URL_DASHBOARD_6791
# URL of the Convex API as accessed by the dashboard (browser).
- NEXT_PUBLIC_DEPLOYMENT_URL=${SERVICE_URL_BACKEND}
depends_on:

View 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

View file

@ -599,7 +599,7 @@
"convex": {
"documentation": "https://github.com/get-convex/convex-backend/blob/main/self-hosted/README.md?utm_source=coolify.io",
"slogan": "Convex is the open-source reactive database for app developers.",
"compose": "c2VydmljZXM6CiAgYmFja2VuZDoKICAgIGltYWdlOiAnZ2hjci5pby9nZXQtY29udmV4L2NvbnZleC1iYWNrZW5kOjAwYmQ5MjcyMzQyMmYzYmZmOTY4MjMwYzk0Y2NkZWI4YzE3MTk4MzInCiAgICB2b2x1bWVzOgogICAgICAtICdkYXRhOi9jb252ZXgvZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0JBQ0tFTkRfMzIxMAogICAgICAtICdJTlNUQU5DRV9OQU1FPSR7SU5TVEFOQ0VfTkFNRTotc2VsZi1ob3N0ZWQtY29udmV4fScKICAgICAgLSAnSU5TVEFOQ0VfU0VDUkVUPSR7U0VSVklDRV9IRVhfMzJfU0VDUkVUfScKICAgICAgLSAnQ09OVkVYX1JFTEVBU0VfVkVSU0lPTl9ERVY9JHtDT05WRVhfUkVMRUFTRV9WRVJTSU9OX0RFVjotfScKICAgICAgLSAnQUNUSU9OU19VU0VSX1RJTUVPVVRfU0VDUz0ke0FDVElPTlNfVVNFUl9USU1FT1VUX1NFQ1M6LX0nCiAgICAgIC0gJ0NPTlZFWF9DTE9VRF9PUklHSU49JHtTRVJWSUNFX1VSTF9DT05WRVh9JwogICAgICAtICdDT05WRVhfU0lURV9PUklHSU49JHtTRVJWSUNFX1VSTF9CQUNLRU5EfScKICAgICAgLSAnREFUQUJBU0VfVVJMPSR7REFUQUJBU0VfVVJMOi19JwogICAgICAtICdESVNBQkxFX0JFQUNPTj0ke0RJU0FCTEVfQkVBQ09OOj9mYWxzZX0nCiAgICAgIC0gJ1JFREFDVF9MT0dTX1RPX0NMSUVOVD0ke1JFREFDVF9MT0dTX1RPX0NMSUVOVDo/ZmFsc2V9JwogICAgICAtICdET19OT1RfUkVRVUlSRV9TU0w9JHtET19OT1RfUkVRVUlSRV9TU0w6P3RydWV9JwogICAgICAtICdQT1NUR1JFU19VUkw9JHtQT1NUR1JFU19VUkw6LX0nCiAgICAgIC0gJ01ZU1FMX1VSTD0ke01ZU1FMX1VSTDotfScKICAgICAgLSAnUlVTVF9MT0c9JHtSVVNUX0xPRzotaW5mb30nCiAgICAgIC0gJ1JVU1RfQkFDS1RSQUNFPSR7UlVTVF9CQUNLVFJBQ0U6LX0nCiAgICAgIC0gJ0FXU19SRUdJT049JHtBV1NfUkVHSU9OOi19JwogICAgICAtICdBV1NfQUNDRVNTX0tFWV9JRD0ke0FXU19BQ0NFU1NfS0VZX0lEOi19JwogICAgICAtICdBV1NfU0VDUkVUX0FDQ0VTU19LRVk9JHtBV1NfU0VDUkVUX0FDQ0VTU19LRVk6LX0nCiAgICAgIC0gJ0FXU19TRVNTSU9OX1RPS0VOPSR7QVdTX1NFU1NJT05fVE9LRU46LX0nCiAgICAgIC0gJ0FXU19TM19GT1JDRV9QQVRIX1NUWUxFPSR7QVdTX1MzX0ZPUkNFX1BBVEhfU1RZTEU6LX0nCiAgICAgIC0gJ0FXU19TM19ESVNBQkxFX1NTRT0ke0FXU19TM19ESVNBQkxFX1NTRTotfScKICAgICAgLSAnQVdTX1MzX0RJU0FCTEVfQ0hFQ0tTVU1TPSR7QVdTX1MzX0RJU0FCTEVfQ0hFQ0tTVU1TOi19JwogICAgICAtICdTM19TVE9SQUdFX0VYUE9SVFNfQlVDS0VUPSR7UzNfU1RPUkFHRV9FWFBPUlRTX0JVQ0tFVDotfScKICAgICAgLSAnUzNfU1RPUkFHRV9TTkFQU0hPVF9JTVBPUlRTX0JVQ0tFVD0ke1MzX1NUT1JBR0VfU05BUFNIT1RfSU1QT1JUU19CVUNLRVQ6LX0nCiAgICAgIC0gJ1MzX1NUT1JBR0VfTU9EVUxFU19CVUNLRVQ9JHtTM19TVE9SQUdFX01PRFVMRVNfQlVDS0VUOi19JwogICAgICAtICdTM19TVE9SQUdFX0ZJTEVTX0JVQ0tFVD0ke1MzX1NUT1JBR0VfRklMRVNfQlVDS0VUOi19JwogICAgICAtICdTM19TVE9SQUdFX1NFQVJDSF9CVUNLRVQ9JHtTM19TVE9SQUdFX1NFQVJDSF9CVUNLRVQ6LX0nCiAgICAgIC0gJ1MzX0VORFBPSU5UX1VSTD0ke1MzX0VORFBPSU5UX1VSTDotfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAnY3VybCAtZiBodHRwOi8vMTI3LjAuMC4xOjMyMTAvdmVyc2lvbicKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHN0YXJ0X3BlcmlvZDogMTBzCiAgZGFzaGJvYXJkOgogICAgaW1hZ2U6ICdnaGNyLmlvL2dldC1jb252ZXgvY29udmV4LWRhc2hib2FyZDozM2NlZjc3NWE4YTYyMjhjYmFjZWU0YTA5YWMyYzQwNzNkNjJlZDEzJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfQ09OVkVYXzY3OTEKICAgICAgLSAnTkVYVF9QVUJMSUNfREVQTE9ZTUVOVF9VUkw9JHtTRVJWSUNFX1VSTF9CQUNLRU5EfScKICAgIGRlcGVuZHNfb246CiAgICAgIGJhY2tlbmQ6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAnd2dldCAtcU8tIGh0dHA6Ly8xMjcuMC4wLjE6Njc5MS8nCiAgICAgIGludGVydmFsOiA1cwogICAgICBzdGFydF9wZXJpb2Q6IDVzCg==",
"compose": "c2VydmljZXM6CiAgYmFja2VuZDoKICAgIGltYWdlOiAnZ2hjci5pby9nZXQtY29udmV4L2NvbnZleC1iYWNrZW5kOjAwYmQ5MjcyMzQyMmYzYmZmOTY4MjMwYzk0Y2NkZWI4YzE3MTk4MzInCiAgICB2b2x1bWVzOgogICAgICAtICdkYXRhOi9jb252ZXgvZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0JBQ0tFTkRfMzIxMAogICAgICAtICdJTlNUQU5DRV9OQU1FPSR7SU5TVEFOQ0VfTkFNRTotc2VsZi1ob3N0ZWQtY29udmV4fScKICAgICAgLSAnSU5TVEFOQ0VfU0VDUkVUPSR7U0VSVklDRV9IRVhfMzJfU0VDUkVUfScKICAgICAgLSAnQ09OVkVYX1JFTEVBU0VfVkVSU0lPTl9ERVY9JHtDT05WRVhfUkVMRUFTRV9WRVJTSU9OX0RFVjotfScKICAgICAgLSAnQUNUSU9OU19VU0VSX1RJTUVPVVRfU0VDUz0ke0FDVElPTlNfVVNFUl9USU1FT1VUX1NFQ1M6LX0nCiAgICAgIC0gJ0NPTlZFWF9DTE9VRF9PUklHSU49JHtTRVJWSUNFX1VSTF9EQVNIQk9BUkR9JwogICAgICAtICdDT05WRVhfU0lURV9PUklHSU49JHtTRVJWSUNFX1VSTF9CQUNLRU5EfScKICAgICAgLSAnREFUQUJBU0VfVVJMPSR7REFUQUJBU0VfVVJMOi19JwogICAgICAtICdESVNBQkxFX0JFQUNPTj0ke0RJU0FCTEVfQkVBQ09OOj9mYWxzZX0nCiAgICAgIC0gJ1JFREFDVF9MT0dTX1RPX0NMSUVOVD0ke1JFREFDVF9MT0dTX1RPX0NMSUVOVDo/ZmFsc2V9JwogICAgICAtICdET19OT1RfUkVRVUlSRV9TU0w9JHtET19OT1RfUkVRVUlSRV9TU0w6P3RydWV9JwogICAgICAtICdQT1NUR1JFU19VUkw9JHtQT1NUR1JFU19VUkw6LX0nCiAgICAgIC0gJ01ZU1FMX1VSTD0ke01ZU1FMX1VSTDotfScKICAgICAgLSAnUlVTVF9MT0c9JHtSVVNUX0xPRzotaW5mb30nCiAgICAgIC0gJ1JVU1RfQkFDS1RSQUNFPSR7UlVTVF9CQUNLVFJBQ0U6LX0nCiAgICAgIC0gJ0FXU19SRUdJT049JHtBV1NfUkVHSU9OOi19JwogICAgICAtICdBV1NfQUNDRVNTX0tFWV9JRD0ke0FXU19BQ0NFU1NfS0VZX0lEOi19JwogICAgICAtICdBV1NfU0VDUkVUX0FDQ0VTU19LRVk9JHtBV1NfU0VDUkVUX0FDQ0VTU19LRVk6LX0nCiAgICAgIC0gJ0FXU19TRVNTSU9OX1RPS0VOPSR7QVdTX1NFU1NJT05fVE9LRU46LX0nCiAgICAgIC0gJ0FXU19TM19GT1JDRV9QQVRIX1NUWUxFPSR7QVdTX1MzX0ZPUkNFX1BBVEhfU1RZTEU6LX0nCiAgICAgIC0gJ0FXU19TM19ESVNBQkxFX1NTRT0ke0FXU19TM19ESVNBQkxFX1NTRTotfScKICAgICAgLSAnQVdTX1MzX0RJU0FCTEVfQ0hFQ0tTVU1TPSR7QVdTX1MzX0RJU0FCTEVfQ0hFQ0tTVU1TOi19JwogICAgICAtICdTM19TVE9SQUdFX0VYUE9SVFNfQlVDS0VUPSR7UzNfU1RPUkFHRV9FWFBPUlRTX0JVQ0tFVDotfScKICAgICAgLSAnUzNfU1RPUkFHRV9TTkFQU0hPVF9JTVBPUlRTX0JVQ0tFVD0ke1MzX1NUT1JBR0VfU05BUFNIT1RfSU1QT1JUU19CVUNLRVQ6LX0nCiAgICAgIC0gJ1MzX1NUT1JBR0VfTU9EVUxFU19CVUNLRVQ9JHtTM19TVE9SQUdFX01PRFVMRVNfQlVDS0VUOi19JwogICAgICAtICdTM19TVE9SQUdFX0ZJTEVTX0JVQ0tFVD0ke1MzX1NUT1JBR0VfRklMRVNfQlVDS0VUOi19JwogICAgICAtICdTM19TVE9SQUdFX1NFQVJDSF9CVUNLRVQ9JHtTM19TVE9SQUdFX1NFQVJDSF9CVUNLRVQ6LX0nCiAgICAgIC0gJ1MzX0VORFBPSU5UX1VSTD0ke1MzX0VORFBPSU5UX1VSTDotfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAnY3VybCAtZiBodHRwOi8vMTI3LjAuMC4xOjMyMTAvdmVyc2lvbicKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHN0YXJ0X3BlcmlvZDogMTBzCiAgZGFzaGJvYXJkOgogICAgaW1hZ2U6ICdnaGNyLmlvL2dldC1jb252ZXgvY29udmV4LWRhc2hib2FyZDozM2NlZjc3NWE4YTYyMjhjYmFjZWU0YTA5YWMyYzQwNzNkNjJlZDEzJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfREFTSEJPQVJEXzY3OTEKICAgICAgLSAnTkVYVF9QVUJMSUNfREVQTE9ZTUVOVF9VUkw9JHtTRVJWSUNFX1VSTF9CQUNLRU5EfScKICAgIGRlcGVuZHNfb246CiAgICAgIGJhY2tlbmQ6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAnd2dldCAtcU8tIGh0dHA6Ly8xMjcuMC4wLjE6Njc5MS8nCiAgICAgIGludGVydmFsOiA1cwogICAgICBzdGFydF9wZXJpb2Q6IDVzCg==",
"tags": [
"database",
"reactive",
@ -3375,6 +3375,19 @@
"minversion": "0.0.0",
"port": "9000"
},
"postgresus": {
"documentation": "https://postgresus.com/#guide?utm_source=coolify.io",
"slogan": "Postgresus is a free, open source and self-hosted tool to backup PostgreSQL.",
"compose": "c2VydmljZXM6CiAgcG9zdGdyZXN1czoKICAgIGltYWdlOiAncm9zdGlzbGF2ZHVnaW4vcG9zdGdyZXN1czo3ZmI1OWJiNWQwMmZiYWY4NTZiMGJjZmM3YTA3ODY1NzU4MThiOTZmJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfUE9TVEdSRVNVU180MDA1CiAgICB2b2x1bWVzOgogICAgICAtICdwb3N0Z3Jlc3VzLWRhdGE6L3Bvc3RncmVzdXMtZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSB3Z2V0CiAgICAgICAgLSAnLXFPLScKICAgICAgICAtICdodHRwOi8vbG9jYWxob3N0OjQwMDUvYXBpL3YxL3N5c3RlbS9oZWFsdGgnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogNQo=",
"tags": [
"postgres",
"backup"
],
"category": "devtools",
"logo": "svgs/postgresus.svg",
"minversion": "0.0.0",
"port": "4005"
},
"postiz": {
"documentation": "https://docs.postiz.com?utm_source=coolify.io",
"slogan": "Open source social media scheduling tool.",

View file

@ -599,7 +599,7 @@
"convex": {
"documentation": "https://github.com/get-convex/convex-backend/blob/main/self-hosted/README.md?utm_source=coolify.io",
"slogan": "Convex is the open-source reactive database for app developers.",
"compose": "c2VydmljZXM6CiAgYmFja2VuZDoKICAgIGltYWdlOiAnZ2hjci5pby9nZXQtY29udmV4L2NvbnZleC1iYWNrZW5kOjAwYmQ5MjcyMzQyMmYzYmZmOTY4MjMwYzk0Y2NkZWI4YzE3MTk4MzInCiAgICB2b2x1bWVzOgogICAgICAtICdkYXRhOi9jb252ZXgvZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9CQUNLRU5EXzMyMTAKICAgICAgLSAnSU5TVEFOQ0VfTkFNRT0ke0lOU1RBTkNFX05BTUU6LXNlbGYtaG9zdGVkLWNvbnZleH0nCiAgICAgIC0gJ0lOU1RBTkNFX1NFQ1JFVD0ke1NFUlZJQ0VfSEVYXzMyX1NFQ1JFVH0nCiAgICAgIC0gJ0NPTlZFWF9SRUxFQVNFX1ZFUlNJT05fREVWPSR7Q09OVkVYX1JFTEVBU0VfVkVSU0lPTl9ERVY6LX0nCiAgICAgIC0gJ0FDVElPTlNfVVNFUl9USU1FT1VUX1NFQ1M9JHtBQ1RJT05TX1VTRVJfVElNRU9VVF9TRUNTOi19JwogICAgICAtICdDT05WRVhfQ0xPVURfT1JJR0lOPSR7U0VSVklDRV9GUUROX0NPTlZFWH0nCiAgICAgIC0gJ0NPTlZFWF9TSVRFX09SSUdJTj0ke1NFUlZJQ0VfRlFETl9CQUNLRU5EfScKICAgICAgLSAnREFUQUJBU0VfVVJMPSR7REFUQUJBU0VfVVJMOi19JwogICAgICAtICdESVNBQkxFX0JFQUNPTj0ke0RJU0FCTEVfQkVBQ09OOj9mYWxzZX0nCiAgICAgIC0gJ1JFREFDVF9MT0dTX1RPX0NMSUVOVD0ke1JFREFDVF9MT0dTX1RPX0NMSUVOVDo/ZmFsc2V9JwogICAgICAtICdET19OT1RfUkVRVUlSRV9TU0w9JHtET19OT1RfUkVRVUlSRV9TU0w6P3RydWV9JwogICAgICAtICdQT1NUR1JFU19VUkw9JHtQT1NUR1JFU19VUkw6LX0nCiAgICAgIC0gJ01ZU1FMX1VSTD0ke01ZU1FMX1VSTDotfScKICAgICAgLSAnUlVTVF9MT0c9JHtSVVNUX0xPRzotaW5mb30nCiAgICAgIC0gJ1JVU1RfQkFDS1RSQUNFPSR7UlVTVF9CQUNLVFJBQ0U6LX0nCiAgICAgIC0gJ0FXU19SRUdJT049JHtBV1NfUkVHSU9OOi19JwogICAgICAtICdBV1NfQUNDRVNTX0tFWV9JRD0ke0FXU19BQ0NFU1NfS0VZX0lEOi19JwogICAgICAtICdBV1NfU0VDUkVUX0FDQ0VTU19LRVk9JHtBV1NfU0VDUkVUX0FDQ0VTU19LRVk6LX0nCiAgICAgIC0gJ0FXU19TRVNTSU9OX1RPS0VOPSR7QVdTX1NFU1NJT05fVE9LRU46LX0nCiAgICAgIC0gJ0FXU19TM19GT1JDRV9QQVRIX1NUWUxFPSR7QVdTX1MzX0ZPUkNFX1BBVEhfU1RZTEU6LX0nCiAgICAgIC0gJ0FXU19TM19ESVNBQkxFX1NTRT0ke0FXU19TM19ESVNBQkxFX1NTRTotfScKICAgICAgLSAnQVdTX1MzX0RJU0FCTEVfQ0hFQ0tTVU1TPSR7QVdTX1MzX0RJU0FCTEVfQ0hFQ0tTVU1TOi19JwogICAgICAtICdTM19TVE9SQUdFX0VYUE9SVFNfQlVDS0VUPSR7UzNfU1RPUkFHRV9FWFBPUlRTX0JVQ0tFVDotfScKICAgICAgLSAnUzNfU1RPUkFHRV9TTkFQU0hPVF9JTVBPUlRTX0JVQ0tFVD0ke1MzX1NUT1JBR0VfU05BUFNIT1RfSU1QT1JUU19CVUNLRVQ6LX0nCiAgICAgIC0gJ1MzX1NUT1JBR0VfTU9EVUxFU19CVUNLRVQ9JHtTM19TVE9SQUdFX01PRFVMRVNfQlVDS0VUOi19JwogICAgICAtICdTM19TVE9SQUdFX0ZJTEVTX0JVQ0tFVD0ke1MzX1NUT1JBR0VfRklMRVNfQlVDS0VUOi19JwogICAgICAtICdTM19TVE9SQUdFX1NFQVJDSF9CVUNLRVQ9JHtTM19TVE9SQUdFX1NFQVJDSF9CVUNLRVQ6LX0nCiAgICAgIC0gJ1MzX0VORFBPSU5UX1VSTD0ke1MzX0VORFBPSU5UX1VSTDotfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAnY3VybCAtZiBodHRwOi8vMTI3LjAuMC4xOjMyMTAvdmVyc2lvbicKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHN0YXJ0X3BlcmlvZDogMTBzCiAgZGFzaGJvYXJkOgogICAgaW1hZ2U6ICdnaGNyLmlvL2dldC1jb252ZXgvY29udmV4LWRhc2hib2FyZDozM2NlZjc3NWE4YTYyMjhjYmFjZWU0YTA5YWMyYzQwNzNkNjJlZDEzJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0NPTlZFWF82NzkxCiAgICAgIC0gJ05FWFRfUFVCTElDX0RFUExPWU1FTlRfVVJMPSR7U0VSVklDRV9GUUROX0JBQ0tFTkR9JwogICAgZGVwZW5kc19vbjoKICAgICAgYmFja2VuZDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6ICd3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo2NzkxLycKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHN0YXJ0X3BlcmlvZDogNXMK",
"compose": "c2VydmljZXM6CiAgYmFja2VuZDoKICAgIGltYWdlOiAnZ2hjci5pby9nZXQtY29udmV4L2NvbnZleC1iYWNrZW5kOjAwYmQ5MjcyMzQyMmYzYmZmOTY4MjMwYzk0Y2NkZWI4YzE3MTk4MzInCiAgICB2b2x1bWVzOgogICAgICAtICdkYXRhOi9jb252ZXgvZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9CQUNLRU5EXzMyMTAKICAgICAgLSAnSU5TVEFOQ0VfTkFNRT0ke0lOU1RBTkNFX05BTUU6LXNlbGYtaG9zdGVkLWNvbnZleH0nCiAgICAgIC0gJ0lOU1RBTkNFX1NFQ1JFVD0ke1NFUlZJQ0VfSEVYXzMyX1NFQ1JFVH0nCiAgICAgIC0gJ0NPTlZFWF9SRUxFQVNFX1ZFUlNJT05fREVWPSR7Q09OVkVYX1JFTEVBU0VfVkVSU0lPTl9ERVY6LX0nCiAgICAgIC0gJ0FDVElPTlNfVVNFUl9USU1FT1VUX1NFQ1M9JHtBQ1RJT05TX1VTRVJfVElNRU9VVF9TRUNTOi19JwogICAgICAtICdDT05WRVhfQ0xPVURfT1JJR0lOPSR7U0VSVklDRV9GUUROX0RBU0hCT0FSRH0nCiAgICAgIC0gJ0NPTlZFWF9TSVRFX09SSUdJTj0ke1NFUlZJQ0VfRlFETl9CQUNLRU5EfScKICAgICAgLSAnREFUQUJBU0VfVVJMPSR7REFUQUJBU0VfVVJMOi19JwogICAgICAtICdESVNBQkxFX0JFQUNPTj0ke0RJU0FCTEVfQkVBQ09OOj9mYWxzZX0nCiAgICAgIC0gJ1JFREFDVF9MT0dTX1RPX0NMSUVOVD0ke1JFREFDVF9MT0dTX1RPX0NMSUVOVDo/ZmFsc2V9JwogICAgICAtICdET19OT1RfUkVRVUlSRV9TU0w9JHtET19OT1RfUkVRVUlSRV9TU0w6P3RydWV9JwogICAgICAtICdQT1NUR1JFU19VUkw9JHtQT1NUR1JFU19VUkw6LX0nCiAgICAgIC0gJ01ZU1FMX1VSTD0ke01ZU1FMX1VSTDotfScKICAgICAgLSAnUlVTVF9MT0c9JHtSVVNUX0xPRzotaW5mb30nCiAgICAgIC0gJ1JVU1RfQkFDS1RSQUNFPSR7UlVTVF9CQUNLVFJBQ0U6LX0nCiAgICAgIC0gJ0FXU19SRUdJT049JHtBV1NfUkVHSU9OOi19JwogICAgICAtICdBV1NfQUNDRVNTX0tFWV9JRD0ke0FXU19BQ0NFU1NfS0VZX0lEOi19JwogICAgICAtICdBV1NfU0VDUkVUX0FDQ0VTU19LRVk9JHtBV1NfU0VDUkVUX0FDQ0VTU19LRVk6LX0nCiAgICAgIC0gJ0FXU19TRVNTSU9OX1RPS0VOPSR7QVdTX1NFU1NJT05fVE9LRU46LX0nCiAgICAgIC0gJ0FXU19TM19GT1JDRV9QQVRIX1NUWUxFPSR7QVdTX1MzX0ZPUkNFX1BBVEhfU1RZTEU6LX0nCiAgICAgIC0gJ0FXU19TM19ESVNBQkxFX1NTRT0ke0FXU19TM19ESVNBQkxFX1NTRTotfScKICAgICAgLSAnQVdTX1MzX0RJU0FCTEVfQ0hFQ0tTVU1TPSR7QVdTX1MzX0RJU0FCTEVfQ0hFQ0tTVU1TOi19JwogICAgICAtICdTM19TVE9SQUdFX0VYUE9SVFNfQlVDS0VUPSR7UzNfU1RPUkFHRV9FWFBPUlRTX0JVQ0tFVDotfScKICAgICAgLSAnUzNfU1RPUkFHRV9TTkFQU0hPVF9JTVBPUlRTX0JVQ0tFVD0ke1MzX1NUT1JBR0VfU05BUFNIT1RfSU1QT1JUU19CVUNLRVQ6LX0nCiAgICAgIC0gJ1MzX1NUT1JBR0VfTU9EVUxFU19CVUNLRVQ9JHtTM19TVE9SQUdFX01PRFVMRVNfQlVDS0VUOi19JwogICAgICAtICdTM19TVE9SQUdFX0ZJTEVTX0JVQ0tFVD0ke1MzX1NUT1JBR0VfRklMRVNfQlVDS0VUOi19JwogICAgICAtICdTM19TVE9SQUdFX1NFQVJDSF9CVUNLRVQ9JHtTM19TVE9SQUdFX1NFQVJDSF9CVUNLRVQ6LX0nCiAgICAgIC0gJ1MzX0VORFBPSU5UX1VSTD0ke1MzX0VORFBPSU5UX1VSTDotfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAnY3VybCAtZiBodHRwOi8vMTI3LjAuMC4xOjMyMTAvdmVyc2lvbicKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHN0YXJ0X3BlcmlvZDogMTBzCiAgZGFzaGJvYXJkOgogICAgaW1hZ2U6ICdnaGNyLmlvL2dldC1jb252ZXgvY29udmV4LWRhc2hib2FyZDozM2NlZjc3NWE4YTYyMjhjYmFjZWU0YTA5YWMyYzQwNzNkNjJlZDEzJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0RBU0hCT0FSRF82NzkxCiAgICAgIC0gJ05FWFRfUFVCTElDX0RFUExPWU1FTlRfVVJMPSR7U0VSVklDRV9GUUROX0JBQ0tFTkR9JwogICAgZGVwZW5kc19vbjoKICAgICAgYmFja2VuZDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6ICd3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo2NzkxLycKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHN0YXJ0X3BlcmlvZDogNXMK",
"tags": [
"database",
"reactive",
@ -3375,6 +3375,19 @@
"minversion": "0.0.0",
"port": "9000"
},
"postgresus": {
"documentation": "https://postgresus.com/#guide?utm_source=coolify.io",
"slogan": "Postgresus is a free, open source and self-hosted tool to backup PostgreSQL.",
"compose": "c2VydmljZXM6CiAgcG9zdGdyZXN1czoKICAgIGltYWdlOiAncm9zdGlzbGF2ZHVnaW4vcG9zdGdyZXN1czo3ZmI1OWJiNWQwMmZiYWY4NTZiMGJjZmM3YTA3ODY1NzU4MThiOTZmJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX1BPU1RHUkVTVVNfNDAwNQogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXN1cy1kYXRhOi9wb3N0Z3Jlc3VzLWRhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gd2dldAogICAgICAgIC0gJy1xTy0nCiAgICAgICAgLSAnaHR0cDovL2xvY2FsaG9zdDo0MDA1L2FwaS92MS9zeXN0ZW0vaGVhbHRoJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDUK",
"tags": [
"postgres",
"backup"
],
"category": "devtools",
"logo": "svgs/postgresus.svg",
"minversion": "0.0.0",
"port": "4005"
},
"postiz": {
"documentation": "https://docs.postiz.com?utm_source=coolify.io",
"slogan": "Open source social media scheduling tool.",

View 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();
});

View 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);
});

View file

@ -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:

View file

@ -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',